mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat
This commit is contained in:
@@ -302,6 +302,26 @@ export class ContractTransitionService {
|
|||||||
return { view, html, signatures: view.signatures };
|
return { view, html, signatures: view.signatures };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the stored `contract` PDF from the current aggregate (now including
|
||||||
|
* the latest signatures) so the downloaded/viewed file matches the live HTML
|
||||||
|
* view. Best-effort — a Chromium hiccup must never fail the signing
|
||||||
|
* transaction; the doc still re-renders live on /contract/view.
|
||||||
|
*/
|
||||||
|
private async regenerateContractPdf(
|
||||||
|
contractId: string,
|
||||||
|
reference: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||||
|
await this.upsertContractPdf(contractId, reference, view);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Render the contract PDF and upsert it as the `contract` file on the contract. */
|
/** Render the contract PDF and upsert it as the `contract` file on the contract. */
|
||||||
private async upsertContractPdf(
|
private async upsertContractPdf(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -458,6 +478,7 @@ export class ContractTransitionService {
|
|||||||
status: 'SIGNED_CUSTOMER',
|
status: 'SIGNED_CUSTOMER',
|
||||||
customerSignedAt: new Date(),
|
customerSignedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
|
await this.regenerateContractPdf(contractId, contract.reference);
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +538,7 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, updates as never);
|
await this.contractsRepository.update(contractId, updates as never);
|
||||||
|
await this.regenerateContractPdf(contractId, contract.reference);
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ import NewBookingPage from "./pages/bookings/NewBookingPage";
|
|||||||
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
|
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
|
||||||
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
|
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
|
||||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
|
||||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
|
||||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
@@ -134,18 +132,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <ShieldCheck />,
|
icon: <ShieldCheck />,
|
||||||
permission: FREIGHT_PERMS.bookings.reviewDocuments,
|
permission: FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Contract Clearance",
|
|
||||||
href: "/dashboard/contracts/clearance",
|
|
||||||
icon: <ShieldCheck />,
|
|
||||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Self-Clearance Review",
|
|
||||||
href: "/dashboard/contracts/ops-clearance",
|
|
||||||
icon: <ShieldCheck />,
|
|
||||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
@@ -467,34 +453,15 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contracts/clearance"
|
path="contracts/clearance"
|
||||||
element={
|
element={<Navigate to="/dashboard/clearance" replace />}
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
|
||||||
<ContractClearanceListPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contracts/ops-clearance"
|
path="contracts/ops-clearance"
|
||||||
element={
|
element={<Navigate to="/dashboard/clearance?mode=ops" replace />}
|
||||||
<RequirePermission
|
|
||||||
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
|
||||||
>
|
|
||||||
<ContractClearanceListPage opsMode />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contracts/clearance/:id"
|
path="contracts/clearance/:id"
|
||||||
element={
|
element={<Navigate to="/dashboard/clearance" replace />}
|
||||||
<RequirePermission
|
|
||||||
permission={[
|
|
||||||
FREIGHT_PERMS.contracts.clearanceReview,
|
|
||||||
FREIGHT_PERMS.contracts.opsClearanceReview,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ContractClearanceDetailPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contracts/:id/create-booking"
|
path="contracts/:id/create-booking"
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function ClearanceReviewSection({
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||||
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const { data: clearance, isLoading } = useQuery({
|
const { data: clearance, isLoading } = useQuery({
|
||||||
@@ -100,13 +100,17 @@ export function ClearanceReviewSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const outputMutation = useMutation({
|
const outputMutation = useMutation({
|
||||||
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles),
|
mutationFn: (files: Record<string, File>) =>
|
||||||
|
bookingsService.uploadClearanceOutput(bookingId, files),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Output documents uploaded");
|
toast.success("Document uploaded");
|
||||||
setOutputFiles({});
|
setUploadingKey(null);
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Upload failed"),
|
onError: () => {
|
||||||
|
toast.error("Upload failed");
|
||||||
|
setUploadingKey(null);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalizeMutation = useMutation({
|
const finalizeMutation = useMutation({
|
||||||
@@ -223,99 +227,100 @@ export function ClearanceReviewSection({
|
|||||||
<SectionCard
|
<SectionCard
|
||||||
icon={Upload}
|
icon={Upload}
|
||||||
title="Customs output documents"
|
title="Customs output documents"
|
||||||
subtitle="Upload the cleared/customs paperwork to hand back to the customer."
|
subtitle="Upload each document individually — changes save immediately."
|
||||||
accent="edr-green"
|
accent="edr-green"
|
||||||
>
|
>
|
||||||
<Stack gap={10}>
|
<Stack gap={10}>
|
||||||
{glDocs.map((doc) => (
|
{glDocs.map((doc) => {
|
||||||
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
const isUploading =
|
||||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
uploadingKey === doc.fileKey && outputMutation.isPending;
|
||||||
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
|
return (
|
||||||
<Text fz="13px" c="edr-text" truncate>
|
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
||||||
{doc.label}
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
{doc.required ? " *" : ""}
|
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
|
||||||
</Text>
|
<Text fz="13px" c="edr-text" truncate>
|
||||||
</Group>
|
{doc.label}
|
||||||
<Group gap={8} wrap="nowrap">
|
{doc.required ? " *" : ""}
|
||||||
{doc.file ? (
|
</Text>
|
||||||
<>
|
</Group>
|
||||||
{isViewable({
|
<Group gap={8} wrap="nowrap">
|
||||||
name: doc.file.name,
|
{doc.file ? (
|
||||||
url: fileViewUrl(doc.file.id),
|
<>
|
||||||
}) && (
|
{isViewable({
|
||||||
<Tooltip label="View">
|
name: doc.file.name,
|
||||||
|
url: fileViewUrl(doc.file.id),
|
||||||
|
}) && (
|
||||||
|
<Tooltip label="View">
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: doc.file!.name,
|
||||||
|
url: fileViewUrl(doc.file!.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c="edr-green"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Download">
|
||||||
<Box
|
<Box
|
||||||
component="button"
|
component="a"
|
||||||
type="button"
|
href={fileViewUrl(doc.file.id, true)}
|
||||||
onClick={() =>
|
|
||||||
view({
|
|
||||||
name: doc.file!.name,
|
|
||||||
url: fileViewUrl(doc.file!.id),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{
|
style={{ display: "flex" }}
|
||||||
display: "flex",
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Eye size={15} />
|
<Download size={15} />
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
</>
|
||||||
<Tooltip label="Download">
|
) : (
|
||||||
<Box
|
<Text fz="12px" c="edr-muted">
|
||||||
component="a"
|
Not uploaded
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
</Text>
|
||||||
c="edr-green"
|
|
||||||
style={{ display: "flex" }}
|
|
||||||
>
|
|
||||||
<Download size={15} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text fz="12px" c="edr-muted">
|
|
||||||
Not uploaded
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<FileButton
|
|
||||||
onChange={(f) =>
|
|
||||||
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
|
|
||||||
}
|
|
||||||
accept="application/pdf,image/*"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Upload size={13} />}
|
|
||||||
>
|
|
||||||
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</FileButton>
|
<FileButton
|
||||||
|
onChange={(f) => {
|
||||||
|
if (!f) return;
|
||||||
|
setUploadingKey(doc.fileKey);
|
||||||
|
outputMutation.mutate({ [doc.fileKey]: f });
|
||||||
|
}}
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={
|
||||||
|
isUploading ? (
|
||||||
|
<Loader size={12} color="edr-green" />
|
||||||
|
) : (
|
||||||
|
<Upload size={13} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loading={isUploading}
|
||||||
|
>
|
||||||
|
{doc.file ? "Replace" : "Upload"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
);
|
||||||
))}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Upload size={15} />}
|
|
||||||
disabled={Object.keys(outputFiles).length === 0}
|
|
||||||
loading={outputMutation.isPending}
|
|
||||||
onClick={() => outputMutation.mutate()}
|
|
||||||
>
|
|
||||||
Upload output documents
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function ContractClearanceReviewSection({
|
|||||||
}: ContractClearanceReviewSectionProps) {
|
}: ContractClearanceReviewSectionProps) {
|
||||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||||
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
|
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
|
||||||
@@ -242,110 +242,106 @@ export function ContractClearanceReviewSection({
|
|||||||
<SectionCard
|
<SectionCard
|
||||||
icon={Upload}
|
icon={Upload}
|
||||||
title="GL output documents"
|
title="GL output documents"
|
||||||
subtitle="Upload IM4/IM5/EX3/EX8/T1 and other cleared paperwork."
|
subtitle="Upload each document individually — changes save immediately."
|
||||||
accent="edr-green"
|
accent="edr-green"
|
||||||
>
|
>
|
||||||
<Stack gap={10}>
|
<Stack gap={10}>
|
||||||
{glDocs.map((doc) => (
|
{glDocs.map((doc) => {
|
||||||
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
const isUploading =
|
||||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
uploadingKey === doc.fileKey && uploadOutputDocuments.isPending;
|
||||||
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
|
return (
|
||||||
<Text fz="13px" c="edr-text" truncate>
|
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
||||||
{doc.label}
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
{doc.required ? " *" : ""}
|
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
|
||||||
</Text>
|
<Text fz="13px" c="edr-text" truncate>
|
||||||
</Group>
|
{doc.label}
|
||||||
<Group gap={8} wrap="nowrap">
|
{doc.required ? " *" : ""}
|
||||||
{doc.file ? (
|
</Text>
|
||||||
<>
|
</Group>
|
||||||
{isViewable({
|
<Group gap={8} wrap="nowrap">
|
||||||
name: doc.file.name,
|
{doc.file ? (
|
||||||
url: fileViewUrl(doc.file.id),
|
<>
|
||||||
}) && (
|
{isViewable({
|
||||||
<Tooltip label="View">
|
name: doc.file.name,
|
||||||
|
url: fileViewUrl(doc.file.id),
|
||||||
|
}) && (
|
||||||
|
<Tooltip label="View">
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: doc.file!.name,
|
||||||
|
url: fileViewUrl(doc.file!.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c="edr-green"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Download">
|
||||||
<Box
|
<Box
|
||||||
component="button"
|
component="a"
|
||||||
type="button"
|
href={fileViewUrl(doc.file.id, true)}
|
||||||
onClick={() =>
|
|
||||||
view({
|
|
||||||
name: doc.file!.name,
|
|
||||||
url: fileViewUrl(doc.file!.id),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{
|
style={{ display: "flex" }}
|
||||||
display: "flex",
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Eye size={15} />
|
<Download size={15} />
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
</>
|
||||||
<Tooltip label="Download">
|
) : (
|
||||||
<Box
|
<Text fz="12px" c="edr-muted">
|
||||||
component="a"
|
Not uploaded
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
</Text>
|
||||||
c="edr-green"
|
)}
|
||||||
style={{ display: "flex" }}
|
{!readOnly && (
|
||||||
>
|
<FileButton
|
||||||
<Download size={15} />
|
onChange={(f) => {
|
||||||
</Box>
|
if (!f) return;
|
||||||
</Tooltip>
|
setUploadingKey(doc.fileKey);
|
||||||
</>
|
uploadOutputDocuments.mutate(
|
||||||
) : (
|
{ [doc.fileKey]: f },
|
||||||
<Text fz="12px" c="edr-muted">
|
{ onSuccess: () => { setUploadingKey(null); onChanged?.(); },
|
||||||
Not uploaded
|
onError: () => setUploadingKey(null) },
|
||||||
</Text>
|
);
|
||||||
)}
|
}}
|
||||||
{!readOnly && (
|
accept="application/pdf,image/*"
|
||||||
<FileButton
|
disabled={isUploading}
|
||||||
onChange={(f) =>
|
>
|
||||||
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
|
{(props) => (
|
||||||
}
|
<Button
|
||||||
accept="application/pdf,image/*"
|
{...props}
|
||||||
>
|
size="compact-xs"
|
||||||
{(props) => (
|
variant="light"
|
||||||
<Button
|
color="edr-green"
|
||||||
{...props}
|
leftSection={
|
||||||
size="compact-xs"
|
isUploading ? (
|
||||||
variant="light"
|
<Loader size={12} color="edr-green" />
|
||||||
color="edr-green"
|
) : (
|
||||||
leftSection={<Upload size={13} />}
|
<Upload size={13} />
|
||||||
>
|
)
|
||||||
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
|
}
|
||||||
</Button>
|
loading={isUploading}
|
||||||
)}
|
>
|
||||||
</FileButton>
|
{doc.file ? "Replace" : "Upload"}
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
);
|
||||||
))}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
{!readOnly && (
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Upload size={15} />}
|
|
||||||
disabled={Object.keys(outputFiles).length === 0}
|
|
||||||
loading={uploadOutputDocuments.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
uploadOutputDocuments.mutate(outputFiles, {
|
|
||||||
onSuccess: () => {
|
|
||||||
setOutputFiles({});
|
|
||||||
onChanged?.();
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Upload output documents
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
History,
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -46,12 +48,16 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
|||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import {
|
import {
|
||||||
CLEARANCE_REVIEW_STATUS,
|
|
||||||
CLEARANCE_TABS,
|
CLEARANCE_TABS,
|
||||||
type ClearanceTabKey,
|
type ClearanceTabKey,
|
||||||
} from "@/features/clearance/clearance-tabs.config";
|
} from "@/features/clearance/clearance-tabs.config";
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
|
type PageTab = "queue" | "history";
|
||||||
|
type ClearanceMode = "gl" | "ops";
|
||||||
|
|
||||||
|
const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||||
|
const CLEARANCE_HISTORY_STATUS = "CLEARANCE_READY";
|
||||||
|
|
||||||
interface ClearanceRow {
|
interface ClearanceRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -62,6 +68,7 @@ interface ClearanceRow {
|
|||||||
originLabel: string;
|
originLabel: string;
|
||||||
destinationLabel: string;
|
destinationLabel: string;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
|
updatedAt: string;
|
||||||
hasCustoms: boolean;
|
hasCustoms: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +92,7 @@ function toClearanceRow(booking: BookingDetail): ClearanceRow {
|
|||||||
originLabel: labelFromRef(booking.originYard),
|
originLabel: labelFromRef(booking.originYard),
|
||||||
destinationLabel: labelFromRef(booking.destinationYard),
|
destinationLabel: labelFromRef(booking.destinationYard),
|
||||||
scheduledDate: booking.scheduledDate,
|
scheduledDate: booking.scheduledDate,
|
||||||
|
updatedAt: booking.updatedAt ?? "",
|
||||||
hasCustoms: Boolean(
|
hasCustoms: Boolean(
|
||||||
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
|
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
|
||||||
),
|
),
|
||||||
@@ -102,12 +110,6 @@ function formatDate(iso?: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Icon-only chip for a booking's trade direction — Truck for import, ShipWheel
|
|
||||||
* for export — on a light background, matching the "awaiting review" badge
|
|
||||||
* styling. Keeps the cards within the white / light-gray / green palette and
|
|
||||||
* drops the text label in favour of a tooltip.
|
|
||||||
*/
|
|
||||||
function DirectionIcon({ direction }: { direction: string }) {
|
function DirectionIcon({ direction }: { direction: string }) {
|
||||||
const isImport = direction === "IMPORT";
|
const isImport = direction === "IMPORT";
|
||||||
const Icon = isImport ? Truck : ShipWheel;
|
const Icon = isImport ? Truck : ShipWheel;
|
||||||
@@ -129,33 +131,55 @@ function DirectionIcon({ direction }: { direction: string }) {
|
|||||||
|
|
||||||
export default function DocumentClearanceListPage() {
|
export default function DocumentClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// "mode=ops" query param lets the old ops-clearance redirect land on the right tab
|
||||||
|
const initialMode: ClearanceMode =
|
||||||
|
searchParams.get("mode") === "ops" ? "ops" : "gl";
|
||||||
|
const [clearanceMode, setClearanceMode] = useState<ClearanceMode>(initialMode);
|
||||||
|
const [pageTab, setPageTab] = useState<PageTab>("queue");
|
||||||
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [view, setView] = useState<ViewMode>("table");
|
const [view, setView] = useState<ViewMode>("table");
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
|
const isHistory = pageTab === "history";
|
||||||
|
|
||||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||||
queryKey: ["clearance", "list"],
|
queryKey: ["clearance", "list", clearanceMode, isHistory],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }),
|
bookingsService.list({
|
||||||
|
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
|
||||||
|
pageSize: 200,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// GL clears customs bookings only; non-customs clearance is reviewed by
|
const allRows = useMemo(() => {
|
||||||
// Marketing on the booking detail. Scope the queue defensively so a staff or
|
const rows = (data?.items ?? []).map(toClearanceRow);
|
||||||
// marketing user opening this page still sees the customs queue.
|
const filtered =
|
||||||
const allRows = useMemo(
|
clearanceMode === "gl"
|
||||||
() => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms),
|
? rows.filter((r) => r.hasCustoms)
|
||||||
[data?.items],
|
: rows.filter((r) => !r.hasCustoms);
|
||||||
);
|
|
||||||
|
|
||||||
// Per-tab counts drive the badge on each tab.
|
if (isHistory) {
|
||||||
const tabCounts = useMemo(() => {
|
// Latest cleared first — fall back to updatedAt
|
||||||
return {
|
return [...filtered].sort((a, b) => {
|
||||||
|
const ta = new Date(a.updatedAt || 0).getTime();
|
||||||
|
const tb = new Date(b.updatedAt || 0).getTime();
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}, [data?.items, clearanceMode, isHistory]);
|
||||||
|
|
||||||
|
const tabCounts = useMemo(
|
||||||
|
() => ({
|
||||||
all: allRows.length,
|
all: allRows.length,
|
||||||
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||||
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||||
} satisfies Record<ClearanceTabKey, number>;
|
}),
|
||||||
}, [allRows]);
|
[allRows],
|
||||||
|
);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -185,6 +209,35 @@ export default function DocumentClearanceListPage() {
|
|||||||
[navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleModeChange = (mode: ClearanceMode) => {
|
||||||
|
setClearanceMode(mode);
|
||||||
|
setPageTab("queue");
|
||||||
|
setActiveTab("all");
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
// clear the ?mode= param after first use
|
||||||
|
setSearchParams({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = isHistory ? (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<CheckCircle size={13} />}
|
||||||
|
>
|
||||||
|
{tabCounts.all} cleared
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ShieldCheck size={13} />}
|
||||||
|
>
|
||||||
|
{tabCounts.all} awaiting review
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -249,11 +302,16 @@ export default function DocumentClearanceListPage() {
|
|||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
cell: () => (
|
cell: ({ row }) =>
|
||||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
isHistory ? (
|
||||||
Under review
|
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||||
</Badge>
|
Cleared
|
||||||
),
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||||
|
Under review
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "go",
|
id: "go",
|
||||||
@@ -265,7 +323,7 @@ export default function DocumentClearanceListPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[isHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -274,16 +332,7 @@ export default function DocumentClearanceListPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Document Clearance"
|
title="Document Clearance"
|
||||||
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
|
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
|
||||||
meta={
|
meta={statusBadge}
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
radius="sm"
|
|
||||||
leftSection={<ShieldCheck size={13} />}
|
|
||||||
>
|
|
||||||
{tabCounts.all} awaiting review
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -298,13 +347,56 @@ export default function DocumentClearanceListPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Mode toggle: GL Customs vs Self-Clearance (Operations) */}
|
||||||
|
<Group>
|
||||||
|
<SegmentedControl
|
||||||
|
value={clearanceMode}
|
||||||
|
onChange={(v) => handleModeChange(v as ClearanceMode)}
|
||||||
|
data={[
|
||||||
|
{ value: "gl", label: "GL Clearance" },
|
||||||
|
{ value: "ops", label: "Self-Clearance" },
|
||||||
|
]}
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
value={pageTab}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPageTab(v as PageTab);
|
||||||
|
setActiveTab("all");
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
value: "queue",
|
||||||
|
label: (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Inbox size={14} />
|
||||||
|
<span>Queue</span>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "history",
|
||||||
|
label: (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<History size={14} />
|
||||||
|
<span>History</span>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "Awaiting review",
|
label: isHistory ? "Cleared" : "Awaiting review",
|
||||||
value: tabCounts.all,
|
value: tabCounts.all,
|
||||||
icon: Inbox,
|
icon: isHistory ? CheckCircle : Inbox,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -370,10 +462,7 @@ export default function DocumentClearanceListPage() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.currentTarget.value);
|
setQuery(e.currentTarget.value);
|
||||||
setPagination({
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
rightSection={
|
rightSection={
|
||||||
query ? (
|
query ? (
|
||||||
@@ -430,9 +519,7 @@ export default function DocumentClearanceListPage() {
|
|||||||
<DataTable<ClearanceRow, unknown>
|
<DataTable<ClearanceRow, unknown>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={pagedRows}
|
data={pagedRows}
|
||||||
status={
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
isLoading ? "loading" : isError ? "error" : "success"
|
|
||||||
}
|
|
||||||
onRowClick={(row) => openDetail(row.id)}
|
onRowClick={(row) => openDetail(row.id)}
|
||||||
pagination={{
|
pagination={{
|
||||||
pageIndex: pagination.pageIndex,
|
pageIndex: pagination.pageIndex,
|
||||||
@@ -454,6 +541,7 @@ export default function DocumentClearanceListPage() {
|
|||||||
<ClearanceCardGrid
|
<ClearanceCardGrid
|
||||||
rows={pagedRows}
|
rows={pagedRows}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
|
isHistory={isHistory}
|
||||||
onOpen={openDetail}
|
onOpen={openDetail}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -467,10 +555,12 @@ export default function DocumentClearanceListPage() {
|
|||||||
function ClearanceCardGrid({
|
function ClearanceCardGrid({
|
||||||
rows,
|
rows,
|
||||||
loading,
|
loading,
|
||||||
|
isHistory,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
rows: ClearanceRow[];
|
rows: ClearanceRow[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
isHistory: boolean;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -495,7 +585,12 @@ function ClearanceCardGrid({
|
|||||||
return (
|
return (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md">
|
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md">
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
|
<ClearanceCard
|
||||||
|
key={r.id}
|
||||||
|
row={r}
|
||||||
|
isHistory={isHistory}
|
||||||
|
onOpen={() => onOpen(r.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
@@ -503,9 +598,11 @@ function ClearanceCardGrid({
|
|||||||
|
|
||||||
function ClearanceCard({
|
function ClearanceCard({
|
||||||
row,
|
row,
|
||||||
|
isHistory,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
row: ClearanceRow;
|
row: ClearanceRow;
|
||||||
|
isHistory: boolean;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -543,9 +640,15 @@ function ClearanceCard({
|
|||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
{isHistory ? (
|
||||||
Under review
|
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||||
</Badge>
|
Cleared
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||||
|
Under review
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
Reference in New Issue
Block a user