enhance contract review and editing experience

This commit is contained in:
Marshal
2026-06-30 15:16:27 +00:00
parent f51c015814
commit 708bd75d1f
31 changed files with 2044 additions and 693 deletions

View File

@@ -65,7 +65,6 @@ export function BookingActionsMenu({
};
const hasMenu = listRowHasActions(row, user);
const primary = actions.find((a) => a.primary) ?? actions[0];
if (!hasMenu && variant === "table") {
return (
@@ -117,19 +116,6 @@ export function BookingActionsMenu({
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{variant === "table" && primary && (
<Button
size="compact-sm"
color="edr-green"
visibleFrom="lg"
leftSection={<primary.icon size={14} />}
disabled={mutations.isPending}
onClick={() => handleAction(primary)}
>
{primary.shortLabel}
</Button>
)}
<Menu position="bottom-end" width={220} withinPortal>
<Menu.Target>
<ActionIcon

View File

@@ -0,0 +1,320 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
Download,
Eye,
FileCheck,
FileText,
Globe,
Hash,
Mail,
MapPin,
Phone,
ShieldCheck,
User,
} from "lucide-react";
import {
ActionIcon,
Badge,
Divider,
Group,
Loader,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { customersService } from "@/services/customers.service";
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// ── shared bits ──────────────────────────────────────────────────────────────
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
const visible = rows.filter((r) => r.value);
if (visible.length === 0) {
return (
<Text size="sm" c="dimmed">
No details available.
</Text>
);
}
return (
<Stack gap={0}>
{visible.map((row, i) => (
<div key={row.label}>
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))}
</Stack>
);
}
// ── Customer tab ─────────────────────────────────────────────────────────────
/**
* Customer info for the contract. The contract detail payload only carries a
* `companyId`, so we fetch the full company record to surface contact + manager
* details (mirrors the booking-request customer card).
*/
export function ContractCustomerCard({
contract,
}: {
contract: Freight.IContract;
}) {
const companyId = contract.companyId ?? undefined;
const { data: company, isLoading } = useQuery({
queryKey: ["companies", "byId", companyId],
queryFn: () => customersService.getById(companyId!),
enabled: Boolean(companyId) && !contract.isGovernment,
});
// Government contracts carry an institution name instead of a company.
if (contract.isGovernment) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRows
rows={[
{
icon: Building2,
label: "Government",
value: contract.governmentInstitution ?? "Government",
},
]}
/>
</SectionCard>
);
}
if (isLoading) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Group gap="sm" py="sm">
<Loader size="sm" color="gray" />
<Text size="sm" c="dimmed">
Loading customer
</Text>
</Group>
</SectionCard>
);
}
if (!company) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Text size="sm" c="dimmed">
No customer linked to this contract.
</Text>
</SectionCard>
);
}
return (
<Stack gap="lg">
<SectionCard
icon={Building2}
title="Customer"
subtitle={company.name}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Hash, label: "VAT number", value: company.vatNumber },
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
{ icon: Globe, label: "Country", value: company.country },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: Globe, label: "Website", value: company.website },
]}
/>
</SectionCard>
<SectionCard icon={User} title="Contact person" accent="teal">
<InfoRows
rows={[
{ icon: User, label: "Name", value: company.contactPersonName },
{ icon: Phone, label: "Phone", value: company.contactPersonPhone },
]}
/>
</SectionCard>
<SectionCard icon={User} title="General manager" accent="grape">
<InfoRows
rows={[
{ icon: User, label: "Name", value: company.generalManagerName },
{ icon: Mail, label: "Email", value: company.generalManagerEmail },
{ icon: Phone, label: "Phone", value: company.generalManagerPhone },
]}
/>
</SectionCard>
</Stack>
);
}
// ── Documents tab ────────────────────────────────────────────────────────────
function formatBytes(bytes?: number | null): string {
if (!bytes || bytes <= 0) return "—";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
/** Human label for a file's `code` (e.g. "business_license" → "Business license"). */
function codeLabel(code?: string | null): string | null {
if (!code) return null;
return code
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
export interface ContractDocumentsCardProps {
files: ContractFile[];
/** Open the file inline in a viewer modal. */
onView?: (file: ContractFile) => void;
/** Download the file to disk. */
onDownload?: (file: ContractFile) => void;
}
/** Rich list of the contract's attached documents: type, size, view + download. */
export function ContractDocumentsCard({
files,
onView,
onDownload,
}: ContractDocumentsCardProps) {
return (
<SectionCard
icon={FileText}
title="Documents"
accent="indigo"
extra={
<Badge color="gray" variant="light" radius="sm">
{files.length}
</Badge>
}
>
{files.length === 0 ? (
<Text size="sm" c="dimmed">
No documents attached to this contract.
</Text>
) : (
<Stack gap="xs">
{files.map((file) => {
const label = codeLabel(file.code);
return (
<Group
key={file.id}
justify="space-between"
wrap="nowrap"
p="xs"
style={detailStyles.fileRow}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--mantine-color-gray-0)";
e.currentTarget.style.borderColor =
"var(--freight-brand-border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.borderColor =
"var(--mantine-color-gray-2)";
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={36} radius="md" variant="light" color="indigo">
<FileText size={17} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{file.name}
</Text>
<Group gap={6} wrap="nowrap">
{label ? (
<Badge
variant="light"
color="gray"
radius="sm"
size="xs"
tt="none"
>
{label}
</Badge>
) : null}
<Text size="xs" c="dimmed">
{formatBytes(file.size)}
</Text>
</Group>
</Stack>
</Group>
<Group gap={4} wrap="nowrap">
{onView ? (
<Tooltip label="View" withArrow>
<ActionIcon
variant="subtle"
color="indigo"
radius="md"
onClick={() => onView(file)}
aria-label={`View ${file.name}`}
>
<Eye size={16} />
</ActionIcon>
</Tooltip>
) : null}
{onDownload ? (
<Tooltip label="Download" withArrow>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
onClick={() => onDownload(file)}
aria-label={`Download ${file.name}`}
>
<Download size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Group>
);
})}
</Stack>
)}
</SectionCard>
);
}

View File

@@ -7,13 +7,16 @@ import {
Calendar,
CalendarClock,
FileText,
Files,
Flame,
LayoutGrid,
Package,
Receipt,
RefreshCw,
Route as RouteIcon,
ShieldCheck,
Snowflake,
Users,
} from "lucide-react";
import {
Badge,
@@ -31,6 +34,8 @@ import {
Title,
} from "@mantine/core";
import toast from "react-hot-toast";
import "@/components/overview/overview.css";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -41,11 +46,18 @@ import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflow
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import {
ContractCustomerCard,
ContractDocumentsCard,
} from "@/components/contracts/detail/ContractDetailTabCards";
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
import { useFileViewer } from "@/hooks/useFileViewer";
import {
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
import { downloadBookingFile } from "@/services/files.service";
import type { Freight } from "@edr/types";
// Clearance phase — staff can still ACT (approve / query / finalize).
const CLEARANCE_ACTIVE_STATUSES = [
@@ -94,7 +106,8 @@ export default function ContractRequestDetailPage() {
} = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
const { view, viewer } = useFileViewer();
const requestedTab = searchParams.get("tab");
const setTab = (tab: string) =>
setSearchParams(
(prev) => {
@@ -106,6 +119,23 @@ export default function ContractRequestDetailPage() {
{ replace: true },
);
const handleViewFile = (file: NonNullable<Freight.IContract["files"]>[number]) =>
view({
name: file.name,
url: file.signedUrl ?? file.url,
mimeType: file.mimeType,
});
const handleDownloadFile = async (
file: NonNullable<Freight.IContract["files"]>[number],
) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
if (isLoading) {
return (
<PageContainer>
@@ -176,9 +206,17 @@ export default function ContractRequestDetailPage() {
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
const selfClear = !contract.customsClearingEnabled;
// If the tab param points at clearance but the contract isn't in a clearance
// phase, fall back to details so we never show an empty tab.
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
const files = contract.files ?? [];
// Resolve the active tab from the URL, falling back to details when the
// requested tab isn't available for this contract (e.g. clearance pre-phase).
const currentTab =
requestedTab === "documents"
? "documents"
: requestedTab === "customer"
? "customer"
: requestedTab === "clearance" && showClearanceTab
? "clearance"
: "details";
const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government")
@@ -264,27 +302,43 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{showClearanceTab && (
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}
variant="pills"
color="edr-green"
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
Details
</Tabs.Tab>
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}
variant="pills"
color="edr-green"
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
<Tabs.Tab value="details" leftSection={<LayoutGrid size={16} />}>
Details
</Tabs.Tab>
<Tabs.Tab
value="documents"
leftSection={<Files size={16} />}
rightSection={
files.length > 0 ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{files.length}
</Badge>
) : null
}
>
Documents
</Tabs.Tab>
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
Customer
</Tabs.Tab>
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Clearance Review
</Tabs.Tab>
</Tabs.List>
</Tabs>
)}
)}
</Tabs.List>
</Tabs>
<Grid gap="lg">
{/* LEFT — primary content */}
@@ -296,6 +350,14 @@ export default function ContractRequestDetailPage() {
readOnly={clearanceReadOnly}
onChanged={() => refetch()}
/>
) : currentTab === "documents" ? (
<ContractDocumentsCard
files={files}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
) : currentTab === "customer" ? (
<ContractCustomerCard contract={contract} />
) : (
<Stack gap="lg">
<SectionCard icon={RouteIcon} title="Routes">
@@ -453,6 +515,8 @@ export default function ContractRequestDetailPage() {
</Grid.Col>
</Grid>
</Stack>
{viewer}
</PageContainer>
);
}