mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Merge pull request #598 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -43,11 +43,26 @@ export class Route extends BaseEntity {
|
|||||||
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
|
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
|
||||||
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
|
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
|
||||||
* machine identifier and is only a fallback for a yard missing one.
|
* machine identifier and is only a fallback for a yard missing one.
|
||||||
|
*
|
||||||
|
* When the route's milestones are loaded (with their yards), the label is the FULL
|
||||||
|
* ordered corridor — "Addis Ababa → Adama → Dire Dawa" — since milestones already
|
||||||
|
* include the origin (first) and destination (last). Without milestones it falls
|
||||||
|
* back to origin → destination.
|
||||||
*/
|
*/
|
||||||
export function formatRouteLabel(route: {
|
export function formatRouteLabel(route: {
|
||||||
originYard?: { code?: string; label?: string } | null;
|
originYard?: { code?: string; label?: string } | null;
|
||||||
destinationYard?: { code?: string; label?: string } | null;
|
destinationYard?: { code?: string; label?: string } | null;
|
||||||
|
milestones?: Array<{
|
||||||
|
sequenceNo: number;
|
||||||
|
yard?: { code?: string; label?: string } | null;
|
||||||
|
}> | null;
|
||||||
}): string {
|
}): string {
|
||||||
|
const stops = [...(route.milestones ?? [])]
|
||||||
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
|
.map((m) => m.yard?.label ?? m.yard?.code)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
if (stops.length >= 2) return stops.join(' → ');
|
||||||
|
|
||||||
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||||
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
||||||
return `${origin} → ${dest}`;
|
return `${origin} → ${dest}`;
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
relations: {
|
relations: {
|
||||||
// Yards carry the route's display name; without them formatRouteLabel
|
// Yards carry the route's display name; without them formatRouteLabel
|
||||||
// degrades to the literal "Origin → Destination".
|
// degrades to the literal "Origin → Destination". Milestones (with
|
||||||
route: { originYard: true, destinationYard: true },
|
// their yards) give it the full corridor path.
|
||||||
|
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||||
trainSet: {
|
trainSet: {
|
||||||
locomotive: true,
|
locomotive: true,
|
||||||
locomotives: { locomotive: true },
|
locomotives: { locomotive: true },
|
||||||
|
|||||||
@@ -746,8 +746,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
trainSet: { locomotive: true },
|
trainSet: { locomotive: true },
|
||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
// Yards supply the route's display name for `routeName` below.
|
// Yards supply the route's display name for `routeName` below;
|
||||||
route: { originYard: true, destinationYard: true },
|
// milestones (with yards) give it the full corridor path.
|
||||||
|
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder } as never,
|
order: { [sortBy]: sortOrder } as never,
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
|
|||||||
@@ -2645,8 +2645,9 @@ export class TrainSchedulingService {
|
|||||||
const schedules = await this.trainSchedulesRepository.findAll({
|
const schedules = await this.trainSchedulesRepository.findAll({
|
||||||
relations: {
|
relations: {
|
||||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||||
// Yards carry the route's display name used by mapScheduleListItem.
|
// Yards carry the route's display name used by mapScheduleListItem;
|
||||||
route: { originYard: true, destinationYard: true },
|
// milestones (with yards) let it show the full corridor path.
|
||||||
|
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
scheduleBookings: { booking: true },
|
scheduleBookings: { booking: true },
|
||||||
|
|||||||
@@ -2,17 +2,25 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Boxes, Container, Eye, FileSignature, Pencil } from "lucide-react";
|
import {
|
||||||
|
Boxes,
|
||||||
|
Clock,
|
||||||
|
Container,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Pencil,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
|
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
|
||||||
@@ -25,10 +33,10 @@ const DIRECTION_LABEL: Record<string, string> = {
|
|||||||
INTERCITY: "Intercity",
|
INTERCITY: "Intercity",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DIRECTION_COLOR: Record<string, string> = {
|
const DIRECTION_DOT: Record<string, string> = {
|
||||||
IMPORT: "edr-green",
|
IMPORT: "var(--mantine-color-blue-5)",
|
||||||
EXPORT: "teal",
|
EXPORT: "var(--mantine-color-violet-5)",
|
||||||
INTERCITY: "lime",
|
INTERCITY: "var(--mantine-color-orange-5)",
|
||||||
};
|
};
|
||||||
|
|
||||||
function templateDirection(code: ContractTemplate["code"]): string {
|
function templateDirection(code: ContractTemplate["code"]): string {
|
||||||
@@ -39,6 +47,14 @@ function isBulk(code: ContractTemplate["code"]): boolean {
|
|||||||
return code.endsWith("_BULK");
|
return code.endsWith("_BULK");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatUpdated(value: string): string {
|
||||||
|
return new Date(value).toLocaleDateString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractTemplatesPage() {
|
export default function ContractTemplatesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: templates, isLoading } = useContractTemplates();
|
const { data: templates, isLoading } = useContractTemplates();
|
||||||
@@ -53,94 +69,20 @@ export default function ContractTemplatesPage() {
|
|||||||
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
|
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||||
<Center h={320}>
|
{isLoading
|
||||||
<Loader color="edr-green" />
|
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||||
</Center>
|
: (templates ?? []).map((template) => (
|
||||||
) : (
|
<TemplateCard
|
||||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
key={template.code}
|
||||||
{(templates ?? []).map((template) => {
|
template={template}
|
||||||
const direction = templateDirection(template.code);
|
onPreview={() => setPreviewCode(template.code)}
|
||||||
return (
|
onEdit={() =>
|
||||||
<Card key={template.code} withBorder radius="xl" padding="lg">
|
navigate(`/dashboard/contract-templates/${template.code}`)
|
||||||
<Stack gap="sm" h="100%">
|
}
|
||||||
<Group justify="space-between" align="flex-start">
|
/>
|
||||||
<ThemeIcon
|
))}
|
||||||
size={44}
|
</SimpleGrid>
|
||||||
radius="md"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
>
|
|
||||||
{isBulk(template.code) ? (
|
|
||||||
<Boxes size={24} />
|
|
||||||
) : (
|
|
||||||
<Container size={24} />
|
|
||||||
)}
|
|
||||||
</ThemeIcon>
|
|
||||||
<Group gap={6}>
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color={DIRECTION_COLOR[direction] ?? "edr-green"}
|
|
||||||
>
|
|
||||||
{DIRECTION_LABEL[direction] ?? direction}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" color="gray">
|
|
||||||
{isBulk(template.code) ? "Bulk" : "Container"}
|
|
||||||
</Badge>
|
|
||||||
{!template.isActive && (
|
|
||||||
<Badge variant="light" color="red">
|
|
||||||
Inactive
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text fw={700} size="lg">
|
|
||||||
{template.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" lineClamp={3}>
|
|
||||||
{template.description || template.documentTitle}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Group gap="xs" mt="auto">
|
|
||||||
<FileSignature size={14} className="text-edr-primary" />
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{template.articles.length} articles · updated{" "}
|
|
||||||
{new Date(template.updatedAt).toLocaleDateString("en-GB", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group grow>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Eye size={16} />}
|
|
||||||
onClick={() => setPreviewCode(template.code)}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Pencil size={16} />}
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/dashboard/contract-templates/${template.code}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Edit articles
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TemplatePreviewModal
|
<TemplatePreviewModal
|
||||||
code={previewCode}
|
code={previewCode}
|
||||||
@@ -150,3 +92,151 @@ export default function ContractTemplatesPage() {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TemplateCard({
|
||||||
|
template,
|
||||||
|
onPreview,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
template: ContractTemplate;
|
||||||
|
onPreview: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
const direction = templateDirection(template.code);
|
||||||
|
const bulk = isBulk(template.code);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
padding={0}
|
||||||
|
className="group flex flex-col overflow-hidden transition-all duration-150 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<Stack gap="md" p="lg" style={{ flex: 1 }}>
|
||||||
|
{/* Kicker row: muted icon well + category label + state */}
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon size={40} radius="md" variant="light" color="gray" c="gray.6">
|
||||||
|
{bulk ? (
|
||||||
|
<Boxes size={20} strokeWidth={1.75} />
|
||||||
|
) : (
|
||||||
|
<Container size={20} strokeWidth={1.75} />
|
||||||
|
)}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Group gap={7} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
w={7}
|
||||||
|
h={7}
|
||||||
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
|
||||||
|
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
|
||||||
|
{bulk ? "Bulk" : "Container"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
{!template.isActive && (
|
||||||
|
<Tooltip label="Not used for new contracts" withArrow>
|
||||||
|
<Badge size="sm" variant="light" color="red">
|
||||||
|
Inactive
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Name + description */}
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="md" lh={1.35}>
|
||||||
|
{template.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" lineClamp={2} mt={4} lh={1.5}>
|
||||||
|
{template.description || template.documentTitle}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta stats */}
|
||||||
|
<Group gap="lg" mt="auto">
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<FileText size={13} className="text-gray-400" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{template.articles.length} article
|
||||||
|
{template.articles.length !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Clock size={13} className="text-gray-400" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Updated {formatUpdated(template.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Footer actions, separated by a hairline */}
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
py="xs"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Eye size={14} />}
|
||||||
|
onClick={onPreview}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Pencil size={14} />}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
Edit articles
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="lg" padding={0} className="overflow-hidden">
|
||||||
|
<Stack gap="md" p="lg">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Skeleton height={40} width={40} radius="md" />
|
||||||
|
<Skeleton height={10} width={120} radius="xl" />
|
||||||
|
</Group>
|
||||||
|
<div>
|
||||||
|
<Skeleton height={14} width="70%" radius="xl" />
|
||||||
|
<Skeleton height={10} width="95%" radius="xl" mt={10} />
|
||||||
|
<Skeleton height={10} width="60%" radius="xl" mt={6} />
|
||||||
|
</div>
|
||||||
|
<Group gap="lg">
|
||||||
|
<Skeleton height={10} width={70} radius="xl" />
|
||||||
|
<Skeleton height={10} width={110} radius="xl" />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
py="xs"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Skeleton height={26} width={90} radius="md" />
|
||||||
|
<Skeleton height={26} width={110} radius="md" />
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,8 +43,18 @@ export interface SaveRoutePayload {
|
|||||||
* Human-readable route label: yard names, not yard codes. Staff read
|
* Human-readable route label: yard names, not yard codes. Staff read
|
||||||
* "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the
|
* "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the
|
||||||
* code only when a yard has no label.
|
* code only when a yard has no label.
|
||||||
|
*
|
||||||
|
* When milestones are present they ARE the full ordered corridor (origin first,
|
||||||
|
* destination last), so the label shows every stop:
|
||||||
|
* "Addis Ababa → Adama → Dire Dawa".
|
||||||
*/
|
*/
|
||||||
export function formatRouteLabel(route: RouteRecord): string {
|
export function formatRouteLabel(route: RouteRecord): string {
|
||||||
|
const stops = [...(route.milestones ?? [])]
|
||||||
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
|
.map((m) => m.yard?.label ?? m.yard?.code)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
if (stops.length >= 2) return stops.join(' → ');
|
||||||
|
|
||||||
const origin =
|
const origin =
|
||||||
route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||||
const dest =
|
const dest =
|
||||||
|
|||||||
Reference in New Issue
Block a user