mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Pre-declaration Djibouti GL assignee request flow
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BadgeCheck,
|
||||
CalendarClock,
|
||||
Flame,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
FileSignature,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import {
|
||||
CONTRACT_APPROVAL_ROLE_LABELS,
|
||||
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
interface ContractMilestonesTimelineProps {
|
||||
contract: Freight.IContract;
|
||||
}
|
||||
|
||||
const SIGNATURE_ROLE_LABELS: Record<Freight.ContractSignatureRole, string> = {
|
||||
CUSTOMER: "Signed by customer",
|
||||
STAFF: "Signed by EDR — line staff",
|
||||
DIRECTOR: "Signed by EDR — director",
|
||||
CEO: "Signed by EDR — CEO",
|
||||
};
|
||||
|
||||
/** "27 Jul 2026, 18:18" — the exact stamp, shown in the tooltip. */
|
||||
function formatWhen(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/** "3 hours ago" — the at-a-glance read. */
|
||||
function formatAgo(iso: string): string {
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
];
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
||||
for (const [unit, secondsPerUnit] of units) {
|
||||
if (seconds >= secondsPerUnit) {
|
||||
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
|
||||
}
|
||||
}
|
||||
return "just now";
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||
}
|
||||
|
||||
type MilestoneIcon = typeof Send;
|
||||
|
||||
interface Milestone {
|
||||
key: string;
|
||||
at: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
color: string;
|
||||
icon: MilestoneIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dated moments of a contract's life — submission, hazardous approval,
|
||||
* final approval, both parties' signatures, full execution — read straight off
|
||||
* the contract and its already-loaded approvalSteps/signatures (no extra
|
||||
* fetch). Sits above the document edit history on the History tab.
|
||||
*/
|
||||
export function ContractMilestonesTimeline({
|
||||
contract,
|
||||
}: ContractMilestonesTimelineProps) {
|
||||
const milestones = useMemo<Milestone[]>(() => {
|
||||
const items: Milestone[] = [];
|
||||
|
||||
// A DRAFT/RENEWAL_DRAFT contract hasn't been (re)submitted yet — nothing
|
||||
// to date. submittedAt is only tracked going forward; a contract that
|
||||
// reached SUBMITTED before that column existed falls back to createdAt.
|
||||
const submittedAt =
|
||||
contract.submittedAt ??
|
||||
(contract.status !== "DRAFT" && contract.status !== "RENEWAL_DRAFT"
|
||||
? contract.createdAt
|
||||
: null);
|
||||
if (submittedAt) {
|
||||
items.push({
|
||||
key: "submitted",
|
||||
at: submittedAt,
|
||||
title: "Submitted for review",
|
||||
color: "blue",
|
||||
icon: Send,
|
||||
});
|
||||
}
|
||||
|
||||
for (const step of contract.approvalSteps ?? []) {
|
||||
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
|
||||
if (!step.actedAt) continue;
|
||||
items.push({
|
||||
key: `hazard-${step.id}`,
|
||||
at: step.actedAt,
|
||||
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole,
|
||||
detail: step.status === "REJECTED" ? "Rejected" : "Approved",
|
||||
color: step.status === "REJECTED" ? "red" : "orange",
|
||||
icon: Flame,
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.contractGeneratedAt) {
|
||||
items.push({
|
||||
key: "approved",
|
||||
at: contract.contractGeneratedAt,
|
||||
title: "Contract approved",
|
||||
detail: "Every approval step cleared and the document was generated",
|
||||
color: "edr-green",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
}
|
||||
|
||||
for (const sig of contract.signatures ?? []) {
|
||||
items.push({
|
||||
key: `signature-${sig.id}`,
|
||||
at: sig.signedAt,
|
||||
title: SIGNATURE_ROLE_LABELS[sig.role] ?? `Signed by ${sig.role}`,
|
||||
detail: sig.signerDisplayName,
|
||||
color: "grape",
|
||||
icon: FileSignature,
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.fullyExecutedAt) {
|
||||
items.push({
|
||||
key: "executed",
|
||||
at: contract.fullyExecutedAt,
|
||||
title: "Fully executed",
|
||||
detail: "Both parties have signed",
|
||||
color: "edr-green",
|
||||
icon: BadgeCheck,
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort(
|
||||
(a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
|
||||
);
|
||||
}, [contract]);
|
||||
|
||||
const hasValidity = contract.contractValidFrom && contract.contractValidUntil;
|
||||
|
||||
if (milestones.length === 0 && !hasValidity) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No dated milestones recorded yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{hasValidity && (
|
||||
<Group
|
||||
gap="xs"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
<CalendarClock size={16} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="sm">
|
||||
Valid <strong>{formatDate(contract.contractValidFrom!)}</strong>
|
||||
{" → "}
|
||||
<strong>{formatDate(contract.contractValidUntil!)}</strong>
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{milestones.length > 0 && (
|
||||
<Timeline active={milestones.length} bulletSize={26} lineWidth={2} color="edr-green">
|
||||
{milestones.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={m.key}
|
||||
bullet={<Icon size={13} />}
|
||||
color={m.color}
|
||||
title={
|
||||
<Group gap="xs" wrap="wrap" align="baseline">
|
||||
<Text size="sm" fw={600}>
|
||||
{m.title}
|
||||
</Text>
|
||||
<Tooltip label={formatWhen(m.at)} withArrow>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatAgo(m.at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{m.detail && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{m.detail}
|
||||
</Text>
|
||||
)}
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -349,11 +349,11 @@ export function PhasedClearanceActionPanel({
|
||||
API refuses the upload until the name is in. */}
|
||||
{showEt &&
|
||||
canEt &&
|
||||
!isBooking &&
|
||||
!clearance.transitAssignee?.name &&
|
||||
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
|
||||
<TransitAssigneePanel
|
||||
contractId={entityId}
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
transitAssignee={clearance.transitAssignee}
|
||||
side="ET"
|
||||
onChanged={onChanged}
|
||||
|
||||
@@ -15,10 +15,14 @@ import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
export interface TransitAssigneePanelProps {
|
||||
contractId: string;
|
||||
/** Booking id when `isBooking`, contract id otherwise. */
|
||||
entityId: string;
|
||||
/** Clearance runs per booking now; contract-level cycles are the legacy case. */
|
||||
isBooking?: boolean;
|
||||
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
|
||||
/**
|
||||
* ET asks and waits; DJ answers with a name. The same state renders from both
|
||||
@@ -50,7 +54,8 @@ const fmt = (iso?: string | null) =>
|
||||
* different name later; the newest one wins and Ethiopia is notified again.
|
||||
*/
|
||||
export function TransitAssigneePanel({
|
||||
contractId,
|
||||
entityId,
|
||||
isBooking = false,
|
||||
transitAssignee,
|
||||
side,
|
||||
readOnly = false,
|
||||
@@ -59,9 +64,12 @@ export function TransitAssigneePanel({
|
||||
const [note, setNote] = useState("");
|
||||
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
|
||||
const [changing, setChanging] = useState(false);
|
||||
const service = isBooking ? bookingsService : contractsService;
|
||||
|
||||
const request = useMutation({
|
||||
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
|
||||
mutationFn: async () => {
|
||||
await service.requestTransitAssignee(entityId, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Request sent to GL Djibouti");
|
||||
setNote("");
|
||||
@@ -70,8 +78,9 @@ export function TransitAssigneePanel({
|
||||
});
|
||||
|
||||
const assign = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.assignTransitAssignee(contractId, assignee.trim()),
|
||||
mutationFn: async () => {
|
||||
await service.assignTransitAssignee(entityId, assignee.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Transit assignee sent to GL Ethiopia");
|
||||
setChanging(false);
|
||||
|
||||
Reference in New Issue
Block a user