mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
Project Initialization
This commit is contained in:
18
apps/edr-passenger-web/Dockerfile.backoffice
Normal file
18
apps/edr-passenger-web/Dockerfile.backoffice
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/edr-passenger-web/backoffice/package.json ./apps/edr-passenger-web/backoffice/
|
||||
COPY packages ./packages
|
||||
RUN pnpm install --frozen-lockfile --filter @edr/passenger-backoffice...
|
||||
|
||||
FROM deps AS build
|
||||
COPY apps/edr-passenger-web/backoffice ./apps/edr-passenger-web/backoffice
|
||||
RUN pnpm --filter @edr/passenger-backoffice build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY --from=build /app/apps/edr-passenger-web/backoffice/dist /usr/share/nginx/html
|
||||
EXPOSE 5184
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
18
apps/edr-passenger-web/Dockerfile.portal
Normal file
18
apps/edr-passenger-web/Dockerfile.portal
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/edr-passenger-web/portal/package.json ./apps/edr-passenger-web/portal/
|
||||
COPY packages ./packages
|
||||
RUN pnpm install --frozen-lockfile --filter @edr/passenger-portal...
|
||||
|
||||
FROM deps AS build
|
||||
COPY apps/edr-passenger-web/portal ./apps/edr-passenger-web/portal
|
||||
RUN pnpm --filter @edr/passenger-portal build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY --from=build /app/apps/edr-passenger-web/portal/dist /usr/share/nginx/html
|
||||
EXPOSE 5174
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
1
apps/edr-passenger-web/backoffice/.env.example
Normal file
1
apps/edr-passenger-web/backoffice/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3002
|
||||
12
apps/edr-passenger-web/backoffice/index.html
Normal file
12
apps/edr-passenger-web/backoffice/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Passenger Backoffice</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
apps/edr-passenger-web/backoffice/package.json
Normal file
39
apps/edr-passenger-web/backoffice/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@edr/passenger-backoffice",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5184",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 5184",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*",
|
||||
"@edr/ui-common": "workspace:*",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"axios": "^1.7.7",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.2"
|
||||
}
|
||||
}
|
||||
29
apps/edr-passenger-web/backoffice/src/App.tsx
Normal file
29
apps/edr-passenger-web/backoffice/src/App.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { DashboardLayout, type SidebarItem } from '@edr/ui-common';
|
||||
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="EDR Passenger Backoffice"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
18
apps/edr-passenger-web/backoffice/src/main.tsx
Normal file
18
apps/edr-passenger-web/backoffice/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import App from './App';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
const DashboardPage = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-semibold">EDR Passenger Backoffice</h1>
|
||||
<p className="mt-2 text-gray-600">Backoffice — coming soon.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
1
apps/edr-passenger-web/backoffice/src/vite-env.d.ts
vendored
Normal file
1
apps/edr-passenger-web/backoffice/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
9
apps/edr-passenger-web/backoffice/tsconfig.app.json
Normal file
9
apps/edr-passenger-web/backoffice/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@edr/tsconfig/react.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"useDefineForClassFields": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/edr-passenger-web/backoffice/tsconfig.json
Normal file
7
apps/edr-passenger-web/backoffice/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
14
apps/edr-passenger-web/backoffice/tsconfig.node.json
Normal file
14
apps/edr-passenger-web/backoffice/tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@edr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
apps/edr-passenger-web/backoffice/vite.config.ts
Normal file
14
apps/edr-passenger-web/backoffice/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5184,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
1
apps/edr-passenger-web/portal/.env.example
Normal file
1
apps/edr-passenger-web/portal/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3002
|
||||
12
apps/edr-passenger-web/portal/index.html
Normal file
12
apps/edr-passenger-web/portal/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Passenger Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
apps/edr-passenger-web/portal/package.json
Normal file
39
apps/edr-passenger-web/portal/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@edr/passenger-portal",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5174",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 5174",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*",
|
||||
"@edr/ui-common": "workspace:*",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"axios": "^1.7.7",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.2"
|
||||
}
|
||||
}
|
||||
47
apps/edr-passenger-web/portal/src/App.tsx
Normal file
47
apps/edr-passenger-web/portal/src/App.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { DashboardLayout, type SidebarItem } from '@edr/ui-common';
|
||||
|
||||
import TicketsPage from './pages/tickets/TicketsPage';
|
||||
import TicketDetailPage from './pages/tickets/TicketDetailPage';
|
||||
import BookTicketPage from './pages/tickets/BookTicketPage';
|
||||
import SchedulesPage from './pages/schedules/SchedulesPage';
|
||||
import ScheduleDetailPage from './pages/schedules/ScheduleDetailPage';
|
||||
import StationsPage from './pages/stations/StationsPage';
|
||||
import PassengersPage from './pages/passengers/PassengersPage';
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
{ label: 'Tickets', href: '/tickets' },
|
||||
{ label: 'Schedules', href: '/schedules' },
|
||||
{ label: 'Stations', href: '/stations' },
|
||||
{ label: 'Passengers', href: '/passengers' },
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="EDR Passenger"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/tickets/new" element={<BookTicketPage />} />
|
||||
<Route path="/tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="/schedules" element={<SchedulesPage />} />
|
||||
<Route path="/schedules/:id" element={<ScheduleDetailPage />} />
|
||||
<Route path="/stations" element={<StationsPage />} />
|
||||
<Route path="/passengers" element={<PassengersPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Button, FormField } from '@edr/ui-common';
|
||||
|
||||
export interface ScheduleFormPayload {
|
||||
trainCode: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
departureTime: string;
|
||||
arrivalTime: string;
|
||||
basePrice: number;
|
||||
}
|
||||
|
||||
export interface ScheduleFormProps {
|
||||
onSubmit: (payload: ScheduleFormPayload) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const ScheduleForm = ({ onSubmit, isSubmitting }: ScheduleFormProps) => {
|
||||
const [trainCode, setTrainCode] = useState('');
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [departureTime, setDepartureTime] = useState('');
|
||||
const [arrivalTime, setArrivalTime] = useState('');
|
||||
const [basePrice, setBasePrice] = useState('0');
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit({
|
||||
trainCode,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
departureTime,
|
||||
arrivalTime,
|
||||
basePrice: Number(basePrice),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<FormField label="Train code" value={trainCode} onChange={(e) => setTrainCode(e.target.value)} required />
|
||||
<FormField
|
||||
label="Origin station ID"
|
||||
value={originStationId}
|
||||
onChange={(e) => setOriginStationId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Destination station ID"
|
||||
value={destinationStationId}
|
||||
onChange={(e) => setDestinationStationId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Departure"
|
||||
type="datetime-local"
|
||||
value={departureTime}
|
||||
onChange={(e) => setDepartureTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Arrival"
|
||||
type="datetime-local"
|
||||
value={arrivalTime}
|
||||
onChange={(e) => setArrivalTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Base price"
|
||||
type="number"
|
||||
value={basePrice}
|
||||
onChange={(e) => setBasePrice(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Publish schedule
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleForm;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Passenger } from '@edr/types';
|
||||
import { Table, type TableColumn } from '@edr/ui-common';
|
||||
|
||||
export interface ScheduleTableProps {
|
||||
schedules: Passenger.ISchedule[];
|
||||
}
|
||||
|
||||
const columns: TableColumn<Passenger.ISchedule>[] = [
|
||||
{ key: 'trainCode', header: 'Train' },
|
||||
{ key: 'status', header: 'Status' },
|
||||
{
|
||||
key: 'departureTime',
|
||||
header: 'Departure',
|
||||
render: (row) => new Date(row.departureTime).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: 'arrivalTime',
|
||||
header: 'Arrival',
|
||||
render: (row) => new Date(row.arrivalTime).toLocaleString(),
|
||||
},
|
||||
{ key: 'basePrice', header: 'Base price', render: (row) => row.basePrice.toFixed(2) },
|
||||
];
|
||||
|
||||
const ScheduleTable = ({ schedules }: ScheduleTableProps) => (
|
||||
<Table columns={columns} data={schedules} rowKey={(row) => row.id} emptyMessage="No schedules yet" />
|
||||
);
|
||||
|
||||
export default ScheduleTable;
|
||||
@@ -0,0 +1,37 @@
|
||||
import clsx from 'clsx';
|
||||
import type { Passenger } from '@edr/types';
|
||||
|
||||
export interface SeatSelectorProps {
|
||||
seats: Passenger.ISeat[];
|
||||
selectedSeatId?: string;
|
||||
onSelect: (seatId: string) => void;
|
||||
}
|
||||
|
||||
const SeatSelector = ({ seats, selectedSeatId, onSelect }: SeatSelectorProps) => (
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{seats.map((seat) => {
|
||||
const isAvailable = seat.status === 'AVAILABLE';
|
||||
const isSelected = seat.id === selectedSeatId;
|
||||
return (
|
||||
<button
|
||||
key={seat.id}
|
||||
type="button"
|
||||
disabled={!isAvailable}
|
||||
onClick={() => onSelect(seat.id)}
|
||||
className={clsx(
|
||||
'rounded-md border px-2 py-2 text-xs font-medium',
|
||||
isSelected
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: isAvailable
|
||||
? 'border-gray-200 bg-white text-gray-800 hover:border-blue-400'
|
||||
: 'cursor-not-allowed border-gray-200 bg-gray-100 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{seat.seatNumber}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SeatSelector;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Button, FormField } from '@edr/ui-common';
|
||||
|
||||
import type { CreateTicketPayload } from '../../services/tickets.service';
|
||||
|
||||
export interface TicketFormProps {
|
||||
onSubmit: (payload: CreateTicketPayload) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const TicketForm = ({ onSubmit, isSubmitting }: TicketFormProps) => {
|
||||
const [reference, setReference] = useState('');
|
||||
const [passengerId, setPassengerId] = useState('');
|
||||
const [scheduleId, setScheduleId] = useState('');
|
||||
const [seatId, setSeatId] = useState('');
|
||||
const [pricePaid, setPricePaid] = useState('0');
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit({
|
||||
reference,
|
||||
passengerId,
|
||||
scheduleId,
|
||||
seatId,
|
||||
pricePaid: Number(pricePaid),
|
||||
issuedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<FormField label="Reference" value={reference} onChange={(e) => setReference(e.target.value)} required />
|
||||
<FormField label="Passenger ID" value={passengerId} onChange={(e) => setPassengerId(e.target.value)} required />
|
||||
<FormField label="Schedule ID" value={scheduleId} onChange={(e) => setScheduleId(e.target.value)} required />
|
||||
<FormField label="Seat ID" value={seatId} onChange={(e) => setSeatId(e.target.value)} required />
|
||||
<FormField
|
||||
label="Price paid"
|
||||
type="number"
|
||||
value={pricePaid}
|
||||
onChange={(e) => setPricePaid(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Issue ticket
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketForm;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Passenger } from '@edr/types';
|
||||
import { Table, type TableColumn } from '@edr/ui-common';
|
||||
|
||||
export interface TicketTableProps {
|
||||
tickets: Passenger.ITicket[];
|
||||
}
|
||||
|
||||
const columns: TableColumn<Passenger.ITicket>[] = [
|
||||
{ key: 'reference', header: 'Reference' },
|
||||
{ key: 'passengerId', header: 'Passenger' },
|
||||
{ key: 'status', header: 'Status' },
|
||||
{
|
||||
key: 'issuedAt',
|
||||
header: 'Issued',
|
||||
render: (row) => new Date(row.issuedAt).toLocaleString(),
|
||||
},
|
||||
{ key: 'pricePaid', header: 'Price', render: (row) => row.pricePaid.toFixed(2) },
|
||||
];
|
||||
|
||||
const TicketTable = ({ tickets }: TicketTableProps) => (
|
||||
<Table columns={columns} data={tickets} rowKey={(row) => row.id} emptyMessage="No tickets yet" />
|
||||
);
|
||||
|
||||
export default TicketTable;
|
||||
16
apps/edr-passenger-web/portal/src/hooks/useSchedules.ts
Normal file
16
apps/edr-passenger-web/portal/src/hooks/useSchedules.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { schedulesService } from '../services/schedules.service';
|
||||
|
||||
export const useSchedules = () =>
|
||||
useQuery({
|
||||
queryKey: ['schedules'],
|
||||
queryFn: schedulesService.list,
|
||||
});
|
||||
|
||||
export const useSchedule = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: ['schedules', id],
|
||||
queryFn: () => schedulesService.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
9
apps/edr-passenger-web/portal/src/hooks/useStations.ts
Normal file
9
apps/edr-passenger-web/portal/src/hooks/useStations.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { stationsService } from '../services/stations.service';
|
||||
|
||||
export const useStations = () =>
|
||||
useQuery({
|
||||
queryKey: ['stations'],
|
||||
queryFn: stationsService.list,
|
||||
});
|
||||
16
apps/edr-passenger-web/portal/src/hooks/useTickets.ts
Normal file
16
apps/edr-passenger-web/portal/src/hooks/useTickets.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ticketsService } from '../services/tickets.service';
|
||||
|
||||
export const useTickets = () =>
|
||||
useQuery({
|
||||
queryKey: ['tickets'],
|
||||
queryFn: ticketsService.list,
|
||||
});
|
||||
|
||||
export const useTicket = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: ['tickets', id],
|
||||
queryFn: () => ticketsService.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
18
apps/edr-passenger-web/portal/src/main.tsx
Normal file
18
apps/edr-passenger-web/portal/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import App from './App';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
const DashboardPage = () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Passenger Dashboard</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Daily ridership, ticket sales, occupancy by class, and schedule status go here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,10 @@
|
||||
const PassengersPage = () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Passengers</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Passenger directory. Connect to <code>/passengers</code> when ready.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PassengersPage;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { useSchedule } from '../../hooks/useSchedules';
|
||||
|
||||
const ScheduleDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data, isLoading } = useSchedule(id ?? '');
|
||||
|
||||
if (isLoading) return <div className="text-sm text-gray-500">Loading…</div>;
|
||||
if (!data) return <div className="text-sm text-red-600">Schedule not found.</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Schedule {data.trainCode}</h1>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-gray-500">Status</dt>
|
||||
<dd className="text-gray-900">{data.status}</dd>
|
||||
<dt className="text-gray-500">Departure</dt>
|
||||
<dd className="text-gray-900">{new Date(data.departureTime).toLocaleString()}</dd>
|
||||
<dt className="text-gray-500">Arrival</dt>
|
||||
<dd className="text-gray-900">{new Date(data.arrivalTime).toLocaleString()}</dd>
|
||||
<dt className="text-gray-500">Base price</dt>
|
||||
<dd className="text-gray-900">{data.basePrice.toFixed(2)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleDetailPage;
|
||||
@@ -0,0 +1,16 @@
|
||||
import ScheduleTable from '../../components/schedules/ScheduleTable';
|
||||
import { useSchedules } from '../../hooks/useSchedules';
|
||||
|
||||
const SchedulesPage = () => {
|
||||
const { data, isLoading } = useSchedules();
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Schedules</h1>
|
||||
{isLoading ? <div className="text-sm text-gray-500">Loading…</div> : <ScheduleTable schedules={items} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulesPage;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Table, type TableColumn } from '@edr/ui-common';
|
||||
import type { Passenger } from '@edr/types';
|
||||
|
||||
import { useStations } from '../../hooks/useStations';
|
||||
|
||||
const columns: TableColumn<Passenger.IStation>[] = [
|
||||
{ key: 'code', header: 'Code' },
|
||||
{ key: 'name', header: 'Name' },
|
||||
{ key: 'city', header: 'City' },
|
||||
{ key: 'country', header: 'Country' },
|
||||
];
|
||||
|
||||
const StationsPage = () => {
|
||||
const { data, isLoading } = useStations();
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Stations</h1>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-500">Loading…</div>
|
||||
) : (
|
||||
<Table columns={columns} data={data ?? []} rowKey={(row) => row.id} emptyMessage="No stations" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StationsPage;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import TicketForm from '../../components/tickets/TicketForm';
|
||||
import { ticketsService } from '../../services/tickets.service';
|
||||
|
||||
const BookTicketPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ticketsService.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
navigate('/tickets');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-lg">
|
||||
<h1 className="mb-4 text-2xl font-semibold text-gray-900">Issue ticket</h1>
|
||||
<TicketForm onSubmit={mutation.mutate} isSubmitting={mutation.isPending} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookTicketPage;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { useTicket } from '../../hooks/useTickets';
|
||||
|
||||
const TicketDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data, isLoading } = useTicket(id ?? '');
|
||||
|
||||
if (isLoading) return <div className="text-sm text-gray-500">Loading…</div>;
|
||||
if (!data) return <div className="text-sm text-red-600">Ticket not found.</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Ticket {data.reference}</h1>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-gray-500">Passenger ID</dt>
|
||||
<dd className="text-gray-900">{data.passengerId}</dd>
|
||||
<dt className="text-gray-500">Schedule ID</dt>
|
||||
<dd className="text-gray-900">{data.scheduleId}</dd>
|
||||
<dt className="text-gray-500">Seat ID</dt>
|
||||
<dd className="text-gray-900">{data.seatId}</dd>
|
||||
<dt className="text-gray-500">Status</dt>
|
||||
<dd className="text-gray-900">{data.status}</dd>
|
||||
<dt className="text-gray-500">Issued</dt>
|
||||
<dd className="text-gray-900">{new Date(data.issuedAt).toLocaleString()}</dd>
|
||||
<dt className="text-gray-500">Price paid</dt>
|
||||
<dd className="text-gray-900">{data.pricePaid.toFixed(2)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketDetailPage;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@edr/ui-common';
|
||||
|
||||
import TicketTable from '../../components/tickets/TicketTable';
|
||||
import { useTickets } from '../../hooks/useTickets';
|
||||
|
||||
const TicketsPage = () => {
|
||||
const { data, isLoading } = useTickets();
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Tickets</h1>
|
||||
<Link to="/tickets/new">
|
||||
<Button>Issue ticket</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{isLoading ? <div className="text-sm text-gray-500">Loading…</div> : <TicketTable tickets={items} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketsPage;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Passenger } from '@edr/types';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
|
||||
export const schedulesService = {
|
||||
list: async (): Promise<Passenger.ISchedule[]> => {
|
||||
const { data } = await api.get('/schedules');
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string): Promise<Passenger.ISchedule> => {
|
||||
const { data } = await api.get(`/schedules/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Passenger } from '@edr/types';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
|
||||
export const stationsService = {
|
||||
list: async (): Promise<Passenger.IStation[]> => {
|
||||
const { data } = await api.get('/stations');
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Passenger, PaginatedResponse } from '@edr/types';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
|
||||
export interface CreateTicketPayload {
|
||||
reference: string;
|
||||
passengerId: string;
|
||||
scheduleId: string;
|
||||
seatId: string;
|
||||
pricePaid: number;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
export const ticketsService = {
|
||||
list: async (): Promise<PaginatedResponse<Passenger.ITicket>> => {
|
||||
const { data } = await api.get('/tickets');
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string): Promise<Passenger.ITicket> => {
|
||||
const { data } = await api.get(`/tickets/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
create: async (payload: CreateTicketPayload): Promise<Passenger.ITicket> => {
|
||||
const { data } = await api.post('/tickets', payload);
|
||||
return data.data;
|
||||
},
|
||||
cancel: async (id: string): Promise<void> => {
|
||||
await api.delete(`/tickets/${id}`);
|
||||
},
|
||||
};
|
||||
12
apps/edr-passenger-web/portal/src/store/index.ts
Normal file
12
apps/edr-passenger-web/portal/src/store/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AppState {
|
||||
// TODO: integrate @edr/auth — currentUser will be sourced from the auth package
|
||||
isSidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
isSidebarOpen: true,
|
||||
toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
|
||||
}));
|
||||
6
apps/edr-passenger-web/portal/src/types/index.ts
Normal file
6
apps/edr-passenger-web/portal/src/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { Passenger } from '@edr/types';
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
9
apps/edr-passenger-web/portal/src/utils/api.ts
Normal file
9
apps/edr-passenger-web/portal/src/utils/api.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
// TODO: integrate @edr/auth — add a request interceptor here that attaches
|
||||
// the bearer token from the auth package and a response interceptor that
|
||||
// triggers a refresh on 401.
|
||||
9
apps/edr-passenger-web/portal/src/vite-env.d.ts
vendored
Normal file
9
apps/edr-passenger-web/portal/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
9
apps/edr-passenger-web/portal/tsconfig.app.json
Normal file
9
apps/edr-passenger-web/portal/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@edr/tsconfig/react.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"useDefineForClassFields": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/edr-passenger-web/portal/tsconfig.json
Normal file
7
apps/edr-passenger-web/portal/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
14
apps/edr-passenger-web/portal/tsconfig.node.json
Normal file
14
apps/edr-passenger-web/portal/tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@edr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
apps/edr-passenger-web/portal/vite.config.ts
Normal file
14
apps/edr-passenger-web/portal/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user