diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index b26764d30..290ecec69 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -20,7 +20,12 @@ "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", - "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh" + "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", + "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", + "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", + "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", + "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", + "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js" }, "dependencies": { "@edr/api-common": "workspace:*", @@ -39,14 +44,15 @@ "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz", - + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cross-env": "^10.1.0", "dotenv": "^17.4.2", + "dotenv-cli": "^11.0.0", "handlebars": "^4.7.9", "libphonenumber-js": "^1.13.6", "minio": "7.1.3", diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 1ac78355a..73d98b8fe 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; @@ -44,30 +44,19 @@ export class FirstMileService { * paid before any first-mile work proceeds. Throws if the reference is * unknown or the booking has not reached PAID status. */ - async acceptBooking(bookingId: string): Promise { + async acceptBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId, { relations: { serviceType: true }, }); if (!booking) { - return null; + throw new NotFoundException(`Booking ${bookingId} not found`); } - if (booking.paymentStatus !== 'PAID') { - return null; - } - - if (!this.bookingRequestsFirstMile(booking)) { - return null; - } - - return this.create({ - bookingId: booking.id, - advancedPayment: 0, - }); + return this.acceptEligibleBooking(booking); } - async acceptBookingByReference(bookingReference: string): Promise { + async acceptBookingByReference(bookingReference: string): Promise { const [booking] = await this.bookingsRepository.findAll({ where: { reference: bookingReference }, relations: { serviceType: true }, @@ -75,15 +64,39 @@ export class FirstMileService { }); if (!booking) { - return null; + throw new NotFoundException(`Booking ${bookingReference} not found`); } + return this.acceptEligibleBooking(booking); + } + + /** + * Shared accept path: validates payment + first-mile eligibility, rejects an + * already-assigned booking, then creates the first-mile record. Throws a + * meaningful HTTP error instead of returning null so the client can surface + * why an accept was refused. + */ + private async acceptEligibleBooking(booking: { + id: string; + reference?: string; + paymentStatus?: string | null; + tradeDirection?: string | null; + firstMilePickupAddress?: string | null; + serviceType?: { includesFirstMile?: boolean | null } | null; + }): Promise { + const label = booking.reference ?? booking.id; + if (booking.paymentStatus !== 'PAID') { - return null; + throw new BadRequestException(`Booking ${label} is not paid`); } if (!this.bookingRequestsFirstMile(booking)) { - return null; + throw new BadRequestException(`Booking ${label} does not require a first mile`); + } + + const existing = await this.findByBookingId(booking.id); + if (existing) { + throw new ConflictException(`Booking ${label} already has a first-mile assignment`); } return this.create({ @@ -174,10 +187,17 @@ export class FirstMileService { } private bookingRequestsFirstMile(booking: { + tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { - return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile); + // Export bookings always need a first mile (pickup → origin yard); the + // pickup address is captured at assignment time, not required upfront. + return Boolean( + booking.tradeDirection === 'EXPORT' || + booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile, + ); } async update(id: string, dto: UpdateFirstMileDto): Promise { diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 67fe4e9cb..93caae0a4 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -20,7 +20,7 @@ "@mantine/hooks": "^9.3.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", - "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz", + "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4e268c99f..ab1356a42 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -77,6 +77,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; +import { HealthCheck } from "./features/health/HealthCheck"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -374,6 +375,7 @@ const App = () => { return ( } /> + } /> } /> } /> }> diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index b1e05ab3d..0d8a4fa29 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -22,7 +22,7 @@ import { LayoutGrid, Package, } from "lucide-react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; @@ -84,9 +84,6 @@ export default function CustomerDetailPage() { enabled: Boolean(id), }), ); - const approveMutation = useMutation( - api.customers.setCompanyStatus.mutationOptions(), - ); const bookingsQuery = useQuery( api.customers.bookings.queryOptions({ input: { id: id ?? "" }, @@ -394,28 +391,12 @@ export default function CustomerDetailPage() { ]} backTo="/dashboard/customers" title={company.name} - subtitle={`TIN ${company.tin}${ - company.country ? ` · ${company.country}` : "" - }`} + subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : "" + }`} meta={ - {company.status === "pending" && ( - - )} } /> @@ -546,9 +527,9 @@ export default function CustomerDetailPage() { error={ bookingsQuery.isError ? { - message: "Failed to load bookings.", - onRetry: () => void bookingsQuery.refetch(), - } + message: "Failed to load bookings.", + onRetry: () => void bookingsQuery.refetch(), + } : undefined } /> @@ -567,9 +548,9 @@ export default function CustomerDetailPage() { error={ documentsQuery.isError ? { - message: "Failed to load documents.", - onRetry: () => void documentsQuery.refetch(), - } + message: "Failed to load documents.", + onRetry: () => void documentsQuery.refetch(), + } : undefined } /> @@ -588,9 +569,9 @@ export default function CustomerDetailPage() { error={ paymentsQuery.isError ? { - message: "Failed to load payments.", - onRetry: () => void paymentsQuery.refetch(), - } + message: "Failed to load payments.", + onRetry: () => void paymentsQuery.refetch(), + } : undefined } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx index dedc41084..d473bdc90 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementHostPage.tsx @@ -1,23 +1,34 @@ -import { useEffect, useRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import { useEffect, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { UserManagementApp, type UserManagementRuntimeOptions, type UserManagementSessionSeed, -} from "@tria-plc/iamui"; +} from '@tria-plc/iamui'; +import { iamConfig } from './iamConfig'; -import { getCookie } from "@/auth/cookies"; +function readCookieValue(name: string): string | null { + const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1'); + const match = document.cookie.match( + new RegExp(`(?:^|; )${escaped}=([^;]*)`), + ); -import { iamConfig } from "./iamConfig"; + return match ? decodeURIComponent(match[1]) : null; +} function readInitialSession(): UserManagementSessionSeed | null { - const token = getCookie("auth-token"); + const token = + localStorage.getItem('fhc-backoffice-auth-token') ?? + readCookieValue('auth-token'); if (!token) { return null; } - const refreshToken = getCookie("refresh-token") ?? undefined; + const refreshToken = + localStorage.getItem('fhc-backoffice-auth-refresh-token') ?? + readCookieValue('refresh-token') ?? + undefined; return { token, @@ -47,15 +58,14 @@ export default function UserManagementHostPage() { rootRef.current = createRoot(mountNode); } - const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, ""); - const iamApiUrl = "/um-api"; + const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, ''); const runtime: UserManagementRuntimeOptions = { - basename: "/um", + basename: '/um', apiBaseUrl, - apiUrl: iamApiUrl, - recordApiUrl: iamApiUrl, - chronicleUrl: iamApiUrl, - auditApiUrl: iamApiUrl, + apiUrl: `${apiBaseUrl}/api`, + recordApiUrl: `${apiBaseUrl}/api`, + chronicleUrl: `${apiBaseUrl}/api`, + auditApiUrl: `${apiBaseUrl}/api`, }; rootRef.current.render( @@ -78,5 +88,5 @@ export default function UserManagementHostPage() { }; }, []); - return
; + return
; } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 075efd34c..208db85f8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -379,6 +379,9 @@ const FirstMilePage = () => { mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { const res = await firstMileService.accept(reference); const created = res.data; + if (!created?.id) { + throw new Error("First-mile leg was not created for this booking."); + } if (vehicleId) await firstMileService.update(created.id, { vehicleId }); return created; }, @@ -387,8 +390,11 @@ const FirstMilePage = () => { toast({ title: "Booking accepted", description: "First-mile leg created successfully." }); closeAccept(); }, - onError: () => { - toast({ title: "Accept failed", variant: "destructive" }); + onError: (err: unknown) => { + const description = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + (err instanceof Error ? err.message : undefined); + toast({ title: "Accept failed", description, variant: "destructive" }); }, }); @@ -407,7 +413,6 @@ const FirstMilePage = () => { paidBookings.filter( (booking) => booking.tradeDirection === "EXPORT" && - Boolean(booking.firstMilePickupAddress?.trim()) && !existingFirstMileBookingIds.has(booking.id), ), [existingFirstMileBookingIds, paidBookings], diff --git a/apps/edr-freight-web/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts index 840e831e5..341d2c7c3 100644 --- a/apps/edr-freight-web/backoffice/vite.config.ts +++ b/apps/edr-freight-web/backoffice/vite.config.ts @@ -11,97 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const streamBrowserifyPath = require.resolve("stream-browserify"); -function createIamApiAdapter(apiBaseUrl: string): Plugin { - const upstreamBaseUrl = `${apiBaseUrl.replace(/\/+$/, "")}/api`; - - return { - name: "iam-api-adapter", - configureServer(server) { - server.middlewares.use("/um-api", async (req, res) => { - const requestPath = req.url ?? "/"; - const normalizedPath = requestPath.replace(/^\/+/, ""); - const targetUrl = new URL(normalizedPath, `${upstreamBaseUrl}/`); - - try { - const headers = new Headers(); - for (const [key, value] of Object.entries(req.headers)) { - if (!value || key.toLowerCase() === "host") { - continue; - } - - if (Array.isArray(value)) { - for (const item of value) { - headers.append(key, item); - } - continue; - } - - headers.set(key, value); - } - - const body = - req.method === "GET" || req.method === "HEAD" - ? undefined - : await new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on("data", (chunk) => - chunks.push( - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), - ), - ); - req.on("end", () => resolve(Buffer.concat(chunks))); - req.on("error", reject); - }); - - const upstreamResponse = await fetch(targetUrl, { - method: req.method, - headers, - body, - }); - - if (targetUrl.pathname.endsWith("/auth/me")) { - const payload = await upstreamResponse.json(); - const unwrappedPayload = - payload && - typeof payload === "object" && - "success" in payload && - "data" in payload - ? payload.data - : payload; - - res.statusCode = upstreamResponse.status; - res.setHeader("content-type", "application/json; charset=utf-8"); - res.end(JSON.stringify(unwrappedPayload)); - return; - } - - res.statusCode = upstreamResponse.status; - upstreamResponse.headers.forEach((value, key) => { - res.setHeader(key, value); - }); - res.end(Buffer.from(await upstreamResponse.arrayBuffer())); - } catch (error) { - server.ssrFixStacktrace(error as Error); - res.statusCode = 502; - res.setHeader("content-type", "application/json; charset=utf-8"); - res.end( - JSON.stringify({ - message: "Failed to forward IAM request", - }), - ); - } - }); - }, - }; -} - export default defineConfig(({ mode }) => { - const env = loadEnv(mode, __dirname, ""); - const apiBaseUrl = - env.VITE_BASE_API_URL?.trim() || "http://localhost:3000"; - return { - plugins: [react(), tailwindcss(), createIamApiAdapter(apiBaseUrl)], + plugins: [react(), tailwindcss()], resolve: { alias: { "@": path.resolve(__dirname, "./src"), diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 248086188..9f866d756 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -18,7 +18,7 @@ "@mantine/core": "^9.3.0", "@mantine/hooks": "^9.3.0", "@tanstack/react-query": "^5.59.0", - "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz", + "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 8d6bc3d93..4d4c8664a 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({ (company?.company?.nationality as CompanyNationality | null) ?? null; // Resume position from the backend-persisted step. - const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) + const resumeFormStep: FormStep = FORM_STEPS.includes( + onboardingStep as FormStep, + ) ? (onboardingStep as FormStep) : "company"; @@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({ const idx = FORM_STEPS.indexOf(step as FormStep); if (idx < 0 || idx <= furthestIdxRef.current) return; furthestIdxRef.current = idx; - api.companies.setOnboardingStep.call({ step }).catch(() => {}); + api.companies.setOnboardingStep.call({ step }).catch(() => { }); }, []); // Mirror the form's step locally (for the header/pill) and persist it. @@ -321,7 +323,7 @@ export default function OnboardingWizardDialog({ // Note: no "back to role selection" — once the draft is created the role(s) // are fixed; the form's first-step Back is a no-op so progress never resets. - const handleBackToRoles = useCallback(() => {}, []); + const handleBackToRoles = useCallback(() => { }, []); // Save the current step's fields to the draft (PATCH /profile). Returns the // server error message on failure so the form can show it (e.g. duplicate TIN). @@ -339,6 +341,31 @@ export default function OnboardingWizardDialog({ [], ); + // Auto-upload the documents the user just selected as they leave the documents + // step. Only the in-memory selections are sent; once uploaded they're cleared + // (so the final submit never re-uploads them) and the requirements query is + // refreshed so the "Already uploaded" badges light up. Partial uploads are + // allowed — the user may continue even with required docs still outstanding. + const handleUploadDocuments = useCallback(async (): Promise< + { ok: true } | { ok: false; error: string } + > => { + const companyId = company?.company?.id; + const hasNew = Object.values(documentFiles).some( + (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), + ); + if (!companyId || !hasNew) return { ok: true }; + try { + await companiesService.uploadDocuments(companyId, documentFiles); + setDocumentFiles({}); + await queryClient.invalidateQueries({ + queryKey: api.companies.onboardingRequirements.queryKey(), + }); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractApiError(err).message }; + } + }, [company?.company?.id, documentFiles, queryClient]); + // Final confirm step → finalize onboarding (no company create; it already // exists as a draft that's been filled in step-by-step). const handleSubmit = useCallback( @@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({ requirementsQuery.data?.documentSettingCode ?? documentSettingCode(effectiveNationality); + // Server-confirmed document state, used both to badge already-uploaded fields + // and to keep a refreshed resume from over-shooting the documents step. + const requirementDocuments = requirementsQuery.data?.documents ?? []; + const uploadedDocumentKeys = requirementDocuments + .filter((d) => d.uploaded) + .map((d) => d.fileKey); + // If any REQUIRED document is still missing, the resume must not rest past the + // documents step (don't skip to Business License) — clamp it back. This only + // changes the target once requirements load; the form follows the correction + // as long as the user hasn't navigated yet. + const requiredDocsMissing = requirementDocuments.some( + (d) => d.isRequired && !d.uploaded, + ); + const effectiveResumeStep: FormStep = + requiredDocsMissing && + FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") + ? "documents" + : resumeFormStep; + const formProps = { documentSettingCode: resolvedDocumentSettingCode, documentFiles, @@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({ isPending: finishMutation.isPending, onBack: handleBackToRoles, hideFirstStepBack: true, - initialStep: resumeFormStep, + initialStep: effectiveResumeStep, resyncOpen: opened, onStepChange: handleStepChange, onSaveStep: saveStep, @@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({ roleProfiles, licenseFiles, onLicenseChange: setLicenseFiles, + uploadedDocumentKeys, + onUploadDocuments: handleUploadDocuments, // Surface a failed final submit (license/document upload or complete) inside // the form — otherwise the server message (e.g. a 500) would be invisible on // the submit step. @@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({ withCloseButton={!completed} closeOnClickOutside={false} closeOnEscape={!completed} - size={720} + size={1440} radius="lg" padding="xl" centered @@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({ overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} styles={{ header: { - alignItems:"flex-start" + alignItems: "flex-start", }, title: { - flex: 1 - } + flex: 1, + }, }} title={ completed ? null : ( @@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({ {completed ? ( ) : ( - - - {phase === "nationality" ? ( - - - - - - - ) : phase === "role" ? ( - - - {startError && ( - - {startError} - - )} - - - - - - ) : ( - - )} - + + {phase === "nationality" ? ( + + + + + + + ) : phase === "role" ? ( + + + {startError && ( + + {startError} + + )} + + + + + + ) : ( + + )} + )} ); @@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { className="flex h-16 w-16 items-center justify-center rounded-full" style={{ background: "var(--mantine-color-edr-green-1)" }} > - + @@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { style={{ background: "var(--mantine-color-edr-green-0)" }} > - + Each operational profile (importer, exporter, freight forwarder) is reviewed and approved individually. - + You can start creating bookings under a profile as soon as it's approved — we'll let you know the moment that happens. diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index 5206a4b32..451222841 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -1,14 +1,8 @@ -import { - Anchor, - Badge, - Card, - FileInput, - Group, - Stack, - Text, - ThemeIcon, -} from "@mantine/core"; -import { FileText, Paperclip, Upload } from "lucide-react"; +import { Anchor, Group, Stack, Text } from "@mantine/core"; +import { Paperclip } from "lucide-react"; + +import { SmartFileInput } from "@edr/ui-common"; +import type { IFileUploadSetting } from "@edr/types/freight"; import type { LicenseFile } from "@/services/companies.service"; @@ -20,6 +14,48 @@ const ROLE_LABELS: Record = { transporter: "Transporter", }; +/** Field key the synthesized per-profile upload setting is keyed on. */ +const LICENSE_FILE_KEY = "business_license"; + +/** + * Build a single-field upload setting so each profile's license input can reuse + * the shared SmartFileInput (same dropzone + "uploaded" state as the documents + * step), instead of a bespoke file picker. + */ +function buildLicenseSetting( + profileId: string, + profileName: string, +): IFileUploadSetting { + return { + id: `license-setting-${profileId}`, + createdAt: "", + updatedAt: "", + deletedAt: null, + code: "business_license", + label: "Business license", + description: null, + entity: "customer", + fields: [ + { + id: `${LICENSE_FILE_KEY}-${profileId}`, + createdAt: "", + updatedAt: "", + deletedAt: null, + settingId: `license-setting-${profileId}`, + fileKey: LICENSE_FILE_KEY, + fileLabel: `Upload ${profileName} Business license file(s)`, + helpText: null, + isRequired: true, + isMultiple: true, + maxFiles: 10, + allowedExtensions: ["pdf", "png", "jpg", "jpeg"], + maxSizeMb: 10, + order: 1, + }, + ], + }; +} + export interface RoleLicenseProfile { id: string; type: string; @@ -38,8 +74,9 @@ interface RoleLicenseStepProps { /** * Final onboarding step: collect a business license (one or more files) for - * each operational role the company holds. Each role gets its own multi-file - * input; already-uploaded files are listed for context. + * each operational role the company holds. Each role gets its own SmartFileInput + * dropzone; already-uploaded files are listed (with download links) for context + * and surface the input's "uploaded" state. */ export default function RoleLicenseStep({ profiles, @@ -60,37 +97,11 @@ export default function RoleLicenseStep({ {profiles.map((profile) => { const label = ROLE_LABELS[profile.type] ?? profile.type; const selected = value[profile.id] ?? []; - const hasAny = selected.length > 0 || profile.existingFiles.length > 0; + const hasExisting = profile.existingFiles.length > 0; return ( - - - - - - -
- - {label} — Business License - - - {profile.reference} - -
-
- {hasAny && ( - - Provided - - )} -
- - {profile.existingFiles.length > 0 && ( + <> + {hasExisting && ( {profile.existingFiles.map((f) => ( @@ -108,20 +119,17 @@ export default function RoleLicenseStep({ )} - } - placeholder={ - profile.existingFiles.length > 0 - ? "Upload more / replace files" - : "Select license file(s)" - } - value={selected} - onChange={(files) => setFiles(profile.id, files ?? [])} + { + const next = v[LICENSE_FILE_KEY]; + const files = Array.isArray(next) ? next : next ? [next] : []; + setFiles(profile.id, files); + }} /> -
+ ); })} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 4f61c7a61..bfde5c43b 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -18,23 +18,17 @@ import { ArrowRight, CheckCircle2, RotateCw, - ShieldCheck, Smartphone, UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; -import { - ControlledPhoneField, - isValidPhone, - toEthiopianE164, -} from "@/components/PhoneField"; +import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; import RoleLicenseStep, { @@ -42,261 +36,21 @@ import RoleLicenseStep, { } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; import { extractApiError } from "@/utils/result"; - -type CompanyStep = - | "company" - | "personnel" - | "contact" - | "verify" - | "poa" - | "documents" - | "additional"; - -/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ -const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9); -const samePhone = (a?: string | null, b?: string | null) => { - const da = phoneDigits(a); - return da.length === 9 && da === phoneDigits(b); -}; -/** Mask all but the first 7 chars of an E.164 phone for display. */ -const maskPhone = (p: string) => - p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; - -const onboardingSchema = z.object({ - companyName: z.string().min(1, "Company name is required"), - companyEmail: z.string().email("Invalid email address"), - companyPhone: z - .string() - .min(1, "Company phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - companyLocation: z.string().min(1, "Location is required"), - // Derived from the eTrade address parts (kebele/woreda/zone/region); no - // standalone input — the granular fields live in the registration section. - companyAddress: z.string().optional(), - tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), - vatNumber: z - .string() - .min(1, "VAT number is required") - .length(10, "VAT number must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), - licenceNumber: z.string().optional(), - statusDescription: z.string().optional(), - dateRegistered: z.string().optional(), - renewedFrom: z.string().optional(), - renewalDate: z.string().optional(), - renewedTo: z.string().optional(), - // Address fields are user-entered and required (the registration/license - // fields above are read-only confirmations pulled from eTrade). - region: z.string().min(1, "Region is required"), - zone: z.string().min(1, "Zone is required"), - woreda: z.string().min(1, "Woreda is required"), - kebele: z.string().min(1, "Kebele is required"), - houseNo: z.string().min(1, "House number is required"), - etradePhone: z.string().optional(), - contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPosition: z.string().optional(), - contactPersonEmail: z - .string() - .email("Invalid email address") - .optional() - .or(z.literal("")), - contactPersonPhone: z - .string() - .min(1, "Contact person phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - generalManagerName: z.string().min(1, "Manager name is required"), - generalManagerEmail: z.string().email("Invalid Manager email"), - generalManagerPhone: z - .string() - .min(1, "Manager phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - poaName: z.string().optional(), - poaPhone: z - .string() - .optional() - .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), - poaAddress: z.string().optional(), - poaEmail: z.string().optional(), - poaLocation: z.string().optional(), -}); - -type FormData = z.infer; - -const stepFields: Record = { - company: [ - "companyName", - "companyEmail", - "companyPhone", - "companyLocation", - "companyAddress", - "tinNumber", - "vatNumber", - "fanNumber", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", - "region", - "zone", - "woreda", - "kebele", - "houseNo", - "etradePhone", - ], - personnel: [ - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - ], - contact: [ - "contactPersonName", - "contactPersonPosition", - "contactPersonEmail", - "contactPersonPhone", - ], - verify: [], - poa: [], - documents: [], - additional: [], -}; - -function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { - return { - companyName: data.companyName, - companyEmail: data.companyEmail, - companyPhone: data.companyPhone, - companyLocation: data.companyLocation, - companyAddress: data.companyAddress, - tin: data.tinNumber, - vatNumber: data.vatNumber, - fanNumber: data.fanNumber, - attributes: { - contactPersonName: data.contactPersonName, - contactPersonPosition: data.contactPersonPosition || undefined, - contactPersonEmail: data.contactPersonEmail || undefined, - contactPersonPhone: data.contactPersonPhone, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: data.generalManagerPhone, - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaAddress: data.poaAddress || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - }, - }; -} - -/** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload( - step: CompanyStep, - d: FormData, -): Partial { - switch (step) { - case "company": - return { - companyName: d.companyName, - companyEmail: d.companyEmail, - companyPhone: d.companyPhone, - companyLocation: d.companyLocation, - companyAddress: d.companyAddress, - tin: d.tinNumber, - vatNumber: d.vatNumber, - fanNumber: d.fanNumber, - licenceNumber: d.licenceNumber, - statusDescription: d.statusDescription, - dateRegistered: d.dateRegistered, - renewedFrom: d.renewedFrom, - renewalDate: d.renewalDate, - renewedTo: d.renewedTo, - region: d.region, - zone: d.zone, - woreda: d.woreda, - kebele: d.kebele, - houseNo: d.houseNo, - etradePhone: d.etradePhone, - }; - case "personnel": - return { - generalManagerName: d.generalManagerName, - generalManagerEmail: d.generalManagerEmail, - generalManagerPhone: d.generalManagerPhone, - }; - case "contact": - return { - contactPersonName: d.contactPersonName, - contactPersonPosition: d.contactPersonPosition || undefined, - contactPersonEmail: d.contactPersonEmail || undefined, - contactPersonPhone: d.contactPersonPhone, - }; - case "poa": - return { - poaName: d.poaName || undefined, - poaPhone: d.poaPhone || undefined, - poaEmail: d.poaEmail || undefined, - poaLocation: d.poaLocation || undefined, - poaAddress: d.poaAddress || undefined, - }; - default: - return {}; - } -} - -/** Seed the form from previously-saved profile data. */ -function toFormValues(p: ProfileResponse): FormData { - // The draft placeholder TIN ("D…") shouldn't show as a real value. - const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; - return { - companyName: p.companyName ?? "", - companyEmail: p.companyEmail ?? "", - companyPhone: p.companyPhone ?? "", - companyLocation: p.companyLocation ?? "", - companyAddress: p.companyAddress ?? "", - tinNumber: tin, - vatNumber: p.vatNumber ?? "", - fanNumber: p.fanNumber ?? "", - licenceNumber: p.licenceNumber ?? "", - statusDescription: p.statusDescription ?? "", - dateRegistered: p.dateRegistered ?? "", - renewedFrom: p.renewedFrom ?? "", - renewalDate: p.renewalDate ?? "", - renewedTo: p.renewedTo ?? "", - region: p.region ?? "", - zone: p.zone ?? "", - woreda: p.woreda ?? "", - kebele: p.kebele ?? "", - houseNo: p.houseNo ?? "", - etradePhone: p.etradePhone ?? "", - contactPersonName: p.contactPersonName ?? "", - contactPersonPosition: p.contactPersonPosition ?? "", - contactPersonEmail: p.contactPersonEmail ?? "", - contactPersonPhone: p.contactPersonPhone ?? "", - generalManagerName: p.generalManagerName ?? "", - generalManagerEmail: p.generalManagerEmail ?? "", - generalManagerPhone: p.generalManagerPhone ?? "", - poaName: p.poaName ?? "", - poaPhone: p.poaPhone ?? "", - poaAddress: p.poaAddress ?? "", - poaEmail: p.poaEmail ?? "", - poaLocation: p.poaLocation ?? "", - }; -} - -/** A single read-only registration value rendered as a label/value pair. */ -function ReadOnlyField({ label, value }: { label: string; value?: string }) { - return ( - - - {label} - - - {value && value.trim() ? value : "—"} - - - ); -} +import { + type CompanyStep, + type FormData, + onboardingSchema, + stepFields, +} from "./companyProfileForm/schema"; +import { + buildPayload, + maskPhone, + samePhone, + stepPayload, + toFormValues, +} from "./companyProfileForm/helpers"; +import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard"; +import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField"; export default function CompanyProfileForm({ documentSettingCode, @@ -316,6 +70,8 @@ export default function CompanyProfileForm({ licenseFiles, onLicenseChange, submitError, + uploadedDocumentKeys, + onUploadDocuments, }: { documentSettingCode: string; documentFiles?: Record; @@ -345,6 +101,16 @@ export default function CompanyProfileForm({ onLicenseChange?: (value: Record) => void; /** Server error from the final submit (uploads/complete), shown verbatim. */ submitError?: string | null; + /** fileKeys whose company document is already uploaded server-side (resume). */ + uploadedDocumentKeys?: string[]; + /** + * Auto-upload the currently-selected company documents (the Documents step's + * "Continue" action). Resolves to an error message string on failure so the + * step can surface it and hold the user in place. + */ + onUploadDocuments?: () => Promise< + { ok: true } | { ok: false; error: string } + >; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -356,17 +122,40 @@ export default function CompanyProfileForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); + // Tracks whether the user has manually navigated the form this session. While + // false, the form still follows the parent's resume target (initialStep) — + // which can shift to an earlier step once server data lands (e.g. a required + // document turns out to be un-uploaded, so we must not rest on a later step). + const userNavigatedRef = useRef(false); + // On reopen, jump to the furthest step reached (initialStep) so progress - // never appears to reset. + // never appears to reset. Re-arm the follow-the-parent behaviour too. const wasOpen = useRef(resyncOpen); useEffect(() => { if (resyncOpen && !wasOpen.current && initialStep) { + userNavigatedRef.current = false; setStep(initialStep); setSaveError(null); } wasOpen.current = resyncOpen; // eslint-disable-next-line react-hooks/exhaustive-deps }, [resyncOpen]); + + // Follow a parent-driven resume correction: if initialStep changes (the wizard + // re-clamps it back once onboarding requirements load — e.g. a required + // document is still missing, so it must not skip ahead to Business License), + // adopt it, but only while the user hasn't started navigating themselves. + const lastInitialStep = useRef(initialStep); + useEffect(() => { + if (initialStep && initialStep !== lastInitialStep.current) { + lastInitialStep.current = initialStep; + if (!userNavigatedRef.current) { + setStep(initialStep); + setSaveError(null); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialStep]); const [internalFiles, setInternalFiles] = useState< Record >({}); @@ -410,7 +199,6 @@ export default function CompanyProfileForm({ woreda: "", kebele: "", houseNo: "", - etradePhone: "", contactPersonName: "", contactPersonPosition: "", contactPersonEmail: "", @@ -482,7 +270,7 @@ export default function CompanyProfileForm({ setValue("kebele", data.kebele); setValue("houseNo", data.houseNo); setValue( - "etradePhone", + "companyPhone", toEthiopianE164(data.regularPhone || data.mobilePhone), ); // companyAddress is composed reactively from the address fields below, so @@ -512,33 +300,54 @@ export default function CompanyProfileForm({ }); }; - /** Copy the General Manager into the Contact Person fields (still editable). */ - const useGmAsContact = () => { - setValue("contactPersonName", watch("generalManagerName"), { - shouldValidate: true, - }); - setValue("contactPersonEmail", watch("generalManagerEmail")); - setValue("contactPersonPhone", watch("generalManagerPhone"), { - shouldValidate: true, - }); + // "Same as …" links. A checked card prefills the target step's fields from the + // source step and disables them (kept mirrored while linked); unchecking clears + // them and re-enables editing. + const [contactSameAsGm, setContactSameAsGm] = useState(false); + const [poaSameAsContact, setPoaSameAsContact] = useState(false); + + const gmName = watch("generalManagerName"); + const gmEmail = watch("generalManagerEmail"); + const gmPhone = watch("generalManagerPhone"); + const contactName = watch("contactPersonName"); + const contactEmail = watch("contactPersonEmail"); + const contactPhone = watch("contactPersonPhone"); + + // While linked, mirror the source values into the (disabled) target fields so + // the copy stays current even if the user goes back and edits the source. + useEffect(() => { + if (!contactSameAsGm) return; + setValue("contactPersonName", gmName ?? "", { shouldValidate: true }); + setValue("contactPersonEmail", gmEmail ?? ""); + setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contactSameAsGm, gmName, gmEmail, gmPhone]); + + useEffect(() => { + if (!poaSameAsContact) return; + setValue("poaName", contactName ?? ""); + setValue("poaEmail", contactEmail ?? ""); + setValue("poaPhone", contactPhone ?? ""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [poaSameAsContact, contactName, contactEmail, contactPhone]); + + const toggleContactSameAsGm = (checked: boolean) => { + setContactSameAsGm(checked); + // Checked → the mirror effect fills the fields; unchecked → reset them. + if (!checked) { + setValue("contactPersonName", ""); + setValue("contactPersonEmail", ""); + setValue("contactPersonPhone", ""); + } }; - /** Copy the Contact Person into the PoA fields (still editable). */ - const useContactAsPoa = () => { - setValue("poaName", watch("contactPersonName")); - setValue("poaEmail", watch("contactPersonEmail")); - setValue("poaPhone", watch("contactPersonPhone")); - }; - - /** Populate the Contact Person from the currently logged-in user. */ - const useLoggedInUserAsContact = () => { - setValue("contactPersonName", user?.name?.en ?? "", { - shouldValidate: true, - }); - if (user?.email) setValue("contactPersonEmail", user.email); - setValue("contactPersonPhone", user?.phoneNumber ?? "", { - shouldValidate: true, - }); + const togglePoaSameAsContact = (checked: boolean) => { + setPoaSameAsContact(checked); + if (!checked) { + setValue("poaName", ""); + setValue("poaEmail", ""); + setValue("poaPhone", ""); + } }; // --- Contact-phone SMS OTP verification ----------------------------------- @@ -612,7 +421,7 @@ export default function CompanyProfileForm({ setOtpSent(false); // Persist the verified phone so the step resumes as "done" after a refresh // (best-effort — the OTP itself already succeeded server-side). - onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {}); + onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { }); } catch (err) { setOtpError(extractApiError(err).message); } finally { @@ -675,6 +484,7 @@ export default function CompanyProfileForm({ ); const nextStep = async () => { + userNavigatedRef.current = true; if (step === "additional") { if (!licenseComplete) { setSaveError( @@ -699,16 +509,34 @@ export default function CompanyProfileForm({ setStep(stepOrder[currentIdx + 1]); return; } - // The documents step has nothing to persist; field steps validate + save - // before advancing. - if (step !== "documents") { - const ok = await saveCurrentStep(); - if (!ok) return; + // The documents step auto-uploads whatever the user selected as they + // continue (partial uploads are allowed — required-doc completeness is + // re-checked on resume). A failed upload holds them on the step. + if (step === "documents") { + if (onUploadDocuments) { + setSaving(true); + try { + const res = await onUploadDocuments(); + if (!res.ok) { + setSaveError(res.error); + return; + } + } finally { + setSaving(false); + } + } + setSaveError(null); + setStep(stepOrder[currentIdx + 1]); + return; } + // Field steps validate + save before advancing. + const ok = await saveCurrentStep(); + if (!ok) return; setStep(stepOrder[currentIdx + 1]); }; const prevStep = () => { + userNavigatedRef.current = true; setSaveError(null); if (currentIdx === 0) onBack(); else setStep(stepOrder[currentIdx - 1]); @@ -864,11 +692,6 @@ export default function CompanyProfileForm({ error={errors.houseNo?.message} {...register("houseNo")} /> - )} @@ -917,33 +740,17 @@ export default function CompanyProfileForm({ {step === "contact" && ( <> - - - Contact Person - - - - {watch("generalManagerName") && ( - - )} - - + + Contact Person + + {watch("generalManagerName") && ( + + )} - - - - Verify the contact person - - We'll text a one-time code to the contact person's phone to confirm it's reachable. This is required before you continue. @@ -1009,7 +810,10 @@ export default function CompanyProfileForm({ ) : ( - + {maskPhone(contactPhoneE164)} @@ -1028,15 +832,17 @@ export default function CompanyProfileForm({ ) : ( - - Enter the 6-digit code we sent to{" "} - {maskPhone(contactPhoneE164)}. - @@ -1056,7 +862,9 @@ export default function CompanyProfileForm({ disabled={resendIn > 0 || sendingOtp} leftSection={} > - {resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} + {resendIn > 0 + ? `Resend in ${resendIn}s` + : "Resend code"} @@ -1078,24 +886,18 @@ export default function CompanyProfileForm({ {step === "poa" && ( <> - - - Power of Attorney details are optional. Fill them in if you - have them, or skip to continue. - - {watch("contactPersonName") && ( - - )} - + + Power of Attorney details are optional. Fill them in if you have + them, or skip to continue. + + {watch("contactPersonName") && ( + + )} )} @@ -1210,19 +1014,12 @@ export default function CompanyProfileForm({ } loading={isPending || saving} rightSection={ - !isPending && - !saving && - step !== "additional" && - step !== "documents" ? ( + !isPending && !saving && step !== "additional" ? ( ) : undefined } > - {step === "documents" || step === "verify" - ? "Continue" - : step === "additional" - ? "Submit for review" - : "Save & Continue"} + {step === "additional" ? "Submit for review" : "Continue"} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx new file mode 100644 index 000000000..da0dd6254 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx @@ -0,0 +1,51 @@ +import { Group, Text, UnstyledButton } from "@mantine/core"; +import { Check } from "lucide-react"; + +/** + * A card styled as a large checkbox: clicking it toggles `checked`, which the + * caller uses to prefill + lock a set of fields (and clear them on uncheck). + */ +export function LinkCheckboxCard({ + checked, + onToggle, + title, + description, +}: { + checked: boolean; + onToggle: (checked: boolean) => void; + title: string; + description: string; +}) { + return ( + onToggle(!checked)} + role="checkbox" + aria-checked={checked} + className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked + ? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!" + : "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!" + }`} + > + +
+ {checked && } +
+
+ + {title} + + + {description} + +
+
+
+ ); +} + +export default LinkCheckboxCard; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx new file mode 100644 index 000000000..6465375e7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx @@ -0,0 +1,23 @@ +import { Stack, Text } from "@mantine/core"; + +/** A single read-only registration value rendered as a label/value pair. */ +export function ReadOnlyField({ + label, + value, +}: { + label: string; + value?: string; +}) { + return ( + + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + +export default ReadOnlyField; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts new file mode 100644 index 000000000..8f8a24eca --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -0,0 +1,142 @@ +import type { AuthUser } from "@/types/auth"; +import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; + +import type { CompanyStep, FormData } from "./schema"; + +/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ +export const phoneDigits = (p?: string | null) => + (p ?? "").replace(/\D/g, "").slice(-9); + +export const samePhone = (a?: string | null, b?: string | null) => { + const da = phoneDigits(a); + return da.length === 9 && da === phoneDigits(b); +}; + +/** Mask all but the first 7 chars of an E.164 phone for display. */ +export const maskPhone = (p: string) => + p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; + +export function buildPayload( + data: FormData, + _user: AuthUser, +): CreateCompanyPayload { + return { + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: data.companyPhone, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + tin: data.tinNumber, + vatNumber: data.vatNumber, + fanNumber: data.fanNumber, + attributes: { + contactPersonName: data.contactPersonName, + contactPersonPosition: data.contactPersonPosition || undefined, + contactPersonEmail: data.contactPersonEmail || undefined, + contactPersonPhone: data.contactPersonPhone, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: data.generalManagerPhone, + poaName: data.poaName || undefined, + poaPhone: data.poaPhone || undefined, + poaAddress: data.poaAddress || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + }, + }; +} + +/** Map one wizard step's form values to the profile-update payload it saves. */ +export function stepPayload( + step: CompanyStep, + d: FormData, +): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: d.companyPhone, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + licenceNumber: d.licenceNumber, + statusDescription: d.statusDescription, + dateRegistered: d.dateRegistered, + renewedFrom: d.renewedFrom, + renewalDate: d.renewalDate, + renewedTo: d.renewedTo, + region: d.region, + zone: d.zone, + woreda: d.woreda, + kebele: d.kebele, + houseNo: d.houseNo, + etradePhone: d.companyPhone, + }; + case "personnel": + return { + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: d.generalManagerPhone, + }; + case "contact": + return { + contactPersonName: d.contactPersonName, + contactPersonPosition: d.contactPersonPosition || undefined, + contactPersonEmail: d.contactPersonEmail || undefined, + contactPersonPhone: d.contactPersonPhone, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: d.poaPhone || undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + +/** Seed the form from previously-saved profile data. */ +export function toFormValues(p: ProfileResponse): FormData { + // The draft placeholder TIN ("D…") shouldn't show as a real value. + const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; + return { + companyName: p.companyName ?? "", + companyEmail: p.companyEmail ?? "", + companyPhone: p.companyPhone ?? "", + companyLocation: p.companyLocation ?? "", + companyAddress: p.companyAddress ?? "", + tinNumber: tin, + vatNumber: p.vatNumber ?? "", + fanNumber: p.fanNumber ?? "", + licenceNumber: p.licenceNumber ?? "", + statusDescription: p.statusDescription ?? "", + dateRegistered: p.dateRegistered ?? "", + renewedFrom: p.renewedFrom ?? "", + renewalDate: p.renewalDate ?? "", + renewedTo: p.renewedTo ?? "", + region: p.region ?? "", + zone: p.zone ?? "", + woreda: p.woreda ?? "", + kebele: p.kebele ?? "", + houseNo: p.houseNo ?? "", + contactPersonName: p.contactPersonName ?? "", + contactPersonPosition: p.contactPersonPosition ?? "", + contactPersonEmail: p.contactPersonEmail ?? "", + contactPersonPhone: p.contactPersonPhone ?? "", + generalManagerName: p.generalManagerName ?? "", + generalManagerEmail: p.generalManagerEmail ?? "", + generalManagerPhone: p.generalManagerPhone ?? "", + poaName: p.poaName ?? "", + poaPhone: p.poaPhone ?? "", + poaAddress: p.poaAddress ?? "", + poaEmail: p.poaEmail ?? "", + poaLocation: p.poaLocation ?? "", + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts new file mode 100644 index 000000000..31ee21ced --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +import { isValidPhone } from "@/components/PhoneField"; + +export type CompanyStep = + | "company" + | "personnel" + | "contact" + | "verify" + | "poa" + | "documents" + | "additional"; + +export const onboardingSchema = z.object({ + companyName: z.string().min(1, "Company name is required"), + companyEmail: z.string().email("Invalid email address"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), + companyLocation: z.string().min(1, "Location is required"), + // Derived from the eTrade address parts (kebele/woreda/zone/region); no + // standalone input — the granular fields live in the registration section. + companyAddress: z.string().optional(), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + vatNumber: z + .string() + .min(1, "VAT number is required") + .length(10, "VAT number must be exactly 10 digits"), + fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + licenceNumber: z.string().optional(), + statusDescription: z.string().optional(), + dateRegistered: z.string().optional(), + renewedFrom: z.string().optional(), + renewalDate: z.string().optional(), + renewedTo: z.string().optional(), + // Address fields are user-entered and required (the registration/license + // fields above are read-only confirmations pulled from eTrade). + region: z.string().min(1, "Region is required"), + zone: z.string().min(1, "Zone is required"), + woreda: z.string().min(1, "Woreda is required"), + kebele: z.string().min(1, "Kebele is required"), + houseNo: z.string().min(1, "House number is required"), + contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPosition: z.string().optional(), + contactPersonEmail: z + .string() + .email("Invalid email address") + .optional() + .or(z.literal("")), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), + generalManagerName: z.string().min(1, "Manager name is required"), + generalManagerEmail: z.string().email("Invalid Manager email"), + generalManagerPhone: z + .string() + .min(1, "Manager phone is required") + .refine(isValidPhone, "Enter a valid phone number"), + poaName: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), + poaAddress: z.string().optional(), + poaEmail: z.string().optional(), + poaLocation: z.string().optional(), +}); + +export type FormData = z.infer; + +export const stepFields: Record = { + company: [ + "companyName", + "companyEmail", + "companyPhone", + "companyLocation", + "companyAddress", + "tinNumber", + "vatNumber", + "fanNumber", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + ], + personnel: [ + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", + ], + contact: [ + "contactPersonName", + "contactPersonPosition", + "contactPersonEmail", + "contactPersonPhone", + ], + verify: [], + poa: [], + documents: [], + additional: [], +}; diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts index 5d3a288cd..5652ae91d 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -3,15 +3,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { Currency } from '@prisma/client'; -// Nationality → home currency mapping +// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup) export const NATIONALITY_CURRENCY_MAP: Record = { - Ethiopian: Currency.ETB, - Djiboutian: Currency.DJF, + ETHIOPIAN: Currency.ETB, + DJIBOUTIAN: Currency.DJF, }; export function resolveCurrencyFromNationality(nationality?: string): Currency { if (!nationality) return Currency.ETB; - return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD; + return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD; } export class FareCalculateDto { @@ -29,7 +29,7 @@ export class FareCalculateDto { @ApiPropertyOptional({ example: 'Ethiopian', - description: 'Passenger nationality. Determines the billing currency: Ethiopian → ETB, Djiboutian → DJF, other → USD. Defaults to ETB.', + description: 'Passenger nationality. Determines the billing currency: ETHIOPIAN → ETB, DJIBOUTIAN → DJF, other → USD. Case-insensitive. Defaults to ETB.', }) @IsOptional() @IsString() nationality?: string; diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index c7ed031cd..2660fe616 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -295,20 +295,34 @@ export class NotificationsService { { category: 'PAYMENT', deepLink: `edr://tickets/${ref}` }, ); + // Resolve SMS phone: prefer the IAM user's stored number, fall back to the phone + // the passenger entered on the booking form (contactPhone). + const contactPhone: string | null = (booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null; + const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null; + const smsPhone = iamPhone ?? contactPhone; + // Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation. if (!ticket || !booking) { this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`); const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`; await this.deliverEmail(passengerId, `Payment received — ${ref}`, text); - await this.deliverSms(passengerId, text); + if (smsPhone) { + await this.smsClient.sendSms({ to: smsPhone, message: text }).catch(() => null); + } else { + this.logger.warn(`No SMS phone for booking ${ref}`); + } return; } // SMS — short pointer (no HTML/QR over SMS). - await this.deliverSms( - passengerId, - `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`, - ); + if (smsPhone) { + await this.smsClient.sendSms({ + to: smsPhone, + message: `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`, + }).catch(() => null); + } else { + this.logger.warn(`No SMS phone for booking ${ref}`); + } // EMAIL — rich HTML ticket with plain-text fallback. await this.deliverEmail( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 6a1df8cb1..aea08150e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -128,12 +128,16 @@ export class PaymentsController { @ApiOperation({ summary: "List payment systems supported by the platform", description: - "Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.", + "Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.", }) + @ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) - getMethods(@Query("region") region?: PaymentRegionEnum) { - return this.service.getSupportedPaymentMethods(region); + getMethods( + @Query("currency") currency?: string, + @Query("region") region?: PaymentRegionEnum, + ) { + return this.service.getSupportedPaymentMethods(region, currency); } @Get("checkout") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 7453e1c06..98d7520cf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -473,7 +473,7 @@ export class PaymentsService { }); } - getSupportedPaymentMethods(region?: PaymentRegionEnum) { + getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) { return this.prisma.paymentMethod.findMany({ where: { enabled: true, @@ -487,6 +487,7 @@ export class PaymentsService { }, } : {}), + ...(currency ? { currency: currency.toUpperCase() } : {}), }, orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 4797c6c19..3a7b02682 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -4,6 +4,7 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; +import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; @@ -307,9 +308,13 @@ export class SearchService { (new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000, ); - const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); - const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); - const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0); + const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); + const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); + const leg1MinDisplay = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity); + const leg2MinDisplay = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity); + const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0); + const combinedMinFareDisplay = (isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) + (isFinite(leg2MinDisplay) ? leg2MinDisplay : 0); + const displayCurrency = leg1Result.displayCurrency ?? leg2Result.displayCurrency ?? Currency.ETB; results.push({ type: 'TRANSIT', @@ -318,7 +323,9 @@ export class SearchService { connectionMinutes, leg1: leg1Result, leg2: leg2Result, + displayCurrency, combinedMinFareMinor, + combinedMinFareDisplay, // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, @@ -379,6 +386,8 @@ export class SearchService { const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; + const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality); + return { type: 'DIRECT', scheduleId: schedule.id, @@ -395,6 +404,7 @@ export class SearchService { .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), + displayCurrency, faresByClass, coachTypes, }; @@ -467,7 +477,7 @@ export class SearchService { const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); - const displayCurrency = dto.displayCurrency ?? Currency.ETB; + const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; @@ -494,7 +504,9 @@ export class SearchService { originStationId: string, destinationStationId: string, nationality?: string, - ): Promise> { + ): Promise> { + const displayCurrency = resolveCurrencyFromNationality(nationality); + const seatClassIds: string[] = Array.from( new Set( schedule.coachAssignments @@ -535,8 +547,10 @@ export class SearchService { scheduleId: schedule.id, }); return { - seatClassName: fare.seatClassName, - baseFareMinor: fare.baseFarePerPassengerMinor, + seatClassName: fare.seatClassName, + baseFareMinor: fare.baseFarePerPassengerMinor, + displayCurrency: fare.billingCurrency as Currency, + displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), }; } catch (error) { console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); @@ -545,7 +559,9 @@ export class SearchService { }), ); - const validResults = results.filter((r): r is { seatClassName: string; baseFareMinor: number } => r !== null); + const validResults = results.filter( + (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, + ); if (validResults.length > 0) { return validResults; } @@ -573,9 +589,12 @@ export class SearchService { if (fareRules.length > 0) { console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); + const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); return fareRules.map(rule => ({ - seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', - baseFareMinor: rule.baseFareMinor, + seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', + baseFareMinor: rule.baseFareMinor, + displayCurrency, + displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), })); } } @@ -586,13 +605,13 @@ export class SearchService { private async buildCoachTypeDetails( schedule: any, - faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>, + faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, ): Promise; + classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>; }>> { const coachTypeMap = new Map< string, @@ -621,9 +640,14 @@ export class SearchService { .map((className) => { const fareInfo = faresByClass.find((f) => f.seatClassName === className); if (!fareInfo) return null; - return { name: className, baseFareMinor: fareInfo.baseFareMinor }; + return { + name: className, + baseFareMinor: fareInfo.baseFareMinor, + displayCurrency: fareInfo.displayCurrency, + displayAmountMinor: fareInfo.displayAmountMinor, + }; }) - .filter((c): c is { name: string; baseFareMinor: number } => c !== null) + .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); result.push({ diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index f67e9b3f3..4a0931c6e 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -307,9 +307,9 @@ export class SeatsService { throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`); } - const blocked = seats.filter(s => s.status === 'BLOCKED'); + const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD'); if (blocked.length > 0) - throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`); + throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`); const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber])); @@ -334,28 +334,34 @@ export class SeatsService { select: { seatIds: true, createdBy: true }, }); - const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = []; + const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = []; for (const h of activeHolds) { + const rawSeatIds = h.seatIds as string[]; try { if (h.createdBy?.trimStart().startsWith('{')) { const meta = JSON.parse(h.createdBy); const holdFrom = seqOf(meta.originStationId); const holdTo = seqOf(meta.destinationStationId); - if (holdFrom !== undefined && holdTo !== undefined) { - parsedHolds.push({ - seatIds: h.seatIds, - from: holdFrom, - to: holdTo, - passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId), - }); - } + parsedHolds.push({ + seatIds: rawSeatIds, + from: holdFrom ?? 0, + to: holdTo ?? Number.MAX_SAFE_INTEGER, + passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId), + legUnknown: holdFrom === undefined || holdTo === undefined, + }); + } else { + // Legacy plain-string createdBy — can't determine leg; block conservatively. + parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true }); } - } catch { /* ignore */ } + } catch { + // Malformed JSON — block conservatively. + parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true }); + } } for (const { passengerId, seatId } of dto.passengers) { for (const hold of parsedHolds) { - const legsOverlap = hold.from < reqTo && reqFrom < hold.to; + const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to); if (!legsOverlap) continue; if (hold.seatIds.includes(seatId)) { @@ -364,7 +370,7 @@ export class SeatsService { ); } - if (hold.passengerIds.includes(passengerId)) { + if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) { throw new ConflictException( `Passenger already holds a seat on this journey leg`, ); @@ -386,12 +392,14 @@ export class SeatsService { if (!seg.seatId) continue; const segFrom = seqOf(seg.departureStationId); const segTo = seqOf(seg.arrivalStationId); - if (segFrom !== undefined && segTo !== undefined) { - if (segFrom < reqTo && reqFrom < segTo) { - throw new ConflictException( - `Seat ${seatLabelById[seg.seatId]} is already booked for this leg`, - ); - } + // If stations can't be resolved, assume overlap (conservative) to prevent double-booking. + const overlaps = (segFrom === undefined || segTo === undefined) + ? true + : segFrom < reqTo && reqFrom < segTo; + if (overlaps) { + throw new ConflictException( + `Seat ${seatLabelById[seg.seatId]} is already booked for this leg`, + ); } } @@ -401,6 +409,13 @@ export class SeatsService { passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })), }; + // Mark seats as HELD so the status check catches them immediately on any + // subsequent hold attempt (avoids relying solely on the SeatHold table scan). + await tx.seat.updateMany({ + where: { id: { in: seatIds } }, + data: { status: 'HELD' }, + }); + return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, @@ -541,11 +556,16 @@ export class SeatsService { async releaseHold(holdId: string) { const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); if (!hold) throw new NotFoundException('Hold not found'); - await this.prisma.seatHold.delete({ where: { id: holdId } }); + await this.prisma.$transaction([ + this.prisma.seat.updateMany({ + where: { id: { in: hold.seatIds as string[] }, status: 'HELD' }, + data: { status: 'AVAILABLE' }, + }), + this.prisma.seatHold.delete({ where: { id: holdId } }), + ]); return { released: true, holdId }; } - // Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy. async confirmSeats(_seatIds: string[]) {} // Delete the Journey (and its JourneySegments) scoped to this booking. @@ -719,7 +739,18 @@ export class SeatsService { @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { - // Holds are temporary and don't create Journey rows — just delete expired ones. + const expired = await this.prisma.seatHold.findMany({ + where: { expiresAt: { lt: new Date() } }, + select: { id: true, seatIds: true }, + }); + if (expired.length === 0) return; + + const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]); + // Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched. + await this.prisma.seat.updateMany({ + where: { id: { in: expiredSeatIds }, status: 'HELD' }, + data: { status: 'AVAILABLE' }, + }); await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index ecc17dc53..f39c268e2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @@ -15,6 +15,8 @@ interface OfflineValidation { @Injectable() export class TicketsService { + private readonly logger = new Logger(TicketsService.name); + constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, @@ -154,17 +156,29 @@ export class TicketsService { ); } - // Booking not in CONFIRMED state (safety net — should align with SUCCEEDED) + // Booking not in CONFIRMED state — could be a webhook delivery failure. + // If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT, + // self-heal here rather than rejecting a legitimately paid booking. if (booking.status !== 'CONFIRMED') { - throw new HttpException( - { - status: 'error', - message: 'Payment not completed', - code: 400, - detail: `Booking status: ${booking.status}`, - }, - HttpStatus.BAD_REQUEST, - ); + if (booking.status === 'PENDING_PAYMENT') { + this.logger.warn( + `Booking ${bookingId} is PENDING_PAYMENT but payment intent SUCCEEDED — webhook likely missed. Auto-confirming before ticket generation.`, + ); + await this.prisma.booking.update({ + where: { id: bookingId }, + data: { status: 'CONFIRMED' }, + }); + } else { + throw new HttpException( + { + status: 'error', + message: 'Payment not completed', + code: 400, + detail: `Booking status: ${booking.status}`, + }, + HttpStatus.BAD_REQUEST, + ); + } } // Build a compact multi-leg payload for the QR so gate scanners see all legs diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index d1b7cdf87..6caa5733b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -8,6 +8,9 @@ import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { usePermission } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { bookingsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; @@ -26,7 +29,9 @@ const SectionHeader = ({ title }: { title: string }) => ( ); -export default function BookingsPage() { +function BookingsPageContent() { + const canManage = usePermission(PERMS.bookings.manage); + const canCancel = usePermission(PERMS.bookings.cancel); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); @@ -481,3 +486,11 @@ export default function BookingsPage() {
); } + +export default function BookingsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index d61acff41..66d5ebeb6 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -1,6 +1,8 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; import { Ticket, Users, DollarSign, Percent } from 'lucide-react'; import StatCard from '@/components/dashboard/StatCard'; import DataTable from '@/components/ui/DataTable'; @@ -11,7 +13,7 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, R const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; -export default function DashboardPage() { +function DashboardPageContent() { const { data: stats, isLoading: statsLoading } = useQuery({ queryKey: ['dashboard-stats'], queryFn: dashboardApi.getStats, @@ -237,3 +239,11 @@ export default function DashboardPage() {
); } + +export default function DashboardPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index 0e821873e..d9c9b6220 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -42,7 +42,12 @@ export default function LoginPage() { await login(email, password); router.push('/dashboard'); } catch (err: any) { - setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.'); + const msg = err.message || err.response?.data?.message || ''; + if (msg === 'ACCESS_DENIED') { + setError('This account does not have back-office access. Contact your administrator.'); + } else { + setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.'); + } } finally { setLoading(false); } diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx new file mode 100644 index 000000000..2da55e37f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/auth-store'; + +interface Props { + permission?: string; + children: React.ReactNode; +} + +/** + * Wraps a page to enforce auth + optional permission check. + * - Not logged in → redirect to /login + * - Missing permission → redirect to /dashboard + */ +export function PermissionGuard({ permission, children }: Props) { + const router = useRouter(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const hasPermission = useAuthStore((s) => s.hasPermission); + + useEffect(() => { + if (!isAuthenticated) { + router.replace('/login'); + return; + } + if (permission && !hasPermission(permission)) { + router.replace('/dashboard'); + } + }, [isAuthenticated, permission, hasPermission, router]); + + if (!isAuthenticated) return null; + if (permission && !hasPermission(permission)) return null; + + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 96798936b..a2a9f68b0 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -39,49 +39,65 @@ import { import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; import { useTheme } from '@/lib/theme-store'; +import { PERMS } from '@/lib/permissions'; -const navigationSections = [ +interface NavItem { + name: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + permission?: string; +} + +const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Overview', items: [ - { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, permission: PERMS.dashboard }, ] }, { title: 'Operations', items: [ - { name: 'Bookings', href: '/bookings', icon: Ticket }, - { name: 'Passengers', href: '/passengers', icon: Users }, - { name: 'Tickets', href: '/tickets', icon: FileText }, - { name: 'Lugagges', href: '/excess-baggage', icon: Banknote }, + { name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view }, + { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view }, + { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view }, + { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, ] }, { title: 'Tourism', items: [ - { name: 'Packages', href: '/packages', icon: Package }, - { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare }, + { name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin }, + { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin }, ] }, { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin }, - { name: 'Trains', href: '/trains', icon: Train }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, - { name: 'Seats', href: '/seats', icon: Armchair }, - { name: 'Classes', href: '/classes', icon: Settings }, - { name: 'Routes', href: '/routes', icon: Route }, - { name: 'Schedules', href: '/schedules', icon: Calendar }, + { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.admin }, + { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.admin }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.admin }, + { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.admin }, + { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.admin }, + { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.admin }, + { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.admin }, ] }, { title: 'Financial', items: [ - { name: 'Fares', href: '/pricing', icon: DollarSign }, - { name: 'Currencies', href: '/currencies', icon: Banknote }, - { name: 'Payments', href: '/payments', icon: CreditCard }, - { name: 'Promos', href: '/promos', icon: Gift }, + { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin }, + { name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage }, + { name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view }, + { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin }, + ] + }, + { + title: 'Customer Services', + items: [ + { name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view }, + { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view }, + { name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send }, ] }, // { @@ -95,32 +111,32 @@ const navigationSections = [ { title: 'Security & Compliance', items: [ - { name: 'Logs', href: '/audit', icon: AlertTriangle }, - { name: 'Fraud', href: '/fraud', icon: Shield }, - { name: 'Verifayda', href: '/verifayda', icon: UserCheck }, + { name: 'Audit Logs', href: '/audit', icon: AlertTriangle, permission: PERMS.audit.view }, + { name: 'Fraud Detection', href: '/fraud', icon: Shield, permission: PERMS.fraud.view }, + { name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck, permission: PERMS.admin }, ] }, { title: 'Analytics & Reports', items: [ - { name: 'Reports', href: '/reports', icon: BarChart3 }, - { name: 'Operational', href: '/operational-reports', icon: FileText }, + { name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view }, + { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, ] }, { title: 'System', items: [ - { name: 'Agents', href: '/agents', icon: Briefcase }, - { name: 'Users', href: '/settings/users', icon: Users }, - { name: 'Settings', href: '/settings', icon: Settings }, - { name: 'Health', href: '/health', icon: Activity }, + { name: 'Agent Operations', href: '/agents', icon: Briefcase, permission: PERMS.agents.view }, + { name: 'User Management', href: '/settings/users', icon: Users, permission: PERMS.admin }, + { name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin }, + { name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin }, ] } ]; export default function Sidebar() { const pathname = usePathname(); - const { user, logout } = useAuthStore(); + const { user, logout, hasPermission } = useAuthStore(); const { isDark, toggleTheme } = useTheme(); const [isCollapsed, setIsCollapsed] = useState(false); @@ -154,7 +170,12 @@ export default function Sidebar() { {/* Navigation */} diff --git a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts index fcba670b6..4048e6d0e 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts @@ -1,3 +1,5 @@ +'use client'; + import { create } from 'zustand'; import { AdminUser } from '@/types'; import axios from 'axios'; @@ -20,9 +22,10 @@ interface AuthState { logout: () => void; setUser: (user: AdminUser, token: string) => void; initialize: () => void; + hasPermission: (key: string) => boolean; } -export const useAuthStore = create((set) => ({ +export const useAuthStore = create((set, get) => ({ user: null, token: null, refreshToken: null, @@ -34,7 +37,11 @@ export const useAuthStore = create((set) => ({ const userStr = localStorage.getItem('auth_user'); if (token && userStr) { try { - const user = JSON.parse(userStr); + const user = JSON.parse(userStr) as AdminUser; + // backfill for sessions stored before permissions were added + if (!user.permissions) user.permissions = []; + if (user.isSuperAdmin === undefined) user.isSuperAdmin = false; + if (user.isOrgAdmin === undefined) user.isOrgAdmin = false; set({ user, token, isAuthenticated: true }); } catch { localStorage.removeItem('auth_token'); @@ -51,23 +58,49 @@ export const useAuthStore = create((set) => ({ const { token, refreshToken } = loginData; if (!token) throw new Error('No token received from server'); - // Step 2: fetch full user info with the token + // Step 2: fetch full user from IAM /v1/auth/me — returns session.userInfo + // employee is an array here (unlike /auth/me which transforms it to a single object via parseToken) const meRes = await axios.get(`${API_URL}/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` }, }); const iamUser = meRes.data?.data ?? meRes.data; + // Role permissions — flat array in data.permissions + const rolePerms = (iamUser.permissions ?? []).map((p: any) => String(p.key)); + // Position permissions — employee[] is an array here; positions[].permissions[] merged by IAM + const employeeArr: any[] = Array.isArray(iamUser.employee) ? iamUser.employee : []; + const positionPerms = employeeArr.flatMap((emp: any) => + (emp.positions ?? []).flatMap((pos: any) => + (pos.permissions ?? []).map((p: any) => String(p.key)) + ) + ); + const permissions = Array.from(new Set([...rolePerms, ...positionPerms])); + + const isSuperAdmin = iamUser.isSuperAdmin ?? false; + const isOrgAdmin = iamUser.isOrganizationAdmin ?? false; + + // Block individual (passenger) accounts — backoffice requires at least one of: + // super admin, org admin, an employee position, or an explicit permission. + if (!isSuperAdmin && !isOrgAdmin && employeeArr.length === 0 && permissions.length === 0) { + throw new Error('ACCESS_DENIED'); + } + const user: AdminUser = { id: iamUser.id, email: iamUser.email, fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email, role: mapIamRole(iamUser.roles ?? []), active: true, + permissions, + isSuperAdmin, + isOrgAdmin, }; localStorage.setItem('auth_token', token); localStorage.setItem('auth_user', JSON.stringify(user)); if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken); + // cookie lets middleware detect auth without reading localStorage + document.cookie = `auth_token=${token}; path=/; SameSite=Lax`; set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true }); }, @@ -76,10 +109,18 @@ export const useAuthStore = create((set) => ({ localStorage.removeItem('auth_token'); localStorage.removeItem('auth_refresh_token'); localStorage.removeItem('auth_user'); + document.cookie = 'auth_token=; path=/; max-age=0'; set({ user: null, token: null, refreshToken: null, isAuthenticated: false }); }, setUser: (user: AdminUser, token: string) => { set({ user, token, isAuthenticated: true }); }, + + hasPermission: (key: string) => { + const { user } = get(); + if (!user) return false; + if (user.isSuperAdmin || user.isOrgAdmin) return true; + return user.permissions.includes(key); + }, })); diff --git a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts new file mode 100644 index 000000000..7731d784e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts @@ -0,0 +1,42 @@ +export const PERMS = { + dashboard: 'edr_passenger_app:dashboard:view', + bookings: { + view: 'edr_passenger_app:bookings:view', + manage: 'edr_passenger_app:bookings:manage', + cancel: 'edr_passenger_app:bookings:cancel', + }, + passengers: { + view: 'edr_passenger_app:passengers:view', + manage: 'edr_passenger_app:passengers:manage', + }, + tickets: { + view: 'edr_passenger_app:tickets:view', + manage: 'edr_passenger_app:tickets:manage', + }, + payments: { + view: 'edr_passenger_app:payments:view_all', + refund: 'edr_passenger_app:payments:refund', + manage: 'edr_passenger_app:payments:manage_methods', + }, + reports: { + view: 'edr_passenger_app:reports:view', + }, + fraud: { + view: 'edr_passenger_app:fraud:view', + manage: 'edr_passenger_app:fraud:manage', + }, + audit: { + view: 'edr_passenger_app:audit:view', + }, + agents: { + view: 'edr_passenger_app:agents:view', + manage: 'edr_passenger_app:agents:manage', + }, + currencies: { + manage: 'edr_passenger_app:currencies:manage', + }, + notifications: { + send: 'edr_passenger_app:notifications:send', + }, + admin: 'edr_passenger_app:admin', +} as const; diff --git a/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts new file mode 100644 index 000000000..0991a360e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useAuthStore } from './auth-store'; + +/** + * Returns whether the current user has a given permission key. + * Super admins and org admins always return true. + * + * Usage: + * const canCancel = usePermission(PERMS.bookings.cancel); + * {canCancel && } + */ +export function usePermission(key: string): boolean { + return useAuthStore((s) => s.hasPermission(key)); +} diff --git a/apps/edr-passenger-web/backoffice/src/middleware.ts b/apps/edr-passenger-web/backoffice/src/middleware.ts new file mode 100644 index 000000000..0b437d112 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/middleware.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const PUBLIC_PATHS = ['/login']; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { + return NextResponse.next(); + } + + // Token is stored in localStorage (client-side only), so middleware can't + // read it directly. We use a cookie set on login as the server-side signal. + const token = request.cookies.get('auth_token')?.value; + if (!token) { + return NextResponse.redirect(new URL('/login', request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'], +}; diff --git a/apps/edr-passenger-web/backoffice/src/types/index.ts b/apps/edr-passenger-web/backoffice/src/types/index.ts index 5ec6f4e1d..f853ec95e 100644 --- a/apps/edr-passenger-web/backoffice/src/types/index.ts +++ b/apps/edr-passenger-web/backoffice/src/types/index.ts @@ -96,6 +96,9 @@ export interface AdminUser { fullName: string; role: 'ADMIN' | 'AGENT' | 'SUPERVISOR'; active: boolean; + permissions: string[]; + isSuperAdmin: boolean; + isOrgAdmin: boolean; } // Re-export EDR types diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx index c1787010c..0ef5c74ec 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -1,9 +1,42 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; -import { LogIn, UserPlus, Shield, Clock } from 'lucide-react'; +import { LogIn, UserPlus, ChevronLeft } from 'lucide-react'; + +function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) { + const [visible, setVisible] = useState(false); + + return ( +
setVisible(true)} + onMouseLeave={() => setVisible(false)} + onFocus={() => setVisible(true)} + onBlur={() => setVisible(false)} + > + {children} +
+
    + {content.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ {/* Arrow */} +
+
+
+ ); +} export default function AuthCheckPage() { const router = useRouter(); @@ -19,122 +52,54 @@ export default function AuthCheckPage() { } }, [isAuthenticated, router]); - const handleSignIn = () => { - router.push('/login?redirect=/booking/passengers'); - }; - - const handleGuest = () => { - router.push('/booking/passengers'); - }; - return ( -
-
-
- {/* Header */} -
-

Continue your booking

-

- Sign in to access saved profiles or continue as a guest -

-
+
+
+

+ Continue your booking +

+

+ Choose how you'd like to proceed +

- {/* Options Grid */} -
- {/* Sign In Option */} -
+ + -
-
- - {/* Guest Option */} -
-
-
- -
-

Continue as guest

-

- Book without an account. You can create one after completing your booking -

- - {/* Benefits */} -
-
-
- -
- Quick checkout process -
-
-
- -
- No account required -
-
-
- -
- Create account later (optional) -
-
- - -
-
-
- - {/* Back Link */} -
- -
+ + + + + +
+ +
+
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 272992b73..92b82d2cb 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -8,7 +8,7 @@ import { useBookingStore } from '@/lib/booking-store'; import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; import { useState, useEffect, useRef } from 'react'; -import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react'; +import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe, ChevronLeft } from 'lucide-react'; import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar'; const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; @@ -951,7 +951,8 @@ export default function PassengersPage() { ...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }), }); router.push(`/booking/results?${params}`); - }} className="btn-secondary flex-1" disabled={saving}> + }} className="btn-secondary flex-1 flex items-center justify-center gap-2" disabled={saving}> + Back + +

+ 🔒 Secure & encrypted payment +

+
+
+ ); + return ( -
+
-

- Complete payment -

-

- Booking reference:{" "} - {pnr} -

+

Complete payment

{/* Payment Processing Overlay */} {isProcessing && ( -
-
+
+
{paymentMutation.isSuccess ? ( <> - -

- Payment successful! -

-

- Redirecting to confirmation... -

- + +

Payment successful!

+

Redirecting to confirmation...

+ ) : ( <> - -

- Processing payment -

-

- Please wait while we process your payment... -

+ +

Processing payment

+

Please wait...

)}
)} - {/* Order Summary */} -
-

- Order summary -

-
- {isRoundTrip ? ( - <> - {/* Outbound Journey */} -
-
-
- Outbound Journey - - {outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} - -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} -
-
- {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} -
-
- {outboundSchedule?.origin} -
-
+ {/* Two-column grid */} +
- {/* Journey Info */} -
-
-
- - - - {outboundSchedule?.duration} -
-
- - - - Train {outboundSchedule?.trainNumber} -
-
-
- - {/* Destination */} -
-
- {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} -
-
- {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} -
-
- {outboundSchedule?.destination} -
-
-
-
- -
-
- Outbound fare - ETB {(outboundBaseFare / 100).toFixed(2)} -
-
+ {/* Left column — payment methods (2/3 width) */} +
+
+

Select payment method

+ {loadingMethods ? ( +
+ + Loading payment methods...
- - {/* Return Journey */} -
-
-
- Return Journey - - {inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} - -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} -
-
- {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} -
-
- {inboundSchedule?.origin} -
-
- - {/* Journey Info */} -
-
-
- - - - {inboundSchedule?.duration} -
-
- - - - Train {inboundSchedule?.trainNumber} -
-
-
- - {/* Destination */} -
-
- {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} -
-
- {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} -
-
- {inboundSchedule?.destination} -
-
-
-
- -
-
- Return fare - ETB {(inboundBaseFare / 100).toFixed(2)} -
-
+ ) : error ? ( +
+

Failed to load payment methods. Please refresh.

- - ) : ( - <> - {/* One-Way Journey */} -
-
-
- Your Journey - - {selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} - -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} -
-
- {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} -
-
- {selectedSchedule?.origin} -
-
- - {/* Journey Info */} -
-
-
- - - - {selectedSchedule?.duration} -
-
- - - - Train {selectedSchedule?.trainNumber} -
-
-
- - {/* Destination */} -
-
- {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} -
-
- {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} -
-
- {selectedSchedule?.destination} -
-
-
-
+ ) : paymentMethods.length === 0 ? ( +
+

No payment methods available at the moment.

- - )} - - {/* Passengers and Total */} -
-
- - Passengers - - - {passengers.length} passenger{passengers.length !== 1 ? "s" : ""} - -
-
- - Total amount - - - ETB {(totalAmount / 100).toFixed(2)} - -
-
-
-
- - {/* Payment Methods */} -
-

