feat(train-crew): implement train crew management module

- Added DTOs for creating, querying, and updating train crew members.
- Created entity for train crew members with relevant fields and enums for role, nationality, and status.
- Developed service for handling CRUD operations and ensuring no duplicate crew members.
- Implemented controller to manage API endpoints for train crew operations.
- Integrated permissions for viewing, creating, updating, and deleting train crew members.
- Added frontend components for displaying, adding, editing, and deleting train crew members.
- Established API service for interacting with the train crew backend.
- Updated routing and sidebar to include train crew management section.
This commit is contained in:
marshalyordanos
2026-09-04 20:45:41 +03:00
parent ca84aac456
commit 8437a6d5f1
18 changed files with 1086 additions and 0 deletions

View File

@@ -0,0 +1,459 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
Select,
Stack,
Switch,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Pencil, Plus, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
// Generic list footer — shared by the fleet and train-scheduling lists despite
// the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import {
TRAIN_CREW_NATIONALITY_OPTIONS,
TRAIN_CREW_ROLE_OPTIONS,
TRAIN_CREW_STATUS_OPTIONS,
trainCrewNationalityLabel,
trainCrewRoleLabel,
trainCrewService,
trainCrewStatusLabel,
type SaveTrainCrewMemberPayload,
type TrainCrewMember,
type TrainCrewNationality,
type TrainCrewRole,
type TrainCrewStatus,
} from "@/services/trainCrew.service";
const DEFAULT_PAGE_SIZE = 10;
const ALL = "__all__";
/** Mantine colour per status, so the roster reads at a glance. */
const STATUS_COLOR: Record<TrainCrewStatus, string> = {
ACTIVE: "green",
INACTIVE: "gray",
SUSPENDED: "red",
ON_LEAVE: "yellow",
};
type FormState = {
firstName: string;
lastName: string;
role: TrainCrewRole | "";
nationality: TrainCrewNationality | "";
status: TrainCrewStatus;
isActive: boolean;
};
const EMPTY_FORM: FormState = {
firstName: "",
lastName: "",
role: "",
nationality: "",
status: "ACTIVE",
isActive: true,
};
export default function TrainCrewPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canCreate = hasPermission(user, FREIGHT_PERMS.trainCrew.create);
const canUpdate = hasPermission(user, FREIGHT_PERMS.trainCrew.update);
const canDelete = hasPermission(user, FREIGHT_PERMS.trainCrew.delete);
// The footer owns page size as well as page, so both live here. `pageIndex`
// is 0-based to match the footer's PaginationState; the API is 1-based.
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE,
});
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string>(ALL);
const [nationalityFilter, setNationalityFilter] = useState<string>(ALL);
const [statusFilter, setStatusFilter] = useState<string>(ALL);
const [modalOpen, setModalOpen] = useState(false);
/** Row being edited; null means the modal is in create mode. */
const [editing, setEditing] = useState<TrainCrewMember | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState<TrainCrewMember | null>(null);
// Filtering and paging are server-side, so the active filters are part of the
// query key — changing one refetches rather than slicing a stale page.
const filters = useMemo(
() => ({
page: pagination.pageIndex + 1,
limit: pagination.pageSize,
...(search.trim() ? { search: search.trim() } : {}),
...(roleFilter !== ALL ? { role: roleFilter as TrainCrewRole } : {}),
...(nationalityFilter !== ALL
? { nationality: nationalityFilter as TrainCrewNationality }
: {}),
...(statusFilter !== ALL ? { status: statusFilter as TrainCrewStatus } : {}),
}),
[pagination, search, roleFilter, nationalityFilter, statusFilter],
);
const { data, isLoading } = useQuery({
queryKey: QUERY_KEYS.TRAIN_CREW.list(filters),
queryFn: async () => {
const res = await trainCrewService.getAll(filters);
return res.data;
},
});
const members = data?.data ?? [];
const total = data?.total ?? 0;
const invalidate = () =>
qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_CREW.ROOT });
const describeError = (error: unknown, fallback: string): string => {
const message = (error as { response?: { data?: { message?: unknown } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
return typeof message === "string" ? message : fallback;
};
const saveMutation = useMutation({
mutationFn: async (values: FormState) => {
const payload: Partial<SaveTrainCrewMemberPayload> = {
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
role: values.role as TrainCrewRole,
nationality: values.nationality as TrainCrewNationality,
status: values.status,
isActive: values.isActive,
};
return editing
? trainCrewService.update(editing.id, payload)
: trainCrewService.create(payload);
},
onSuccess: () => {
toast({ title: editing ? "Crew member updated" : "Crew member added" });
closeModal();
invalidate();
},
onError: (error: unknown) => {
toast({
title: editing ? "Could not update crew member" : "Could not add crew member",
description: describeError(error, "The request failed. Please try again."),
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => trainCrewService.delete(id),
onSuccess: () => {
toast({ title: "Crew member removed" });
setDeleteTarget(null);
invalidate();
},
onError: (error: unknown) => {
toast({
title: "Could not remove crew member",
description: describeError(error, "The request failed. Please try again."),
variant: "destructive",
});
},
});
const openCreate = () => {
setEditing(null);
setForm(EMPTY_FORM);
setModalOpen(true);
};
const openEdit = (member: TrainCrewMember) => {
setEditing(member);
setForm({
firstName: member.firstName,
lastName: member.lastName,
role: member.role,
nationality: member.nationality,
status: member.status,
isActive: member.isActive,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditing(null);
setForm(EMPTY_FORM);
};
/** Every column is NOT NULL server-side, so all four must be filled. */
const formValid =
form.firstName.trim().length > 0 &&
form.lastName.trim().length > 0 &&
form.role !== "" &&
form.nationality !== "";
// Filters narrow the result set, so a page beyond the new last page would
// render empty — reset to the first page whenever one changes.
const resetToFirstPage = () =>
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
const onFilterChange = (setter: (value: string) => void) => (value: string | null) => {
setter(value ?? ALL);
resetToFirstPage();
};
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Train Crew" }, { label: "Crew Members" }]} />
<Group justify="space-between" mb="lg">
<div>
<Title order={1}>Train Crew</Title>
<Text size="sm" c="dimmed">
Roster of on-board personnel assignable to a train
</Text>
</div>
{canCreate ? (
<Button leftSection={<Plus size={16} />} onClick={openCreate} color="edr-green">
Add Crew Member
</Button>
) : null}
</Group>
<Card withBorder>
<Group p="md" gap="sm" align="flex-end" wrap="wrap">
<TextInput
label="Search"
placeholder="Search by name…"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
resetToFirstPage();
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
label="Role"
data={[{ label: "All roles", value: ALL }, ...TRAIN_CREW_ROLE_OPTIONS]}
value={roleFilter}
onChange={onFilterChange(setRoleFilter)}
style={{ minWidth: 180 }}
/>
<Select
label="Nationality"
data={[
{ label: "All nationalities", value: ALL },
...TRAIN_CREW_NATIONALITY_OPTIONS,
]}
value={nationalityFilter}
onChange={onFilterChange(setNationalityFilter)}
style={{ minWidth: 170 }}
/>
<Select
label="Status"
data={[{ label: "All statuses", value: ALL }, ...TRAIN_CREW_STATUS_OPTIONS]}
value={statusFilter}
onChange={onFilterChange(setStatusFilter)}
style={{ minWidth: 160 }}
/>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>First Name</Table.Th>
<Table.Th>Last Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Nationality</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Active</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : members.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="md">
No crew members found.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{members.map((member) => (
<Table.Tr key={member.id}>
<Table.Td>{member.firstName}</Table.Td>
<Table.Td>{member.lastName}</Table.Td>
<Table.Td>{trainCrewRoleLabel(member.role)}</Table.Td>
<Table.Td>{trainCrewNationalityLabel(member.nationality)}</Table.Td>
<Table.Td>
<Badge size="sm" color={STATUS_COLOR[member.status] ?? "gray"}>
{trainCrewStatusLabel(member.status)}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" color={member.isActive ? "green" : "gray"} variant="light">
{member.isActive ? "Yes" : "No"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
{canUpdate ? (
<Button
size="xs"
variant="light"
leftSection={<Pencil size={14} />}
onClick={() => openEdit(member)}
>
Edit
</Button>
) : null}
{canDelete ? (
<Button
size="xs"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
onClick={() => setDeleteTarget(member)}
>
Delete
</Button>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
itemLabel="crew members"
pagination={pagination}
pageCount={Math.ceil(total / pagination.pageSize)}
totalCount={total}
onPaginationChange={setPagination}
/>
</Card>
<Modal
opened={modalOpen}
onClose={closeModal}
title={editing ? "Edit Crew Member" : "Add Crew Member"}
size="lg"
>
<Stack gap="md">
<TextInput
label="First Name"
placeholder="First name"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.currentTarget.value })}
required
/>
<TextInput
label="Last Name"
placeholder="Last name"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.currentTarget.value })}
required
/>
<Select
label="Role"
placeholder="Select role"
data={TRAIN_CREW_ROLE_OPTIONS}
value={form.role || null}
onChange={(val) => setForm({ ...form, role: (val as TrainCrewRole) ?? "" })}
required
/>
<Select
label="Nationality"
placeholder="Select nationality"
data={TRAIN_CREW_NATIONALITY_OPTIONS}
value={form.nationality || null}
onChange={(val) =>
setForm({ ...form, nationality: (val as TrainCrewNationality) ?? "" })
}
required
/>
<Select
label="Status"
data={TRAIN_CREW_STATUS_OPTIONS}
value={form.status}
onChange={(val) =>
setForm({ ...form, status: (val as TrainCrewStatus) ?? "ACTIVE" })
}
required
/>
<Switch
label="Active"
checked={form.isActive}
onChange={(e) => setForm({ ...form, isActive: e.currentTarget.checked })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={closeModal}>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate(form)}
loading={saveMutation.isPending}
disabled={!formValid}
>
{editing ? "Save Changes" : "Add Crew Member"}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title="Remove Crew Member"
size="md"
>
<Stack gap="md">
<Text size="sm">
Remove {deleteTarget?.firstName} {deleteTarget?.lastName} from the train crew
roster?
</Text>
<Group justify="flex-end">
<Button variant="light" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}