Merge pull request #36 from Tria-plc/freight/features/dynamic-terminal-component

Freight/features/dynamic terminal component
This commit is contained in:
yaschalew10
2026-05-23 11:13:24 +03:00
committed by GitHub
9 changed files with 1866 additions and 1431 deletions

View File

@@ -16,6 +16,7 @@ import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-settings.service";
@Module({
imports: [
@@ -42,9 +43,13 @@ import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-set
],
})
export class AppModule implements OnApplicationBootstrap {
constructor(private readonly seeder: DataSeeder) {}
constructor(
private readonly seeder: DataSeeder,
private readonly dropdownSettingsService: DropdownSettingsService,
) {}
async onApplicationBootstrap() {
await this.seeder.run();
await this.dropdownSettingsService.seedDefaultStations();
}
}

View File

@@ -16,6 +16,71 @@ import {
IDropdownSettingsRepository,
} from "./interfaces/dropdown-settings.repository.interface";
const STATIONS_TER_CODE = "stations_ter";
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
{
value: "inside_addis_ababa",
label: "Addis Ababa",
note: "Inside country",
order: 1,
},
{
value: "inside_adama",
label: "Adama",
note: "Inside country",
order: 2,
},
{
value: "inside_mojo",
label: "Mojo",
note: "Inside country",
order: 3,
},
{
value: "inside_awash",
label: "Awash",
note: "Inside country",
order: 4,
},
{
value: "inside_mieso",
label: "Mieso",
note: "Inside country",
order: 5,
},
{
value: "inside_dire_dawa",
label: "Dire Dawa",
note: "Inside country",
order: 6,
},
{
value: "outside_ali_sabieh",
label: "Ali Sabieh",
note: "Outside country",
order: 7,
},
{
value: "outside_holhol",
label: "Holhol",
note: "Outside country",
order: 8,
},
{
value: "outside_djibouti_city",
label: "Djibouti City",
note: "Outside country",
order: 9,
},
{
value: "outside_doraleh_terminal",
label: "Doraleh Terminal",
note: "Outside country",
order: 10,
},
];
@Injectable()
export class DropdownSettingsService {
constructor(
@@ -62,6 +127,34 @@ export class DropdownSettingsService {
return this.getById(setting.id);
}
async seedDefaultStations(): Promise<void> {
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
if (!existing) {
await this.create({
code: STATIONS_TER_CODE,
label: "Stations TER",
description:
"Temporary freight station list used by booking origin and destination yards.",
multiple: false,
meta: {
searchable: true,
clearable: true,
version: "temporary",
},
children: DEFAULT_STATION_OPTIONS,
});
return;
}
if ((existing.children?.length ?? 0) === 0) {
await this.repository.replaceOptions(
existing.id,
DEFAULT_STATION_OPTIONS,
);
}
}
async update(
id: string,
dto: UpdateDropdownSettingDto,

View File

@@ -18,6 +18,7 @@ import {
Settings,
UserCircle,
FileUp,
MapPinned,
} from "lucide-react";
import BookingsPage from "./pages/bookings/BookingsPage";
@@ -42,6 +43,7 @@ import DocumentsPage from "./pages/documents/DocumentsPage";
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
import MyPortalPage from "./pages/portal/MyPortalPage";
import Station from "./components/stations/Station";
const sidebarItems: SidebarItem[] = [
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
@@ -50,6 +52,7 @@ const sidebarItems: SidebarItem[] = [
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Consignments", href: "/consignments", icon: <Package /> },
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Stations", href: "/stations", icon: <MapPinned /> },
{ label: "Trains", href: "/trains", icon: <Train /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Documents", href: "/documents", icon: <FileText /> },
@@ -121,6 +124,7 @@ const App = () => {
<Route path="/consignments" element={<ConsignmentsPage />} />
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/stations" element={<Station />} />
<Route path="/trains" element={<TrainsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/documents" element={<DocumentsPage />} />

View File

@@ -0,0 +1,228 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
CircleOff,
Loader2,
MapPin,
Search,
TrainFront,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import type { DropdownOption } from "@/types/dropdownSettings";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DataTable,
DataTableFooter,
Input,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
const STATION_DROPDOWN_CODE = "stations_ter";
export default function Station() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const { data, isLoading, isError, error } = useDropdownSettingByCode(
STATION_DROPDOWN_CODE,
);
const stations = useMemo<DropdownOption[]>(
() => [...(data?.children ?? [])].sort((a, b) => a.order - b.order),
[data?.children],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return stations;
return stations.filter(
(station) =>
station.label.toLowerCase().includes(q) ||
station.value.toLowerCase().includes(q) ||
(station.note ?? "").toLowerCase().includes(q),
);
}, [query, stations]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[end, filtered, start],
);
const activeCount = stations.filter((station) => !station.disabled).length;
const disabledCount = stations.length - activeCount;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<DropdownOption>[] = [
{
id: "station",
header: "Station",
cell: ({ row }) => {
const station = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<MapPin />
</div>
<div>
<p className="font-medium text-slate-900">{station.label}</p>
<p className="text-xs text-slate-500">
{station.note ?? "No station note"}
</p>
</div>
</div>
);
},
},
{
id: "value",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.value}
</span>
),
},
{
accessorKey: "order",
header: "Order",
},
{
id: "status",
header: "Status",
cell: ({ row }) =>
row.original.disabled ? (
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
<CircleOff className="h-3 w-3" />
Disabled
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<TrainFront className="h-3 w-3" />
Active
</span>
),
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Stations" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Stations
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Station options loaded from dropdown code{" "}
<span className="font-mono">stations_ter</span>.
</p>
</div>
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search stations..."
className="pl-8!"
/>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<StationStat label="Stations" value={stations.length} />
<StationStat label="Active" value={activeCount} />
<StationStat label="Disabled" value={disabledCount} />
</div>
{isError ? (
<Card>
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
<AlertCircle className="h-5 w-5" />
Failed to load stations.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="border-b">
<CardTitle>Station List</CardTitle>
<CardDescription>
All configured freight stations from the dropdown service.
</CardDescription>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading stations...
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function StationStat({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<MapPin />
</div>
</CardContent>
</Card>
);
}

View File

@@ -471,6 +471,12 @@ export function getRouteDirection(
dest: string,
): RouteDirection {
if (!origin || !dest) return null;
const oLocation = getStationLocation(origin);
const dLocation = getStationLocation(dest);
if (oLocation === "inside" && dLocation === "outside") return "export";
if (oLocation === "outside" && dLocation === "inside") return "import";
if (oLocation === "inside" && dLocation === "inside") return "domestic";
const oEth = ETHIOPIA_STATIONS.has(origin);
const dEth = ETHIOPIA_STATIONS.has(dest);
if (oEth && !dEth) return "export";
@@ -479,6 +485,13 @@ export function getRouteDirection(
return null;
}
function getStationLocation(value: string): "inside" | "outside" | null {
const normalized = value.trim().toLowerCase();
if (normalized.startsWith("inside")) return "inside";
if (normalized.startsWith("outside")) return "outside";
return null;
}
export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
const Ft40Wagons = containers
.filter((c) => c.type === "40ft")
@@ -498,4 +511,4 @@ export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
ft20Wagons: Ft20Wagons,
wagonLayout,
};
}
}

View File

@@ -129,18 +129,24 @@ export function SelectField({
error,
label,
placeholder,
disabled,
children,
}: {
field: ControllerRenderProps<BookingFormValues>;
error?: RhfFieldError;
label: string;
placeholder: string;
disabled?: boolean;
children: ReactNode;
}) {
return (
<Field data-invalid={Boolean(error)}>
<FieldLabel>{label}</FieldLabel>
<Select value={String(field.value)} onValueChange={field.onChange}>
<Select
value={String(field.value)}
onValueChange={field.onChange}
disabled={disabled}
>
<SelectTrigger
className={cn("w-full ", error ? "border-destructive!" : "")}
aria-invalid={Boolean(error)}

View File

@@ -1,14 +1,25 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, Separator, Switch } from "@edr/ui-common";
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import { DropdownOption } from "@/types/dropdownSettings";
type BookingForm = UseFormReturn<BookingFormValues>;
const STATION_DROPDOWN_CODE = "stations_ter";
export function Step4Route({ form }: { form: BookingForm }) {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const {
data: stationSetting,
isLoading: stationsLoading,
isError: stationsError,
error: stationsFetchError,
} = useDropdownSettingByCode(STATION_DROPDOWN_CODE);
const stationOptions = getStationOptions(stationSetting?.children);
const direction = getRouteDirection(originYard, destinationYard);
const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200",
@@ -16,10 +27,11 @@ export function Step4Route({ form }: { form: BookingForm }) {
domestic: "bg-muted text-muted-foreground border-border",
};
const directionLabel: Record<string, string> = {
export: "Export workflow (Ethiopia to Djibouti)",
import: "Import workflow (Djibouti to Ethiopia)",
export: "Export workflow (inside country to outside country)",
import: "Import workflow (outside country to inside country)",
domestic: "Domestic corridor",
};
const stationSelectDisabled = stationsLoading || stationOptions.length === 0;
return (
<div className="space-y-6">
@@ -29,6 +41,7 @@ export function Step4Route({ form }: { form: BookingForm }) {
/>
<div className="space-y-3">
<StepLabel>Route</StepLabel>
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name="originYard"
@@ -39,9 +52,12 @@ export function Step4Route({ form }: { form: BookingForm }) {
error={fieldState.error}
label="Origin Yard*"
placeholder="Select origin..."
disabled={stationSelectDisabled}
>
<SelectOptions
options={STATIONS.filter((s) => s !== destinationYard)}
<StationSelectOptions
options={stationOptions}
excludeValue={destinationYard}
isLoading={stationsLoading}
/>
</SelectField>
)}
@@ -55,14 +71,25 @@ export function Step4Route({ form }: { form: BookingForm }) {
error={fieldState.error}
label="Destination Yard *"
placeholder="Select destination..."
disabled={stationSelectDisabled}
>
<SelectOptions
options={STATIONS.filter((s) => s !== originYard)}
<StationSelectOptions
options={stationOptions}
excludeValue={originYard}
isLoading={stationsLoading}
/>
</SelectField>
)}
/>
</div>
{stationsError && (
<AlertBox tone="error">
Failed to load stations from the API.{" "}
{stationsFetchError instanceof Error
? stationsFetchError.message
: "Try again later."}
</AlertBox>
)}
{direction && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
@@ -117,3 +144,53 @@ export function Step4Route({ form }: { form: BookingForm }) {
</div>
);
}
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
return [...(options ?? [])].sort((a, b) => a.order - b.order);
}
function StationSelectOptions({
options,
excludeValue,
isLoading,
}: {
options: DropdownOption[];
excludeValue: string;
isLoading: boolean;
}) {
if (isLoading) {
return (
<SelectItem value="__stations_loading" disabled>
Loading stations...
</SelectItem>
);
}
const availableOptions = options.filter(
(option) => option.value !== excludeValue,
);
if (availableOptions.length === 0) {
return (
<SelectItem value="__stations_empty" disabled>
No stations available
</SelectItem>
);
}
return (
<>
{availableOptions.map((option) => (
<SelectItem
key={option.id}
value={option.value}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</>
);
}

View File

@@ -0,0 +1,24 @@
// vite.config.ts
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0/node_modules/vite/dist/node/index.js";
import react from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/@vitejs+plugin-react@4.7.0_vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0_/node_modules/@vitejs/plugin-react/dist/index.js";
import tailwindcss from "file:///home/meng/projects/edr-platform/node_modules/.pnpm/@tailwindcss+vite@4.3.0_vite@5.4.21_@types+node@24.12.4_lightningcss@1.32.0_terser@5.48.0_/node_modules/@tailwindcss/vite/dist/index.mjs";
var __vite_injected_original_import_meta_url = "file:///home/meng/projects/edr-platform/apps/edr-freight-web/portal/vite.config.ts";
var __dirname = path.dirname(fileURLToPath(__vite_injected_original_import_meta_url));
var vite_config_default = defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src")
}
},
server: {
port: 5173,
host: "0.0.0.0"
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9tZW5nL3Byb2plY3RzL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWxcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL21lbmcvcHJvamVjdHMvZWRyLXBsYXRmb3JtL2FwcHMvZWRyLWZyZWlnaHQtd2ViL3BvcnRhbC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9tZW5nL3Byb2plY3RzL2Vkci1wbGF0Zm9ybS9hcHBzL2Vkci1mcmVpZ2h0LXdlYi9wb3J0YWwvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcGF0aCBmcm9tIFwibm9kZTpwYXRoXCI7XG5pbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSBcIm5vZGU6dXJsXCI7XG5cbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gXCJ2aXRlXCI7XG5pbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XG5pbXBvcnQgdGFpbHdpbmRjc3MgZnJvbSBcIkB0YWlsd2luZGNzcy92aXRlXCI7XG5cbmNvbnN0IF9fZGlybmFtZSA9IHBhdGguZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbcmVhY3QoKSwgdGFpbHdpbmRjc3MoKV0sXG4gIHJlc29sdmU6IHtcbiAgICBhbGlhczoge1xuICAgICAgXCJAXCI6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsIFwiLi9zcmNcIiksXG4gICAgfSxcbiAgfSxcbiAgc2VydmVyOiB7XG4gICAgcG9ydDogNTE3MyxcbiAgICBob3N0OiBcIjAuMC4wLjBcIixcbiAgfSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFzVyxPQUFPLFVBQVU7QUFDdlgsU0FBUyxxQkFBcUI7QUFFOUIsU0FBUyxvQkFBb0I7QUFDN0IsT0FBTyxXQUFXO0FBQ2xCLE9BQU8saUJBQWlCO0FBTHdNLElBQU0sMkNBQTJDO0FBT2pSLElBQU0sWUFBWSxLQUFLLFFBQVEsY0FBYyx3Q0FBZSxDQUFDO0FBRTdELElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVMsQ0FBQyxNQUFNLEdBQUcsWUFBWSxDQUFDO0FBQUEsRUFDaEMsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsS0FBSyxLQUFLLFFBQVEsV0FBVyxPQUFPO0FBQUEsSUFDdEM7QUFBQSxFQUNGO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixNQUFNO0FBQUEsSUFDTixNQUFNO0FBQUEsRUFDUjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==

2827
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff