mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
always-on calendar, currency cards
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CurrencySelector, default } from "./CurrencySelector";
|
||||||
|
export type { CurrencySelectorProps } from "./CurrencySelector";
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user