Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-24 03:21:22 +03:00
31 changed files with 1251 additions and 226 deletions

View File

@@ -8,10 +8,22 @@ import {
Button,
Textarea,
FileInput,
NumberInput,
} from "@mantine/core";
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
/** Today + `days`, formatted as a readable date for the validity preview. */
function validUntilLabel(days: number): string {
const until = new Date();
until.setDate(until.getDate() + days);
return until.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
interface BookingConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -46,8 +58,14 @@ export function BookingConfirmDialog({
const Icon = action.icon;
const needsTextInput = action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file";
const needsDaysInput = action.input === "days";
const daysValue = Number(inputValue.trim());
const daysValid =
Number.isInteger(daysValue) && daysValue >= 1 && daysValue <= 365;
const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
(needsTextInput && !inputValue.trim()) ||
(needsFileInput && !selectedFile) ||
(needsDaysInput && !daysValid);
const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "edr-green";
@@ -129,6 +147,27 @@ export function BookingConfirmDialog({
clearable
/>
)}
{needsDaysInput && (
<Stack gap={4}>
<NumberInput
label={action.inputLabel ?? "Contract validity (days)"}
withAsterisk
min={1}
max={365}
clampBehavior="strict"
allowDecimal={false}
allowNegative={false}
placeholder={action.inputPlaceholder ?? "e.g. 30"}
value={inputValue === "" ? "" : Number(inputValue)}
onChange={(value) => onInputChange(value === "" ? "" : String(value))}
/>
<Text size="xs" c="dimmed">
{daysValid
? `Contract valid from today until ${validUntilLabel(daysValue)} (${daysValue} day${daysValue === 1 ? "" : "s"}).`
: "Enter a whole number of days between 1 and 365."}
</Text>
</Stack>
)}
{extra}
</Stack>

View File

@@ -9,6 +9,12 @@ import {
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
/** A contract validity window must be a whole number of days, 1365. */
function isValidValidityDays(value: string): boolean {
const days = Number(value.trim());
return Number.isInteger(days) && days >= 1 && days <= 365;
}
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -59,9 +65,12 @@ export function useBookingActionDialog(
const onSuccess = () => closeDialog();
switch (pendingAction.id) {
case "accept":
mutations.staffAccept.mutate(undefined, { onSuccess });
case "accept": {
const days = Number(inputValue.trim());
if (!Number.isInteger(days) || days < 1 || days > 365) return;
mutations.staffAccept.mutate(days, { onSuccess });
break;
}
case "requestChanges":
mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
break;
@@ -116,7 +125,8 @@ export function useBookingActionDialog(
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim());
(pendingAction?.input === "note" && !inputValue.trim()) ||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
return {
actions,

View File

@@ -36,7 +36,7 @@ export type BookingActionId =
| "complete"
| "cancel";
export type BookingActionInputKind = "note" | "reason" | "file";
export type BookingActionInputKind = "note" | "reason" | "file" | "days";
export interface BookingActionDef {
id: BookingActionId;
@@ -121,10 +121,13 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
description: "Start the formal approval chain",
confirmTitle: "Accept submission?",
confirmDescription:
"The booking moves to pending approval and approval steps are created from the rule engine.",
"Set how long the contract stays valid, then the booking moves to pending approval and approval steps are created from the rule engine.",
variant: "default",
icon: ShieldCheck,
primary: true,
input: "days",
inputLabel: "Contract validity (days)",
inputPlaceholder: "e.g. 30",
},
{
id: "requestChanges",

View File

@@ -41,7 +41,8 @@ export function useBookingMutations(bookingId: string) {
};
const staffAccept = useMutation({
mutationFn: () => api.bookings.staffAccept.call({ id: bookingId }),
mutationFn: (validityDays: number) =>
api.bookings.staffAccept.call({ id: bookingId, validityDays }),
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
onError: () => toast.error("Failed to accept booking"),
});

View File

@@ -1817,10 +1817,10 @@ export const api = {
({ id }) => bookingsService.remove(id),
),
staffAccept: endpoint<{ id: string }, BookingDetail>(
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
"bookings",
"staffAccept",
({ id }) => bookingsService.staffAccept(id),
({ id, validityDays }) => bookingsService.staffAccept(id, validityDays),
),
requestChanges: endpoint<{ id: string; note: string }, BookingDetail>(

View File

@@ -200,7 +200,8 @@ export const bookingsService = {
finalizeClearance: (id: string) =>
postBooking<BookingDetail>(`/bookings/${id}/clearance/finalize`),
staffAccept: (id: string) => postBooking<BookingDetail>(B.STAFF_ACCEPT(id)),
staffAccept: (id: string, validityDays: number) =>
postBooking<BookingDetail>(B.STAFF_ACCEPT(id), { validityDays }),
requestChanges: (id: string, note: string) =>
postBooking<BookingDetail>(B.STAFF_REQUEST_CHANGES(id), { note }),

View File

@@ -123,6 +123,10 @@ export interface BookingDetail {
adjustedByStaffId?: string | null;
adjustedAt?: string | null;
adjustmentReason?: string | null;
/** Contract validity window set by the backoffice when accepting. */
contractValidityDays?: number | null;
contractValidFrom?: string | null;
contractValidUntil?: string | null;
pricingBreakdown?: {
currency: string;
totalAmount: number;