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 { 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
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,
|
||||
): 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";
|
||||
@@ -489,6 +495,20 @@ export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
|
||||
const wagonLayout: WagonConfig[] = [];
|
||||
let hasOddUnit = Ft20Wagons % 2 === 1;
|
||||
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 {
|
||||
totalWagons: sharedWagons + Ft40Wagons,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
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