mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
302 lines
8.5 KiB
TypeScript
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;
|