always-on calendar, currency cards

This commit is contained in:
Marshal
2026-08-05 07:03:39 +00:00
parent 5d68a3f7b7
commit 2f576fd1de
6 changed files with 229 additions and 115 deletions

View File

@@ -20,7 +20,6 @@ import {
Loader, Loader,
Modal, Modal,
Paper, Paper,
SegmentedControl,
Select, Select,
Stack, Stack,
Switch, Switch,
@@ -49,7 +48,11 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common"; import {
CurrencySelector,
ExportTrainPicker,
OperationDatePicker,
} from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { PageContainer } from "@/components/page"; import { PageContainer } from "@/components/page";
@@ -1713,47 +1716,42 @@ export default function GlCreateBookingForm() {
title="Schedule" title="Schedule"
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected." description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected."
/> />
{cargoQuery === null ? ( <Box>
<Alert <StepLabel>Shipment day *</StepLabel>
color="yellow" <Box mt={10} w="100%">
variant="light" {/* Calendar stays visible before cargo is entered — all days
radius="md" disabled with a hint, since availability depends on cargo. */}
icon={<AlertCircle size={16} />} <OperationDatePicker
> fullWidth
Enter your cargo details first available shipment days depend availableDays={cargoQuery === null ? [] : (availableDays ?? [])}
on the wagons your cargo needs. isLoading={cargoQuery !== null && daysLoading}
</Alert> emptyMessage={
) : ( cargoQuery === null
<Box> ? "Enter the cargo details first — available shipment days depend on the wagons the cargo needs."
<StepLabel>Shipment day *</StepLabel> : undefined
<Box mt={10} w="100%"> }
<OperationDatePicker value={scheduledDate}
fullWidth onChange={(d) => {
availableDays={availableDays ?? []} setScheduledDate(d);
isLoading={daysLoading} // A new day invalidates the old train pick.
value={scheduledDate} setTrainScheduleId("");
onChange={(d) => { }}
setScheduledDate(d); />
// A new day invalidates the old train pick.
setTrainScheduleId("");
}}
/>
</Box>
{showErrors && dateError && (
<Text fz="xs" c="red" mt={6}>
{dateError}
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={trainScheduleId}
onChange={setTrainScheduleId}
/>
) : null}
</Box> </Box>
)} {showErrors && dateError && (
<Text fz="xs" c="red" mt={6}>
{dateError}
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={trainScheduleId}
onChange={setTrainScheduleId}
/>
) : null}
</Box>
</StepCard> </StepCard>
)} )}
@@ -1769,16 +1767,10 @@ export default function GlCreateBookingForm() {
? "Requested by the customer on their shipment request." ? "Requested by the customer on their shipment request."
: "The contract is quoted in USD — pick the currency this shipment is invoiced in."} : "The contract is quoted in USD — pick the currency this shipment is invoiced in."}
</Text> </Text>
<SegmentedControl <CurrencySelector
value={isIntercity ? "ETB" : paymentCurrency} value={isIntercity ? "ETB" : paymentCurrency}
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")} onChange={setPaymentCurrency}
disabled={isIntercity} disabled={isIntercity}
data={[
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]}
color="edr-green"
radius={10}
/> />
</Box> </Box>

View File

@@ -22,7 +22,6 @@ import {
Loader, Loader,
Modal, Modal,
Paper, Paper,
SegmentedControl,
Stack, Stack,
Switch, Switch,
Text, Text,
@@ -50,7 +49,11 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common"; import {
CurrencySelector,
ExportTrainPicker,
OperationDatePicker,
} from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { import {
contractsService, contractsService,
@@ -1144,75 +1147,56 @@ function ScheduleStep({
Your contract is quoted in USD. Pick the currency this shipment is Your contract is quoted in USD. Pick the currency this shipment is
invoiced in the total is converted for you. invoiced in the total is converted for you.
</Text> </Text>
{/* Rendered unselected until the customer chooses: SegmentedControl <CurrencySelector
highlights whatever value it is given, so passing a fallback
here would look like a made choice. */}
<SegmentedControl
value={field.value || ""} value={field.value || ""}
onChange={(v) => field.onChange(v)} onChange={(v) => field.onChange(v)}
data={[ error={fieldState.error?.message}
{ label: "Select…", value: "", disabled: true },
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]}
color="edr-green"
radius={10}
/> />
{fieldState.error && (
<Text fz={12.5} c="red.6" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box> </Box>
)} )}
/> />
{cargoQuery === null ? ( <Controller
<Alert name="scheduledDate"
color="yellow" control={form.control}
variant="light" render={({ field, fieldState }) => (
radius="md" <Box>
icon={<AlertCircle size={16} />} <StepLabel>Shipment day *</StepLabel>
> <Box mt={10} w="100%">
Enter your cargo details first available shipment days depend on the {/* Calendar stays visible before cargo is entered — all days
wagons your cargo needs. disabled with a hint, since availability depends on cargo. */}
</Alert> <OperationDatePicker
) : ( fullWidth
<Controller availableDays={cargoQuery === null ? [] : (availableDays ?? [])}
name="scheduledDate" isLoading={cargoQuery !== null && isLoading}
control={form.control} emptyMessage={
render={({ field, fieldState }) => ( cargoQuery === null
<Box> ? "Enter your cargo details first — available shipment days depend on the wagons your cargo needs."
<StepLabel>Shipment day *</StepLabel> : undefined
<Box mt={10} w="100%"> }
<OperationDatePicker value={field.value ?? ""}
fullWidth onChange={(d) => {
availableDays={availableDays ?? []} field.onChange(d);
isLoading={isLoading} // A new day invalidates the old train pick.
value={field.value ?? ""} form.setValue("trainScheduleId", "");
onChange={(d) => { }}
field.onChange(d); />
// A new day invalidates the old train pick.
form.setValue("trainScheduleId", "");
}}
/>
</Box>
{fieldState.error?.message && (
<Text fz="xs" c="red" mt={6}>
{fieldState.error.message}
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={selectedTrainId ?? ""}
onChange={(id) => form.setValue("trainScheduleId", id)}
/>
) : null}
</Box> </Box>
)} {fieldState.error?.message && (
/> <Text fz="xs" c="red" mt={6}>
)} {fieldState.error.message}
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={selectedTrainId ?? ""}
onChange={(id) => form.setValue("trainScheduleId", id)}
/>
) : null}
</Box>
)}
/>
</StepCard> </StepCard>
); );
} }

