resolve merge conflict

This commit is contained in:
Marshal
2026-07-09 03:39:06 +00:00
174 changed files with 8297 additions and 3001 deletions

View File

@@ -1,6 +1,7 @@
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
Card,
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
import {
BookingStatusBadge,
ChangeRequestPendingBadge,
ChangeRequestReview,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
{
id: "type",
header: "Role",
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
},
{
id: "reference",
header: "Reference",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
<div className="space-y-2">
<ProfileTypeBadge type={row.original.type} />
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
</div>
),
},
{
id: "businessLicense",
header: "Business license",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.businessLicense || "—"}
</Text>
),
id: "licenseFiles",
header: "License documents",
cell: ({ row }) => {
const files = row.original.licenseFiles ?? [];
if (files.length === 0) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Stack gap={4}>
{files.map((f) => (
<Group key={f.id} gap={6} wrap="nowrap">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
onClick={() =>
view({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
>
<Eye size={14} />
</ActionIcon>
<Anchor
component="button"
type="button"
size="xs"
lineClamp={1}
onClick={() =>
view({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
style={{
maxWidth: 170,
textAlign: "left",
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
>
{f.name}
</Anchor>
{f.status === "pending_add" && (
<Badge size="xs" color="yellow" variant="light">
Pending
</Badge>
)}
{f.status === "pending_remove" && (
<Badge size="xs" color="red" variant="light">
Removing
</Badge>
)}
</Group>
))}
</Stack>
);
},
},
{
id: "status",
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
),
},
],
[],
[view],
);
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
@@ -501,13 +564,13 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
subtitle={`TIN ${company.tin}${
company.country ? ` · ${company.country}` : ""
}`}
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
}`}
meta={
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} />
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
/>
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<ChangeRequestReview company={company} />
<KpiStrip
items={[
{
@@ -614,7 +679,7 @@ export default function CustomerDetailPage() {
<ProfileChips profiles={company.companyProfiles} />
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={860}>
<Box miw={1040}>
<DataTable
columns={profileColumns}
data={company.companyProfiles}
@@ -641,9 +706,9 @@ export default function CustomerDetailPage() {
error={
bookingsQuery.isError
? {
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
: undefined
}
/>
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
error={
documentsQuery.isError
? {
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
: undefined
}
/>
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
</Text>
<Stack gap="md">
{licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}>
<Stack key={p.id} gap={6}>
<Text size="sm" fw={600} c="edr-text">
{humanize(p.type)} · {p.reference}
</Text>
{(p.licenseFiles ?? []).map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Group key={f.id} gap={8} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
component="button"
@@ -692,16 +757,37 @@ export default function CustomerDetailPage() {
onClick={() =>
view({
name: f.name,
url: f.url,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
size="xs"
style={{
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
>
{f.name}
</Anchor>
{f.status === "pending_add" && (
<Badge size="xs" color="yellow" variant="light">
Pending approval
</Badge>
)}
{f.status === "pending_remove" && (
<Badge size="xs" color="red" variant="light">
Removal pending
</Badge>
)}
</Group>
))}
{(p.licenseFiles ?? []).length === 0 && (
<Text size="xs" c="dimmed">
No license documents.
</Text>
)}
</Stack>
))}
</Stack>
@@ -723,9 +809,9 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
: undefined
}
/>
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
error={
invoicesQuery.isError
? {
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
: undefined
}
pagination={{

View File

@@ -27,6 +27,8 @@ import {
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
@@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) {
export function TrackingPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [hoverId, setHoverId] = useState<string | null>(null);
const [mapsReady, setMapsReady] = useState(false);
@@ -288,9 +292,11 @@ export function TrackingPage() {
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
</div>
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
{canManage && (
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
)}
</Group>
<Grid>
@@ -358,9 +364,11 @@ export function TrackingPage() {
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
{selected.online ? "Live" : "Offline"}
</Badge>
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
{canManage && (
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
)}
</Group>
</Group>
@@ -393,6 +401,7 @@ export function TrackingPage() {
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
disabled={!canManage}
searchable
clearable
/>
@@ -421,14 +430,16 @@ export function TrackingPage() {
<Table.Td align="right">
<Group gap={6} justify="flex-end" wrap="nowrap">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
<Pencil size={15} />
</ActionIcon>
{canManage && (
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
<Pencil size={15} />
</ActionIcon>
)}
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
// details captured at arrival (plate, driver, tare, gate-in).
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,

View File

@@ -38,7 +38,7 @@ const columns: ColumnDef<Loading>[] = [
},
{
id: 'weight',
header: 'Loaded Weight (kg)',
header: 'Loaded Weight (t)',
cell: ({ row }) => formatNumber(row.original.loadedWeight),
},
{

View File

@@ -203,7 +203,7 @@ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
},
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
{ id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
{ id: 'weight', header: 'Weight (t)', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'payment',
header: 'Payment',

View File

@@ -16,7 +16,7 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { Info, Plus, Trash2 } from 'lucide-react';
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -30,6 +30,8 @@ import {
useDeleteAllocationRule,
useDeleteFeeRule,
useFeeRules,
useUpdateAllocationRule,
useUpdateFeeRule,
} from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import {
@@ -38,6 +40,8 @@ import {
FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS,
VEHICLE_TYPES,
type AllocationRule,
type FeeRule,
type FeeRuleBasis,
type FeeRuleType,
} from '@/types/warehouse';
@@ -121,8 +125,10 @@ function AllocationRules() {
const { data, isLoading } = useAllocationRules();
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
const create = useCreateAllocationRule();
const update = useUpdateAllocationRule();
const remove = useDeleteAllocationRule();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({
name: '',
priority: 100,
@@ -142,7 +148,8 @@ function AllocationRules() {
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
}));
const resetForm = () =>
const resetForm = () => {
setEditingId(null);
setForm({
name: '',
priority: 100,
@@ -153,6 +160,22 @@ function AllocationRules() {
targetYardCode: '',
storageType: '',
});
};
const startEdit = (rule: AllocationRule) => {
setForm({
name: rule.name,
priority: rule.priority ?? 100,
freightType: rule.freightType ?? '',
tradeDirection: rule.tradeDirection ?? '',
cargoTypeCode: rule.cargoTypeCode ?? '',
containerStatus: rule.containerStatus ?? '',
targetYardCode: rule.targetYardCode ?? '',
storageType: rule.storageType ?? '',
});
setEditingId(rule.id);
setOpen(true);
};
const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) {
@@ -160,7 +183,7 @@ function AllocationRules() {
return;
}
await create.mutateAsync({
const payload = {
name: form.name.trim(),
priority: form.priority,
freightType: clean(form.freightType) ?? null,
@@ -170,10 +193,20 @@ function AllocationRules() {
targetYardCode: form.targetYardCode.trim(),
storageType: clean(form.storageType) ?? null,
isActive: true,
} as never);
toast({ title: 'Allocation rule created' });
setOpen(false);
resetForm();
};
try {
if (editingId) {
await update.mutateAsync({ id: editingId, payload: payload as never });
toast({ title: 'Allocation rule updated' });
} else {
await create.mutateAsync(payload as never);
toast({ title: 'Allocation rule created' });
}
setOpen(false);
resetForm();
} catch (error) {
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
}
};
return (
@@ -182,7 +215,7 @@ function AllocationRules() {
<Text c="dimmed" size="sm">
{rules.length} rule(s) matched by ascending priority
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New allocation rule
</Button>
</Group>
@@ -229,14 +262,19 @@ function AllocationRules() {
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
@@ -245,7 +283,7 @@ function AllocationRules() {
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
<Stack gap="md">
<Card withBorder radius="md" padding="sm" bg="gray.0">
<Stack gap={4}>
@@ -340,11 +378,11 @@ function AllocationRules() {
/>
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
<Button loading={create.isPending || update.isPending} onClick={submit}>
{editingId ? 'Save changes' : 'Create'}
</Button>
</Group>
</Stack>
@@ -363,8 +401,10 @@ function FeeRules() {
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
const create = useCreateFeeRule();
const update = useUpdateFeeRule();
const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({
name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
@@ -394,7 +434,8 @@ function FeeRules() {
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
const isImportOnly = isDoubleHandling || isTruckDetention;
const resetForm = () =>
const resetForm = () => {
setEditingId(null);
setForm({
name: '',
ruleType: 'DEMURRAGE_FEE',
@@ -410,6 +451,27 @@ function FeeRules() {
tiers: [],
currency: 'USD',
});
};
const startEdit = (rule: FeeRule) => {
setForm({
name: rule.name,
ruleType: rule.ruleType,
basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER',
freightType: rule.freightType ?? '',
tradeDirection: rule.tradeDirection ?? '',
cargoTypeCode: rule.cargoTypeCode ?? '',
containerType: rule.containerType ?? '',
vehicleType: rule.vehicleType ?? '',
freeDays: rule.freeDays ?? 3,
freeHours: rule.freeHours ?? 3,
ratePerDay: rule.ratePerDay ?? 0,
tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })),
currency: rule.currency ?? 'USD',
});
setEditingId(rule.id);
setOpen(true);
};
const addTier = () =>
setForm((f) => {
@@ -479,12 +541,17 @@ function FeeRules() {
};
try {
await create.mutateAsync(payload as never);
toast({ title: 'Fee rule created' });
if (editingId) {
await update.mutateAsync({ id: editingId, payload: payload as never });
toast({ title: 'Fee rule updated' });
} else {
await create.mutateAsync(payload as never);
toast({ title: 'Fee rule created' });
}
setOpen(false);
resetForm();
} catch (error) {
if (tiers.length && isUnknownTiersError(error)) {
if (!editingId && tiers.length && isUnknownTiersError(error)) {
const legacyPayload: Omit<typeof payload, 'tiers'> = {
name: payload.name,
ruleType: payload.ruleType,
@@ -505,7 +572,7 @@ function FeeRules() {
resetForm();
return;
}
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
}
};
@@ -515,7 +582,7 @@ function FeeRules() {
<Text c="dimmed" size="sm">
{rules.length} rule(s) - most specific match applies
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New fee rule
</Button>
</Group>
@@ -577,14 +644,19 @@ function FeeRules() {
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
@@ -593,7 +665,7 @@ function FeeRules() {
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput
@@ -775,11 +847,11 @@ function FeeRules() {
</Stack>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
<Button loading={create.isPending || update.isPending} onClick={submit}>
{editingId ? 'Save changes' : 'Create'}
</Button>
</Group>
</Stack>