- Select payment method -

- {loadingMethods ? ( -
- -

Loading payment methods...

-
- ) : error ? ( -
-

- Failed to load payment methods. Please refresh the page. -

-
- ) : paymentMethods.length === 0 ? ( -
-

- No payment methods available at the moment. -

-
- ) : ( -
- {paymentMethods.map((method) => { - const Icon = getIconForMethod(method.type); - const isSelected = selectedMethod === method.type; - return ( -
-
-

- {method.displayName} -

-

- {method.region} · {method.currency} -

-
- {isSelected && ( -
- +
+
+ +
+
+

{method.displayName}

+

{method.region} · {method.currency}

+
+ {isSelected && ( + + )}
- )} -
- - ); - })} + + ); + })} +
+ )}
- )} -
- {/* Action Buttons */} -
- - - -
- - {/* Error Message */} - {paymentError && ( -
-

- ⚠️ {paymentError} -

+ {/* Order summary inline — mobile only */} +
+ +
- )} - {/* Security Notice */} -
-

- 🔒 Your payment is secure and encrypted. We do not store your - payment information. -

-
+ {/* Right column — sticky order summary (desktop only) */} +
+
+ +
+
+ +
{/* end grid */}
+ + {/* Mobile sticky bottom bar */} +
+
+ Total + {displayCurrency} {(totalAmount / 100).toFixed(2)} +
+ {paymentError && ( +

⚠️ {paymentError}

+ )} +
+ + +
+
+
); } diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx index 53da93781..04c2d8251 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/failure/page.tsx @@ -3,7 +3,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; import { useEffect, Suspense } from 'react'; -import { XCircle, Loader2, RefreshCw } from 'lucide-react'; +import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react'; function TelebirrFailureContent() { const router = useRouter(); @@ -36,7 +36,8 @@ function TelebirrFailureContent() { Try Again
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx index 19e3832fe..1e49d5889 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/waafi/failure/page.tsx @@ -3,7 +3,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { usePaymentStore } from '@/lib/payment-store'; import { useEffect, Suspense } from 'react'; -import { XCircle, Loader2, RefreshCw } from 'lucide-react'; +import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react'; function WaafiFailureContent() { const router = useRouter(); @@ -39,7 +39,8 @@ function WaafiFailureContent() { Try Again
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 38693ede6..10eab03e0 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -159,12 +159,17 @@ export default function ResultsPage() { // Find the coach type to get pricing info const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); - const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; - + // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. + const minFare = coachType?.classes.length + ? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor)) + : 0; + const fareCurrency: string = + coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB'; + const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; const durationStr = `${hours}h ${minutes}m`; - + const scheduleData = { id: scheduleId, trainNumber: schedule.trainNumber, @@ -175,6 +180,7 @@ export default function ResultsPage() { duration: durationStr, baseFareAdult: minFare, baseFareChild: minFare, + displayCurrency: fareCurrency, selectedSeatClass: selectedCoachType.name, selectedSeatClassName: selectedCoachType.name, selectedCoachTypeId: selectedCoachType.id, @@ -213,13 +219,22 @@ export default function ResultsPage() { const scheduleId = schedule.scheduleId || schedule.id || ''; const selectedCoachType = selectedCoachTypes[scheduleId]; - // Calculate lowest fare from coach types + // Calculate lowest fare and display currency from coach types / faresByClass. + // Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal). let lowestFare = null; + let displayCurrency = schedule.displayCurrency || 'ETB'; if (schedule.coachTypes?.length) { - const allFares = schedule.coachTypes.flatMap(ct => ct.classes.map(c => c.baseFareMinor)).filter(f => f > 0); + const allClasses = schedule.coachTypes.flatMap(ct => ct.classes); + const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0); lowestFare = allFares.length ? Math.min(...allFares) : null; + const firstWithCurrency = allClasses.find(c => c.displayCurrency); + if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; } else if (schedule.faresByClass?.length) { - lowestFare = Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)); + lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0)); + const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency); + if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; + } else if (schedule.combinedMinFareDisplay) { + lowestFare = schedule.combinedMinFareDisplay; } const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -287,7 +302,7 @@ export default function ResultsPage() {
Starting from
- {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} + {lowestFare ? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
per adult
{selectedCoachType && ( @@ -536,7 +551,8 @@ export default function ResultsPage() {
{coachTypes.map((coachType: any, index: number) => { const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0; + const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB'; const CoachIcon = getCoachIcon(coachType.coachTypeName); return ( @@ -585,7 +601,7 @@ export default function ResultsPage() { }`}> {(minPrice / 100).toFixed(2)} - ETB + {coachCurrency}
@@ -615,10 +631,10 @@ export default function ResultsPage() {
- {(cls.baseFareMinor / 100).toFixed(2)} + {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)} - ETB + {cls.displayCurrency ?? coachCurrency}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 7b94503a0..c74574d65 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -7,6 +7,7 @@ import { useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { format } from 'date-fns'; import { useState, useEffect } from 'react'; +import { ChevronLeft } from 'lucide-react'; // Helper function to decode JWT token and extract passengerId function getPassengerIdFromToken(token: string): string | null { @@ -58,6 +59,14 @@ export default function ReviewPage() { const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; + // Prefer the currency already stored on the selected schedule (set from search results). + // Fall back to deriving from nationality so the review page is never left with a stale value. + const NATIONALITY_TO_CURRENCY: Record = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' }; + const displayCurrency: string = + (isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ?? + NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? + 'USD'; + useEffect(() => { if (!seatHold?.expiresAt) return; @@ -79,55 +88,54 @@ export default function ReviewPage() { return () => clearInterval(interval); }, [seatHold]); + const buildSeatLabel = (seat: any): string => { + const base: string = seat.number || seat.label || seat.seatNumber || ''; + if (!base) return 'N/A'; + const posMap: Record = { lower: 'L', middle: 'M', upper: 'U' }; + const suffix = seat.bedPosition ? (posMap[seat.bedPosition] ?? '') : ''; + return suffix ? `${base}${suffix}` : base; + }; + useEffect(() => { const fetchSeatDetails = async () => { try { const details: Record = {}; - + // Fetch outbound seat details if (isRoundTrip && outboundSchedule?.id) { const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`); - const outboundCoaches = outboundSeatMap?.coaches || []; - const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []); - + const outboundSeats = (outboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []); + passengers.forEach(p => { if ((p as any).outboundSeatId) { const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId); - if (seat) { - details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; - } + if (seat) details[`outbound-${(p as any).outboundSeatId}`] = buildSeatLabel(seat); } }); } - + // Fetch inbound seat details if (isRoundTrip && inboundSchedule?.id) { const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`); - const inboundCoaches = inboundSeatMap?.coaches || []; - const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []); - + const inboundSeats = (inboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []); + passengers.forEach(p => { if ((p as any).inboundSeatId) { const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId); - if (seat) { - details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; - } + if (seat) details[`inbound-${(p as any).inboundSeatId}`] = buildSeatLabel(seat); } }); } - + // Fetch one-way seat details if (!isRoundTrip && selectedSchedule?.id) { const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); - const coaches = seatMapData?.coaches || []; - const allSeats = coaches.flatMap((coach: any) => coach.seats || []); - + const allSeats = (seatMapData?.coaches || []).flatMap((coach: any) => coach.seats || []); + passengers.forEach(p => { if (p.seatId) { const seat = allSeats.find((s: any) => s.id === p.seatId); - if (seat) { - details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; - } + if (seat) details[p.seatId] = buildSeatLabel(seat); } }); } @@ -249,7 +257,7 @@ export default function ReviewPage() { destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', - displayCurrency: 'ETB', + displayCurrency: displayCurrency, passengers: passengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { @@ -288,7 +296,7 @@ export default function ReviewPage() { destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', - displayCurrency: 'ETB', + displayCurrency: displayCurrency, passengers: passengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { @@ -373,21 +381,88 @@ export default function ReviewPage() { }, 0); const total = baseFare; + // Shared fare sidebar — rendered in right column (desktop) and inline (mobile) + const FareSidebar = () => ( +
+

+ Fare breakdown +

+ {passengers.map((p, i) => { + const outFare = outboundSchedule?.baseFareAdult || 0; + const inFare = inboundSchedule?.baseFareAdult || 0; + const onewayFare = selectedSchedule?.baseFareAdult || 0; + const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare; + return ( +
+
+ + {p.name || `Passenger ${i + 1}`} + + + {displayCurrency} {(passengerTotal / 100).toFixed(2)} + +
+ {isRoundTrip && ( +
+
+ Outbound + {displayCurrency} {(outFare / 100).toFixed(2)} +
+
+ Return + {displayCurrency} {(inFare / 100).toFixed(2)} +
+
+ )} +
+ ); + })} +
+ Total + {displayCurrency} {(total / 100).toFixed(2)} +
+ + {/* Action buttons — visible only in desktop sidebar */} +
+ {createBookingMutation.isError && ( +

+ ⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'} +

+ )} + + +
+
+ ); + return ( -
+
-

Review your booking

+

Review your booking

{seatHold && ( -
-

- ⏱️ Your seats will be released in: {timeLeft} -

+
+ + ⏱️ Seats held for: {timeLeft} +
)} -
+ {/* Two-column layout on desktop */} +
+ + {/* Left column — trip details + passengers */} +
{/* Outbound Trip Details */} {isRoundTrip && outboundSchedule && (
@@ -646,67 +721,47 @@ export default function ReviewPage() {
-
-

Fare breakdown

-
- {passengers.map((p, i) => { - const outFare = outboundSchedule?.baseFareAdult || 0; - const inFare = inboundSchedule?.baseFareAdult || 0; - const onewayFare = selectedSchedule?.baseFareAdult || 0; - const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare; - return ( -
-
- - {p.name || `Passenger ${i + 1}`} - - - ETB {(passengerTotal / 100).toFixed(2)} - -
- {isRoundTrip && ( -
-
- Outbound - ETB {(outFare / 100).toFixed(2)} -
-
- Return - ETB {(inFare / 100).toFixed(2)} -
-
- )} -
- ); - })} -
- Total - ETB {(total / 100).toFixed(2)} -
+ {/* Fare breakdown — visible only on mobile (desktop shows it in right column) */} +
+ +
+ +
{/* end left column */} + + {/* Right column — sticky fare card (desktop only) */} +
+
+
-
- - -
- - {createBookingMutation.isError && ( -
-

- ⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred while creating your booking. Please try again.'} -

-
- )} -
+
{/* end grid */} +
+
+ + {/* Mobile sticky bottom bar */} +
+
+ Total + {displayCurrency} {(total / 100).toFixed(2)} +
+ {createBookingMutation.isError && ( +

+ ⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'} +

+ )} +
+ +
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index ba3fe0159..696dcb45b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -12,6 +12,15 @@ import Image from "next/image"; import CustomModal from "@/components/CustomModal"; +const BED_POSITION_SUFFIX: Record = { lower: 'L', middle: 'M', upper: 'U' }; + +const buildSeatLabel = (seat: any): string => { + const base: string = seat.number || seat.label || seat.seatNumber || ''; + if (!base) return ''; + const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? '') : ''; + return suffix ? `${base}${suffix}` : base; +}; + const BedCard = memo(({ bed, isSelected, onToggle }: any) => { const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; const bedPosition = bed.bedPosition || ""; @@ -364,11 +373,7 @@ export default function SeatsPage() { return { ...p, outboundSeatId: selectedSeats[i], - outboundSeatNumber: - seatData?.number || - seatData?.label || - seatData?.seatNumber || - "", + outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '', }; }); setPassengers(updatedPassengers); @@ -401,18 +406,13 @@ export default function SeatsPage() { return { ...p, inboundSeatId: selectedSeats[i], - inboundSeatNumber: - seatData?.number || - seatData?.label || - seatData?.seatNumber || - "", + inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '', }; } return { ...p, seatId: selectedSeats[i], - seatNumber: - seatData?.number || seatData?.label || seatData?.seatNumber || "", + seatNumber: seatData ? buildSeatLabel(seatData) : '', }; }); setPassengers(updatedPassengers); @@ -456,8 +456,7 @@ export default function SeatsPage() { return { ...p, seatId: autoSelectedSeats[i], - seatNumber: - seatData?.number || seatData?.label || seatData?.seatNumber || "", + seatNumber: seatData ? buildSeatLabel(seatData) : '', }; }); setPassengers(updatedPassengers); diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 012fbef44..864dd8086 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -44,6 +44,7 @@ export interface SelectedSchedule { duration: string; baseFareAdult: number; baseFareChild: number; + displayCurrency: string; selectedSeatClass?: string; selectedSeatClassName?: string; } diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts index 08203494a..1baabfd3b 100644 --- a/apps/edr-passenger-web/portal/src/types/index.ts +++ b/apps/edr-passenger-web/portal/src/types/index.ts @@ -38,7 +38,7 @@ export interface Schedule { baseFareChild?: number; availableSeats?: number; availabilityByClass?: Record; // API returns this - faresByClass?: Array<{ seatClassName: string; baseFareMinor: number }>; // API returns this + faresByClass?: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency?: string; displayAmountMinor?: number }>; // API returns this coachTypes?: Array<{ coachId: string; coachTypeName: string; @@ -46,8 +46,13 @@ export interface Schedule { classes: Array<{ name: string; baseFareMinor: number; + displayCurrency?: string; + displayAmountMinor?: number; }>; }>; + displayCurrency?: string; + combinedMinFareMinor?: number; + combinedMinFareDisplay?: number; serviceClass?: string; status?: string; hasAvailability?: boolean; diff --git a/local-packages/tria-plc-iamapi-common-0.7.7.tgz b/local-packages/tria-plc-iamapi-common-0.7.7.tgz new file mode 100644 index 000000000..593357e7c Binary files /dev/null and b/local-packages/tria-plc-iamapi-common-0.7.7.tgz differ diff --git a/local-packages/tria-plc-iamui-0.1.1.tgz b/local-packages/tria-plc-iamui-0.1.1.tgz new file mode 100644 index 000000000..6f3ffaf4e Binary files /dev/null and b/local-packages/tria-plc-iamui-0.1.1.tgz differ diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx index f025e2465..a6c8a202b 100644 --- a/packages/ui-common/src/components/SmartFileInput/index.tsx +++ b/packages/ui-common/src/components/SmartFileInput/index.tsx @@ -1,8 +1,5 @@ import React, { useState, useMemo, useRef } from "react"; -import { - IFileUploadSetting, - IFileUploadField, -} from "@edr/types/freight"; +import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight"; import { UploadCloud, FileText, @@ -24,12 +21,20 @@ export interface SmartFileInputProps { onChange?: (value: Record) => void; /** External form errors mapped by fileKey. */ errors?: Record; + /** + * fileKeys whose document is already uploaded on the server. Such fields show + * an "Already uploaded" badge and a replace-oriented dropzone hint, even when + * no in-memory File is currently selected for them. + */ + uploadedKeys?: string[]; /** Disabled state for the entire file input group. */ disabled?: boolean; /** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */ variant?: "default" | "minimal"; /** Optional custom container CSS classes. */ className?: string; + + containerClassName?: string; } /** Helper to format file sizes in bytes to a human-readable string. */ @@ -45,23 +50,23 @@ function formatBytes(bytes: number, decimals = 2) { /** Render a suitable icon based on file extension. */ function FileIcon({ name, className }: { name: string; className?: string }) { const ext = name.split(".").pop()?.toLowerCase() || ""; - + if (ext === "pdf") { return ; } - + if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) { return ; } - + if (["csv", "xls", "xlsx"].includes(ext)) { return ; } - + if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) { return ; } - + return ; } @@ -70,16 +75,20 @@ export function SmartFileInput({ value, onChange, errors, + uploadedKeys, disabled = false, variant = "default", className, + containerClassName, }: SmartFileInputProps) { // Local state to manage files when the component is used in an uncontrolled manner - const [internalFiles, setInternalFiles] = useState>({}); - + const [internalFiles, setInternalFiles] = useState>( + {}, + ); + // Local validation errors const [localErrors, setLocalErrors] = useState>({}); - + // Drag-and-drop state active per field const [dragActive, setDragActive] = useState>({}); @@ -93,10 +102,13 @@ export function SmartFileInput({ // Create a map of fields for quick lookup const fieldsMap = useMemo(() => { - return file.fields.reduce((acc, currentField) => { - acc[currentField.fileKey] = currentField; - return acc; - }, {} as Record); + return file.fields.reduce( + (acc, currentField) => { + acc[currentField.fileKey] = currentField; + return acc; + }, + {} as Record, + ); }, [file.fields]); // Resolve current files list for a field @@ -109,8 +121,8 @@ export function SmartFileInput({ const handleFilesChange = (fieldKey: string, newFiles: File[]) => { const field = fieldsMap[fieldKey]; if (!field) return; - - const newValue = field.isMultiple ? newFiles : (newFiles[0] || null); + + const newValue = field.isMultiple ? newFiles : newFiles[0] || null; if (onChange) { const updatedValues = { @@ -129,10 +141,10 @@ export function SmartFileInput({ const processFiles = (field: IFileUploadField, incomingFiles: File[]) => { const currentFiles = getFilesForField(field.fileKey); const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1; - + // Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf') const allowedExts = field.allowedExtensions.map((ext) => - ext.toLowerCase().replace(/^\./, "") + ext.toLowerCase().replace(/^\./, ""), ); let validIncoming: File[] = []; @@ -140,13 +152,12 @@ export function SmartFileInput({ for (const fileObj of incomingFiles) { const ext = fileObj.name.split(".").pop()?.toLowerCase() || ""; - const isExtValid = - allowedExts.length === 0 || allowedExts.includes(ext); + const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext); const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024; if (!isExtValid) { errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join( - ", " + ", ", )}`; break; } @@ -187,7 +198,11 @@ export function SmartFileInput({ handleFilesChange(field.fileKey, newFilesList); }; - const handleDrag = (e: React.DragEvent, fieldKey: string, active: boolean) => { + const handleDrag = ( + e: React.DragEvent, + fieldKey: string, + active: boolean, + ) => { e.preventDefault(); e.stopPropagation(); if (disabled) return; @@ -208,7 +223,7 @@ export function SmartFileInput({ const handleFileSelect = ( e: React.ChangeEvent, - field: IFileUploadField + field: IFileUploadField, ) => { if (e.target.files && e.target.files.length > 0) { const filesArray = Array.from(e.target.files); @@ -246,179 +261,275 @@ export function SmartFileInput({ {file.description}
)} - - {sortedFields.map((field) => { - const currentFiles = getFilesForField(field.fileKey); - const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1; - const reachedLimit = currentFiles.length >= maxFiles; - const fieldError = errors?.[field.fileKey] || localErrors[field.fileKey]; - const isDragOver = dragActive[field.fileKey]; - // Format accepted files for the HTML input element - const acceptString = field.allowedExtensions - .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) - .join(","); +
+ {sortedFields.map((field) => { + const currentFiles = getFilesForField(field.fileKey); + const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1; + const reachedLimit = currentFiles.length >= maxFiles; + const fieldError = + errors?.[field.fileKey] || localErrors[field.fileKey]; + const isDragOver = dragActive[field.fileKey]; + // Already uploaded server-side and nothing newly picked to replace it. + const isUploaded = + (uploadedKeys?.includes(field.fileKey) ?? false) && + currentFiles.length === 0; - return ( -
- {/* Field Header */} -
- - - - Max size: {field.maxSizeMb}MB - {field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`} - -
+ // Format accepted files for the HTML input element + const acceptString = field.allowedExtensions + .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) + .join(","); - {/* Help / Description Text */} - {field.helpText && ( -

{field.helpText}

- )} - - {/* Selected Files List */} - {currentFiles.length > 0 && ( -
- {currentFiles.map((fileObj, idx) => ( -
+ {/* Field Header */} +
+
); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 205ca2bd7..bd8c81c83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@mantine/dates': specifier: ^9.3.2 - version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.4.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) devDependencies: '@commitlint/cli': specifier: ^19.5.0 @@ -86,10 +86,10 @@ importers: version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e) + version: file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) '@tria-plc/iamapi-common': - specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz - version: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e) + specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz + version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) amqp-connection-manager: specifier: ^5.0.0 version: 5.0.0(amqplib@2.0.1) @@ -105,9 +105,15 @@ importers: class-validator: specifier: ^0.14.1 version: 0.14.4 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 dotenv: specifier: ^17.4.2 version: 17.4.2 + dotenv-cli: + specifier: ^11.0.0 + version: 11.0.0 handlebars: specifier: ^4.7.9 version: 4.7.9 @@ -215,8 +221,8 @@ importers: specifier: ^5.100.11 version: 5.101.0(react@19.2.6) '@tria-plc/iamui': - specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz - version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7) + specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) axios: specifier: ^1.7.7 version: 1.17.0 @@ -327,8 +333,8 @@ importers: specifier: ^5.59.0 version: 5.101.0(react@19.2.6) '@tria-plc/iamui': - specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz - version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7) + specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) axios: specifier: ^1.7.7 version: 1.17.0 @@ -1476,6 +1482,9 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -2034,10 +2043,10 @@ packages: react: ^19.2.0 react-dom: ^19.2.0 - '@mantine/core@9.4.0': - resolution: {integrity: sha512-BvTzaJ5Nut4ZiWLCcy1/cRXE8yjCgxsBhyAynY1pEg6XBw/fChEqSgbEB2dybOnkiAxvYt89TrnTrBBo9qJtmg==} + '@mantine/core@9.3.2': + resolution: {integrity: sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==} peerDependencies: - '@mantine/hooks': 9.4.0 + '@mantine/hooks': 9.3.2 react: ^19.2.0 react-dom: ^19.2.0 @@ -2050,11 +2059,11 @@ packages: react: ^18.x || ^19.x react-dom: ^18.x || ^19.x - '@mantine/dates@9.4.0': - resolution: {integrity: sha512-15iCWxykutEVoFUPTq4z+An3YQZb7UH6Fwzb3stsVhTMLm6FyGLYSSB6PL+9sBV1TPllIJSm1IizY2SSTDGqQQ==} + '@mantine/dates@9.3.2': + resolution: {integrity: sha512-MzHrXGoOb3rCnHBlsI/BPAjC8K5tZ4f8pzg66tsTsupVCQBaBpHSVatQwNRJ6K2JC9pyCyTa9BL803C8AiWycA==} peerDependencies: - '@mantine/core': 9.4.0 - '@mantine/hooks': 9.4.0 + '@mantine/core': 9.3.2 + '@mantine/hooks': 9.3.2 dayjs: '>=1.0.0' react: ^19.2.0 react-dom: ^19.2.0 @@ -2069,8 +2078,8 @@ packages: peerDependencies: react: ^19.2.0 - '@mantine/hooks@9.4.0': - resolution: {integrity: sha512-SUkge8KlhyLoSSKhEHERx7jLotNu632kldhQPlLn5kbOuJixoEbU8fKcuwpXSsZTtzRgCCT2IkoN+rORhtZTHg==} + '@mantine/hooks@9.3.2': + resolution: {integrity: sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==} peerDependencies: react: ^19.2.0 @@ -4087,9 +4096,31 @@ packages: rxjs: ^7.8.0 typeorm: ^0.3.0 - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.0.3.tgz': - resolution: {integrity: sha512-aZhIeNq2Uui7TUkG88G9QTBqdhaVB5kvqfd47HLgVE7qKkVyfjlm4nwlSWnHh5MO+CjgP9vRi6ILwMJx1ULO8g==, tarball: file:local-packages/tria-plc-iamui-0.0.3.tgz} - version: 0.0.3 + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz': + resolution: {integrity: sha512-zW7dIEnoai9NSagFsjdH3CyU4dizhFnV9AkO0sOb8MJhFGaifFaMLyWkB9QMQvUIxFRziOcqw3vR0yjqLmTNPQ==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz} + version: 0.7.7 + engines: {node: '>=20'} + peerDependencies: + '@nestjs/axios': ^4.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/jwt': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/passport': ^11.0.0 + '@nestjs/swagger': ^11.0.0 + '@nestjs/throttler': ^6.0.0 + '@nestjs/typeorm': ^11.0.0 + '@tria-plc/api-common': '*' + axios: ^1.9.0 + class-transformer: ^0.5.1 + class-validator: ^0.14.1 + reflect-metadata: ^0.2.0 + rxjs: ^7.8.0 + typeorm: ^0.3.0 + + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz': + resolution: {integrity: sha512-FTihoH0lIqKV/0s+FTJZokQw8xX3XztXIqT8eEYEZgDM2xyzVSCHY8pHCUxw0MQtiR8R97zxZJ/o192eWt+E/g==, tarball: file:local-packages/tria-plc-iamui-0.1.1.tgz} + version: 0.1.1 engines: {node: '>=18'} peerDependencies: react: ^18.3.1 || ^19.0.0 @@ -5734,6 +5765,11 @@ packages: resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} engines: {node: '>=18.x'} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6069,6 +6105,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dotenv-cli@11.0.0: + resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==} + hasBin: true + dotenv-expand@12.0.3: resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} engines: {node: '>=12'} @@ -11778,6 +11818,8 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -12440,10 +12482,10 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 9.4.0(react@19.2.6) + '@mantine/hooks': 9.3.2(react@19.2.6) clsx: 2.1.1 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12453,19 +12495,19 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 9.3.0(react@19.2.6) + '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 7.17.8(react@19.2.6) clsx: 2.1.1 dayjs: 1.11.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.4.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/dates@9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 9.4.0(react@19.2.6) + '@mantine/core': 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.2(react@19.2.6) clsx: 2.1.1 dayjs: 1.11.21 react: 19.2.6 @@ -12483,7 +12525,7 @@ snapshots: dependencies: react: 19.2.6 - '@mantine/hooks@9.4.0(react@19.2.6)': + '@mantine/hooks@9.3.2(react@19.2.6)': dependencies: react: 19.2.6 @@ -15191,6 +15233,50 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)': + dependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) + argon2: 0.43.1 + axios: 1.17.0 + change-case: 5.4.4 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 16.6.1 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-date: 0.0.6 + exceljs: 4.4.0 + file-type: 21.3.4 + handlebars: 4.7.9 + handlebars-helpers: 0.10.0 + jmespath: 0.16.0 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + libreoffice-convert: 1.8.1 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + style-object-to-css-string: 1.1.3 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + xlsx: 0.18.5 + transitivePeerDependencies: + - '@faker-js/faker' + - debug + - supports-color + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15235,85 +15321,6 @@ snapshots: - debug - supports-color - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)': - dependencies: - '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e) - argon2: 0.43.1 - axios: 1.17.0 - change-case: 5.4.4 - class-transformer: 0.5.1 - class-validator: 0.14.4 - dotenv: 16.6.1 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-date: 0.0.6 - exceljs: 4.4.0 - file-type: 21.3.4 - handlebars: 4.7.9 - handlebars-helpers: 0.10.0 - jmespath: 0.16.0 - jose: 5.10.0 - jsonwebtoken: 9.0.3 - libphonenumber-js: 1.13.6 - libreoffice-convert: 1.8.1 - nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - passport-jwt: 4.0.1 - qrcode: 1.5.4 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - style-object-to-css-string: 1.1.3 - typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - uuid: 11.1.1 - xlsx: 0.18.5 - transitivePeerDependencies: - - '@faker-js/faker' - - debug - - supports-color - - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)': - dependencies: - '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e) - api-common: 1.2.2 - argon2: 0.43.1 - axios: 1.17.0 - class-transformer: 0.5.1 - class-validator: 0.14.4 - dotenv: 17.4.2 - ethiopian-date: 0.0.6 - file-type: 21.3.4 - jose: 5.10.0 - jsonwebtoken: 9.0.3 - libphonenumber-js: 1.13.6 - nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - passport-jwt: 4.0.1 - qrcode: 1.5.4 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - uuid: 11.1.1 - transitivePeerDependencies: - - '@faker-js/faker' - - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15349,7 +15356,42 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)': + dependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) + api-common: 1.2.2 + argon2: 0.43.1 + axios: 1.17.0 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 17.4.2 + ethiopian-date: 0.0.6 + file-type: 21.3.4 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)': dependencies: '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) @@ -15357,7 +15399,7 @@ snapshots: '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -17284,6 +17326,11 @@ snapshots: '@types/luxon': 3.7.1 luxon: 3.7.2 + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -17575,6 +17622,13 @@ snapshots: dependencies: is-obj: 2.0.0 + dotenv-cli@11.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 17.4.2 + dotenv-expand: 12.0.3 + minimist: 1.2.8 + dotenv-expand@12.0.3: dependencies: dotenv: 16.6.1 @@ -20179,7 +20233,7 @@ snapshots: mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@tabler/icons-react': 3.44.0(react@19.2.6) '@tanstack/match-sorter-utils': 8.19.4 diff --git a/tmp-iam-inspect/package/README.md b/tmp-iam-inspect/package/README.md deleted file mode 100644 index 2d39087c8..000000000 --- a/tmp-iam-inspect/package/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# @tria-plc/iamapi-common - -Standalone IAM NestJS module extracted from the Smart Office monorepo. Provides authentication, user management, organization structure, and record elements functionality as a reusable package for Tria PLC platform services. - -## Installation - -```bash -npm install @tria-plc/iamapi-common --registry=https://npm.pkg.github.com -``` - -Or with a project-level `.npmrc`: - -``` -@tria-plc:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN -``` -## Usage - -```typescript -import { IamModule } from '@tria-plc/iamapi-common'; - -@Module({ - imports: [IamModule], -}) -export class AppModule {} -``` - -The `IamModule` registers all sub-modules: `AuthModule`, `UserModule`, `OrganizationStructureModule`, and `RecordElementModule`. - -Database configuration, migrations, and seeding remain the responsibility of the consuming application. - -## Peer Dependencies - -- `@nestjs/common` ^11 -- `@nestjs/core` ^11 -- `@nestjs/jwt` ^11 -- `@nestjs/passport` ^11 -- `@nestjs/typeorm` ^11 -- `@smart-office/be` ^1.0.0 -- `reflect-metadata` ^0.2 -- `rxjs` ^7.8 -- `typeorm` ^0.3 - -## Publishing - -Releases are published automatically to GitHub Packages when a version tag (`v*`) is pushed: - -```bash -git tag v1.0.1 -git push origin v1.0.1 -``` - -## Development - -```bash -pnpm install -pnpm build -``` - diff --git a/tmp-iam-inspect/package/package.json b/tmp-iam-inspect/package/package.json deleted file mode 100644 index 8befeb41b..000000000 --- a/tmp-iam-inspect/package/package.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "name": "@tria-plc/iamapi-common", - "version": "0.7.3", - "description": "Standalone IAM NestJS module for Tria PLC platform services", - "repository": { - "type": "git", - "url": "https://github.com/Tria-plc/iamapi-common.git" - }, - "author": "Tria PLC", - "license": "UNLICENSED", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist", - "scripts" - ], - "exports": { - ".": { - "types": "./dist/index.d.ts", - "require": "./dist/index.js" - }, - "./package.json": "./package.json", - "./*.js": "./dist/*.js", - "./*": { - "types": "./dist/*.d.ts", - "require": "./dist/*.js" - } - }, - "typesVersions": { - "*": { - "*": [ - "./dist/*.d.ts", - "./dist/*/index.d.ts" - ] - } - }, - "publishConfig": { - "registry": "https://npm.pkg.github.com" - }, - "engines": { - "node": ">=20" - }, - "scripts": { - "prepare": "husky || true", - "test": "echo 'No tests configured' && exit 0", - "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", - "build": "npm run clean && nest build", - "prepublishOnly": "npm run build", - "typeorm:cli": "node ./scripts/typeorm-cli.cjs", - "migration:run": "npm run typeorm:cli migration:run", - "migration:generate": "npm run build && npm run typeorm:cli migration:generate src/db/migrations/IamApiCommonMigration", - "migration:create": "npm run typeorm:cli migration:create", - "migration:revert": "npm run typeorm:cli migration:revert" - }, - "peerDependencies": { - "@nestjs/axios": "^4.0.0", - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/jwt": "^11.0.0", - "@nestjs/microservices": "^11.0.0", - "@nestjs/passport": "^11.0.0", - "@nestjs/swagger": "^11.0.0", - "@nestjs/throttler": "^6.0.0", - "@nestjs/typeorm": "^11.0.0", - "@tria-plc/api-common": "*", - "axios": "^1.9.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.0", - "typeorm": "^0.3.0" - }, - "dependencies": { - "api-common": "1.2.2", - "argon2": "^0.43.0", - "dotenv": "^17.4.2", - "ethiopian-date": "^0.0.6", - "file-type": "^21.3.0", - "jose": "^5.3.0", - "jsonwebtoken": "^9.0.2", - "libphonenumber-js": "^1.12.9", - "nestjs-minio-client": "^2.2.0", - "passport-jwt": "^4.0.1", - "qrcode": "^1.5.4", - "typeorm-extension": "^3.9.0", - "uuid": "^11.1.0" - }, - "devDependencies": { - "@commitlint/cli": "^19.0.0", - "@commitlint/config-conventional": "^19.0.0", - "@nestjs/axios": "^4.0.0", - "@nestjs/cli": "^11.0.7", - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/jwt": "^11.0.0", - "@nestjs/microservices": "^11.0.0", - "@nestjs/passport": "^11.0.0", - "@nestjs/schematics": "^10.2.3", - "@nestjs/swagger": "^11.0.0", - "@nestjs/testing": "^11.0.8", - "@nestjs/throttler": "^6.0.0", - "@nestjs/typeorm": "^11.0.0", - "@semantic-release/changelog": "^6.0.3", - "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^12.0.8", - "@semantic-release/npm": "^13.1.5", - "@tria-plc/api-common": "^1.4.3", - "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^20.17.32", - "@types/passport-jwt": "^4.0.1", - "axios": "^1.9.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "husky": "^9.0.0", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.0", - "semantic-release": "^25.0.3", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typeorm": "^0.3.0", - "typescript": "^5.8.3" - } -} diff --git a/tmp-iam-inspect/package/scripts/typeorm-cli.cjs b/tmp-iam-inspect/package/scripts/typeorm-cli.cjs deleted file mode 100644 index 025cd3ef0..000000000 --- a/tmp-iam-inspect/package/scripts/typeorm-cli.cjs +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node -const { spawnSync } = require("child_process"); -const path = require("path"); - -const tsNodeBin = require.resolve("ts-node/dist/bin.js"); -const typeormCli = require.resolve("typeorm/cli.js"); -const dataSource = path.resolve(__dirname, "../src/typeorm.config.ts"); - -const args = [ - tsNodeBin, - "-r", - "tsconfig-paths/register", - typeormCli, - "-d", - dataSource, - ...process.argv.slice(2), -]; - -const result = spawnSync(process.execPath, args, { stdio: "inherit" }); -process.exit(result.status ?? 1);