mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix local merge conflict
This commit is contained in:
@@ -16,6 +16,7 @@ import { BillingModule } from "./modules/billing/billing.module";
|
|||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
|
import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-settings.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -42,9 +43,13 @@ import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-set
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(private readonly seeder: DataSeeder) {}
|
constructor(
|
||||||
|
private readonly seeder: DataSeeder,
|
||||||
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
await this.seeder.run();
|
await this.seeder.run();
|
||||||
|
await this.dropdownSettingsService.seedDefaultStations();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,71 @@ import {
|
|||||||
IDropdownSettingsRepository,
|
IDropdownSettingsRepository,
|
||||||
} from "./interfaces/dropdown-settings.repository.interface";
|
} 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()
|
@Injectable()
|
||||||
export class DropdownSettingsService {
|
export class DropdownSettingsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -62,6 +127,34 @@ export class DropdownSettingsService {
|
|||||||
return this.getById(setting.id);
|
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(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
dto: UpdateDropdownSettingDto,
|
dto: UpdateDropdownSettingDto,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
FileUp,
|
FileUp,
|
||||||
|
MapPinned,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import BookingsPage from "./pages/bookings/BookingsPage";
|
import BookingsPage from "./pages/bookings/BookingsPage";
|
||||||
@@ -42,6 +43,7 @@ import DocumentsPage from "./pages/documents/DocumentsPage";
|
|||||||
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
||||||
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
|
||||||
import MyPortalPage from "./pages/portal/MyPortalPage";
|
import MyPortalPage from "./pages/portal/MyPortalPage";
|
||||||
|
import Station from "./components/stations/Station";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
|
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
|
||||||
@@ -50,6 +52,7 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||||
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
||||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
||||||
|
{ label: "Stations", href: "/stations", icon: <MapPinned /> },
|
||||||
{ label: "Trains", href: "/trains", icon: <Train /> },
|
{ label: "Trains", href: "/trains", icon: <Train /> },
|
||||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
||||||
@@ -121,6 +124,7 @@ const App = () => {
|
|||||||
<Route path="/consignments" element={<ConsignmentsPage />} />
|
<Route path="/consignments" element={<ConsignmentsPage />} />
|
||||||
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
|
<Route path="/stations" element={<Station />} />
|
||||||
<Route path="/trains" element={<TrainsPage />} />
|
<Route path="/trains" element={<TrainsPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
<Route path="/documents" element={<DocumentsPage />} />
|
||||||
|
|||||||
228
apps/edr-freight-web/portal/src/components/stations/Station.tsx
Normal file
228
apps/edr-freight-web/portal/src/components/stations/Station.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -471,6 +471,12 @@ export function getRouteDirection(
|
|||||||
dest: string,
|
dest: string,
|
||||||
): RouteDirection {
|
): RouteDirection {
|
||||||
if (!origin || !dest) return null;
|
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 oEth = ETHIOPIA_STATIONS.has(origin);
|
||||||
const dEth = ETHIOPIA_STATIONS.has(dest);
|
const dEth = ETHIOPIA_STATIONS.has(dest);
|
||||||
if (oEth && !dEth) return "export";
|
if (oEth && !dEth) return "export";
|
||||||
@@ -489,6 +495,20 @@ export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
|
|||||||
const wagonLayout: WagonConfig[] = [];
|
const wagonLayout: WagonConfig[] = [];
|
||||||
let hasOddUnit = Ft20Wagons % 2 === 1;
|
let hasOddUnit = Ft20Wagons % 2 === 1;
|
||||||
let sharedWagons = Math.floor(Ft20Wagons / 2);
|
let sharedWagons = Math.floor(Ft20Wagons / 2);
|
||||||
|
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(
|
||||||
|
type: BookingFormValues["containerType"],
|
||||||
|
qty: number,
|
||||||
|
): WagonCalcResult {
|
||||||
|
if (type === "40ft") {
|
||||||
|
return { totalWagons: qty, hasOddUnit: false, sharedWagons: qty };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalWagons: sharedWagons + Ft40Wagons,
|
totalWagons: sharedWagons + Ft40Wagons,
|
||||||
|
|||||||
@@ -129,18 +129,24 @@ export function SelectField({
|
|||||||
error,
|
error,
|
||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
disabled,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
field: ControllerRenderProps<BookingFormValues>;
|
field: ControllerRenderProps<BookingFormValues>;
|
||||||
error?: RhfFieldError;
|
error?: RhfFieldError;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
|
disabled?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Field data-invalid={Boolean(error)}>
|
<Field data-invalid={Boolean(error)}>
|
||||||
<FieldLabel>{label}</FieldLabel>
|
<FieldLabel>{label}</FieldLabel>
|
||||||
<Select value={String(field.value)} onValueChange={field.onChange}>
|
<Select
|
||||||
|
value={String(field.value)}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn("w-full ", error ? "border-destructive!" : "")}
|
className={cn("w-full ", error ? "border-destructive!" : "")}
|
||||||
aria-invalid={Boolean(error)}
|
aria-invalid={Boolean(error)}
|
||||||
|
|||||||
3013
pnpm-lock.yaml
generated
3013
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user