mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
enhance contract review and editing experience
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user