View File

@@ -0,0 +1,131 @@
import { Box, Text } from "@mantine/core";
import { Check } from "lucide-react";
export interface CurrencySelectorProps {
/** Selected currency code, or "" when none picked yet. */
value: string;
onChange: (currency: "USD" | "ETB") => void;
disabled?: boolean;
/** Validation error shown under the cards. */
error?: string;
}
const OPTIONS = [
{
code: "USD",
symbol: "$",
name: "US Dollar",
hint: "As quoted on the contract",
},
{
code: "ETB",
symbol: "Br",
name: "Ethiopian Birr",
hint: "Converted from the USD total",
},
] as const;
/**
* Card-style USD/ETB billing-currency picker. Renders unselected when `value`
* is "" so a required choice never looks pre-made.
*/
export function CurrencySelector({
value,
onChange,
disabled = false,
error,
}: CurrencySelectorProps) {
return (
<Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 10,
}}
>
{OPTIONS.map((o) => {
const selected = value === o.code;
return (
<button
key={o.code}
type="button"
disabled={disabled}
aria-pressed={selected}
onClick={() => onChange(o.code)}
style={{
display: "flex",
alignItems: "center",
gap: 12,
textAlign: "left",
padding: "12px 14px",
borderRadius: 12,
border: selected
? "1.5px solid #12B981"
: error
? "1px solid #FCA5A5"
: "1px solid #E6ECF2",
background: selected ? "#F6FBF8" : "#fff",
cursor: disabled ? "default" : "pointer",
opacity: disabled && !selected ? 0.55 : 1,
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 15,
fontWeight: 800,
background: selected ? "#ECF6F1" : "#F1F4F7",
color: selected ? "#0A6F4D" : "#6B7C8E",
}}
>
{o.symbol}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz={14} fw={700} c="#10202F" lh={1.25}>
{o.code}
<Text component="span" fz={12.5} fw={500} c="dimmed">
{" "}
· {o.name}
</Text>
</Text>
<Text fz={11.5} c="dimmed" lh={1.35}>
{o.hint}
</Text>
</Box>
<Box
style={{
width: 20,
height: 20,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: selected ? "none" : "1.5px solid #D4DDE5",
background: selected ? "#12B981" : "transparent",
}}
>
{selected && <Check size={12} color="#fff" strokeWidth={3.5} />}
</Box>
</button>
);
})}
</Box>
{error && (
<Text fz={12.5} c="red.6" mt={6}>
{error}
</Text>
)}
</Box>
);
}
export default CurrencySelector;

View File

@@ -0,0 +1,2 @@
export { CurrencySelector, default } from "./CurrencySelector";
export type { CurrencySelectorProps } from "./CurrencySelector";

View File

@@ -18,6 +18,8 @@ export interface OperationDatePickerProps {
onChange: (date: string) => void; onChange: (date: string) => void;
/** Stretch to the full width of the parent container. */ /** Stretch to the full width of the parent container. */
fullWidth?: boolean; fullWidth?: boolean;
/** Text shown under the grid when no days are available. */
emptyMessage?: string;
} }
/** `yyyy-MM-dd` for a local date. */ /** `yyyy-MM-dd` for a local date. */
@@ -58,6 +60,7 @@ export function OperationDatePicker({
value, value,
onChange, onChange,
fullWidth = false, fullWidth = false,
emptyMessage = "No scheduled departures found for this route yet.",
}: OperationDatePickerProps) { }: OperationDatePickerProps) {
const [month, setMonth] = useState(() => { const [month, setMonth] = useState(() => {
const now = new Date(); const now = new Date();
@@ -270,7 +273,7 @@ export function OperationDatePicker({
)} )}
{departureDays.size === 0 && ( {departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm" ta="center"> <Text fz="12px" c="orange.7" mt="sm" ta="center">
No scheduled departures found for this route yet. {emptyMessage}
</Text> </Text>
)} )}
</> </>

View File

@@ -26,6 +26,8 @@ export { OperationDatePicker } from "./components/OperationDatePicker";
export type { OperationDatePickerProps } from "./components/OperationDatePicker"; export type { OperationDatePickerProps } from "./components/OperationDatePicker";
export { ExportTrainPicker } from "./components/ExportTrainPicker"; export { ExportTrainPicker } from "./components/ExportTrainPicker";
export type { ExportTrainPickerProps } from "./components/ExportTrainPicker"; export type { ExportTrainPickerProps } from "./components/ExportTrainPicker";
export { CurrencySelector } from "./components/CurrencySelector";
export type { CurrencySelectorProps } from "./components/CurrencySelector";
export { CountdownTimer } from "./components/CountdownTimer"; export { CountdownTimer } from "./components/CountdownTimer";
export type { CountdownTimerProps } from "./components/CountdownTimer"; export type { CountdownTimerProps } from "./components/CountdownTimer";