Files
edr-platform/packages/ui-common/src/components/OperationDatePicker/OperationDatePicker.tsx
2026-07-01 20:55:17 +03:00

302 lines
8.5 KiB
TypeScript

import { Box, Button, Group, Text } from "@mantine/core";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useMemo, useState } from "react";
export interface OperationDatePickerProps {
/** Selectable days as `yyyy-MM-dd` strings. */
availableDays: string[];
/** Show the loading state instead of the grid. */
isLoading?: boolean;
/** Currently selected day as `yyyy-MM-dd`, or "" when none. */
value: string;
/** Called with the picked `yyyy-MM-dd` day. */
onChange: (date: string) => void;
/** Stretch to the full width of the parent container. */
fullWidth?: boolean;
}
/** `yyyy-MM-dd` for a local date. */
function fmtDay(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const WEEKDAY_LABELS = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
/**
* Presentational month calendar for picking a binding shipment day. Only the
* `availableDays` (passed in by the caller, which owns the query) are
* selectable; every other day is disabled. Framework-light: no data fetching,
* no date library — both the portal and backoffice feed it their own
* availability results so the picker renders identically in each app.
*/
export function OperationDatePicker({
availableDays,
isLoading = false,
value,
onChange,
fullWidth = false,
}: OperationDatePickerProps) {
const [month, setMonth] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const cells = useMemo(() => {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const lead = (first.getDay() + 6) % 7;
const start = new Date(first);
start.setDate(first.getDate() - lead);
const today = new Date();
const todayStr = fmtDay(today);
return Array.from({ length: 42 }, (_, i) => {
const date = new Date(start);
date.setDate(start.getDate() + i);
const dateString = fmtDay(date);
return {
dateString,
day: date.getDate(),
inMonth: date.getMonth() === month.getMonth(),
today: dateString === todayStr,
selected: value === dateString,
hasDeparture: departureDays.has(dateString),
};
});
}, [month, departureDays, value]);
const availableInMonth = useMemo(
() => cells.filter((c) => c.inMonth && c.hasDeparture).length,
[cells],
);
const shiftMonth = (delta: number) =>
setMonth((m) => new Date(m.getFullYear(), m.getMonth() + delta, 1));
return (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: fullWidth ? "12px 16px" : 14,
width: fullWidth ? "100%" : undefined,
maxWidth: fullWidth ? undefined : 340,
}}
>
<Group
justify="space-between"
align="center"
mb="sm"
px={fullWidth ? 4 : 0}
style={
fullWidth
? {
borderBottom: "1px solid #EEF2F6",
paddingBottom: 10,
}
: undefined
}
>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => shiftMonth(-1)}
>
<ChevronLeft size={15} />
</Button>
<StackedMonthHeader month={month} availableCount={availableInMonth} />
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => shiftMonth(1)}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{WEEKDAY_LABELS.map((d) => (
<Text key={d} ta="center" fz="10px" fw={700} c="#9AA8B5">
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: c.today
? "1.5px solid #94A3B8"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Group
justify="center"
mt="sm"
py={6}
px={10}
style={{
borderRadius: 8,
background: "#F4FBF7",
border: "1px solid #CDEBDD",
}}
>
<Text fz="12px" c="#0A6F4D" fw={600}>
Selected:{" "}
{new Date(value + "T00:00:00").toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
})}
</Text>
</Group>
)}
{departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm" ta="center">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
);
}
function StackedMonthHeader({
month,
availableCount,
}: {
month: Date;
availableCount: number;
}) {
return (
<Box style={{ textAlign: "center" }}>
<Text fz="14px" fw={800} c="#10202F" lh={1.2}>
{MONTH_NAMES[month.getMonth()]} {month.getFullYear()}
</Text>
<Text fz="11px" c="#64748B" mt={2}>
{availableCount} available day{availableCount === 1 ? "" : "s"}
</Text>
</Box>
);
}
export default OperationDatePicker;