interchange document generation and acknowledgement

This commit is contained in:
hagiye
2026-06-27 13:49:41 +03:00
50 changed files with 2149 additions and 1878 deletions

View File

@@ -20,7 +20,12 @@
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "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: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: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": { "dependencies": {
"@edr/api-common": "workspace:*", "@edr/api-common": "workspace:*",
@@ -39,14 +44,15 @@
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1", "@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", "@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", "amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1", "amqplib": "^2.0.1",
"axios": "^1.16.1", "axios": "^1.16.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cross-env": "^10.1.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"libphonenumber-js": "^1.13.6", "libphonenumber-js": "^1.13.6",
"minio": "7.1.3", "minio": "7.1.3",

View File

@@ -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 { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository'; 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 * paid before any first-mile work proceeds. Throws if the reference is
* unknown or the booking has not reached PAID status. * unknown or the booking has not reached PAID status.
*/ */
async acceptBooking(bookingId: string): Promise<FirstMile | null> { async acceptBooking(bookingId: string): Promise<FirstMile> {
const booking = await this.bookingsRepository.findById(bookingId, { const booking = await this.bookingsRepository.findById(bookingId, {
relations: { serviceType: true }, relations: { serviceType: true },
}); });
if (!booking) { if (!booking) {
return null; throw new NotFoundException(`Booking ${bookingId} not found`);
} }
if (booking.paymentStatus !== 'PAID') { return this.acceptEligibleBooking(booking);
return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
} }
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> { async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
const [booking] = await this.bookingsRepository.findAll({ const [booking] = await this.bookingsRepository.findAll({
where: { reference: bookingReference }, where: { reference: bookingReference },
relations: { serviceType: true }, relations: { serviceType: true },
@@ -75,15 +64,39 @@ export class FirstMileService {
}); });
if (!booking) { 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<FirstMile> {
const label = booking.reference ?? booking.id;
if (booking.paymentStatus !== 'PAID') { if (booking.paymentStatus !== 'PAID') {
return null; throw new BadRequestException(`Booking ${label} is not paid`);
} }
if (!this.bookingRequestsFirstMile(booking)) { 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({ return this.create({
@@ -174,10 +187,17 @@ export class FirstMileService {
} }
private bookingRequestsFirstMile(booking: { private bookingRequestsFirstMile(booking: {
tradeDirection?: string | null;
firstMilePickupAddress?: string | null; firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null; serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean { }): 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<FirstMile> { async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {

View File

@@ -20,7 +20,7 @@
"@mantine/hooks": "^9.3.0", "@mantine/hooks": "^9.3.0",
"@tabler/icons-react": "^3.44.0", "@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.11", "@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", "axios": "^1.7.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -77,6 +77,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{ {
@@ -374,6 +375,7 @@ const App = () => {
return ( return (
<Routes> <Routes>
<Route path="/um/*" element={<UserManagementHostPage />} /> <Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} /> <Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} /> <Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}> <Route path="/dashboard" element={<DashboardShell />}>

View File

@@ -22,7 +22,7 @@ import {
LayoutGrid, LayoutGrid,
Package, Package,
} from "lucide-react"; } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
@@ -84,9 +84,6 @@ export default function CustomerDetailPage() {
enabled: Boolean(id), enabled: Boolean(id),
}), }),
); );
const approveMutation = useMutation(
api.customers.setCompanyStatus.mutationOptions(),
);
const bookingsQuery = useQuery( const bookingsQuery = useQuery(
api.customers.bookings.queryOptions({ api.customers.bookings.queryOptions({
input: { id: id ?? "" }, input: { id: id ?? "" },
@@ -394,28 +391,12 @@ export default function CustomerDetailPage() {
]} ]}
backTo="/dashboard/customers" backTo="/dashboard/customers"
title={company.name} title={company.name}
subtitle={`TIN ${company.tin}${ subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
company.country ? ` · ${company.country}` : "" }`}
}`}
meta={ meta={
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} /> <CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} /> <CompanyStatusBadge status={company.status} />
{company.status === "pending" && (
<Button
size="xs"
color="green"
loading={approveMutation.isPending}
onClick={() =>
approveMutation.mutate({
companyId: company.id,
status: "active",
})
}
>
Approve
</Button>
)}
</Group> </Group>
} }
/> />
@@ -546,9 +527,9 @@ export default function CustomerDetailPage() {
error={ error={
bookingsQuery.isError bookingsQuery.isError
? { ? {
message: "Failed to load bookings.", message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(), onRetry: () => void bookingsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -567,9 +548,9 @@ export default function CustomerDetailPage() {
error={ error={
documentsQuery.isError documentsQuery.isError
? { ? {
message: "Failed to load documents.", message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(), onRetry: () => void documentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -588,9 +569,9 @@ export default function CustomerDetailPage() {
error={ error={
paymentsQuery.isError paymentsQuery.isError
? { ? {
message: "Failed to load payments.", message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(), onRetry: () => void paymentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />

View File

@@ -1,23 +1,34 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react';
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from 'react-dom/client';
import { import {
UserManagementApp, UserManagementApp,
type UserManagementRuntimeOptions, type UserManagementRuntimeOptions,
type UserManagementSessionSeed, 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 { function readInitialSession(): UserManagementSessionSeed | null {
const token = getCookie("auth-token"); const token =
localStorage.getItem('fhc-backoffice-auth-token') ??
readCookieValue('auth-token');
if (!token) { if (!token) {
return null; return null;
} }
const refreshToken = getCookie("refresh-token") ?? undefined; const refreshToken =
localStorage.getItem('fhc-backoffice-auth-refresh-token') ??
readCookieValue('refresh-token') ??
undefined;
return { return {
token, token,
@@ -47,15 +58,14 @@ export default function UserManagementHostPage() {
rootRef.current = createRoot(mountNode); rootRef.current = createRoot(mountNode);
} }
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, ""); const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, '');
const iamApiUrl = "/um-api";
const runtime: UserManagementRuntimeOptions = { const runtime: UserManagementRuntimeOptions = {
basename: "/um", basename: '/um',
apiBaseUrl, apiBaseUrl,
apiUrl: iamApiUrl, apiUrl: `${apiBaseUrl}/api`,
recordApiUrl: iamApiUrl, recordApiUrl: `${apiBaseUrl}/api`,
chronicleUrl: iamApiUrl, chronicleUrl: `${apiBaseUrl}/api`,
auditApiUrl: iamApiUrl, auditApiUrl: `${apiBaseUrl}/api`,
}; };
rootRef.current.render( rootRef.current.render(
@@ -78,5 +88,5 @@ export default function UserManagementHostPage() {
}; };
}, []); }, []);
return <div ref={mountRef} style={{ position: "fixed", inset: 0 }} />; return <div ref={mountRef} style={{ position: 'fixed', inset: 0 }} />;
} }

View File

@@ -379,6 +379,9 @@ const FirstMilePage = () => {
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
const res = await firstMileService.accept(reference); const res = await firstMileService.accept(reference);
const created = res.data; 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 }); if (vehicleId) await firstMileService.update(created.id, { vehicleId });
return created; return created;
}, },
@@ -387,8 +390,11 @@ const FirstMilePage = () => {
toast({ title: "Booking accepted", description: "First-mile leg created successfully." }); toast({ title: "Booking accepted", description: "First-mile leg created successfully." });
closeAccept(); closeAccept();
}, },
onError: () => { onError: (err: unknown) => {
toast({ title: "Accept failed", variant: "destructive" }); 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( paidBookings.filter(
(booking) => (booking) =>
booking.tradeDirection === "EXPORT" && booking.tradeDirection === "EXPORT" &&
Boolean(booking.firstMilePickupAddress?.trim()) &&
!existingFirstMileBookingIds.has(booking.id), !existingFirstMileBookingIds.has(booking.id),
), ),
[existingFirstMileBookingIds, paidBookings], [existingFirstMileBookingIds, paidBookings],

View File

@@ -11,97 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const streamBrowserifyPath = require.resolve("stream-browserify"); 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<Buffer>((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 }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, "");
const apiBaseUrl =
env.VITE_BASE_API_URL?.trim() || "http://localhost:3000";
return { return {
plugins: [react(), tailwindcss(), createIamApiAdapter(apiBaseUrl)], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),

View File

@@ -18,7 +18,7 @@
"@mantine/core": "^9.3.0", "@mantine/core": "^9.3.0",
"@mantine/hooks": "^9.3.0", "@mantine/hooks": "^9.3.0",
"@tanstack/react-query": "^5.59.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", "axios": "^1.7.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({
(company?.company?.nationality as CompanyNationality | null) ?? null; (company?.company?.nationality as CompanyNationality | null) ?? null;
// Resume position from the backend-persisted step. // 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) ? (onboardingStep as FormStep)
: "company"; : "company";
@@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({
const idx = FORM_STEPS.indexOf(step as FormStep); const idx = FORM_STEPS.indexOf(step as FormStep);
if (idx < 0 || idx <= furthestIdxRef.current) return; if (idx < 0 || idx <= furthestIdxRef.current) return;
furthestIdxRef.current = idx; 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. // 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) // 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. // 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 // 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). // 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 // Final confirm step → finalize onboarding (no company create; it already
// exists as a draft that's been filled in step-by-step). // exists as a draft that's been filled in step-by-step).
const handleSubmit = useCallback( const handleSubmit = useCallback(
@@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({
requirementsQuery.data?.documentSettingCode ?? requirementsQuery.data?.documentSettingCode ??
documentSettingCode(effectiveNationality); 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 = { const formProps = {
documentSettingCode: resolvedDocumentSettingCode, documentSettingCode: resolvedDocumentSettingCode,
documentFiles, documentFiles,
@@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({
isPending: finishMutation.isPending, isPending: finishMutation.isPending,
onBack: handleBackToRoles, onBack: handleBackToRoles,
hideFirstStepBack: true, hideFirstStepBack: true,
initialStep: resumeFormStep, initialStep: effectiveResumeStep,
resyncOpen: opened, resyncOpen: opened,
onStepChange: handleStepChange, onStepChange: handleStepChange,
onSaveStep: saveStep, onSaveStep: saveStep,
@@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({
roleProfiles, roleProfiles,
licenseFiles, licenseFiles,
onLicenseChange: setLicenseFiles, onLicenseChange: setLicenseFiles,
uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments,
// Surface a failed final submit (license/document upload or complete) inside // 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 form — otherwise the server message (e.g. a 500) would be invisible on
// the submit step. // the submit step.
@@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({
withCloseButton={!completed} withCloseButton={!completed}
closeOnClickOutside={false} closeOnClickOutside={false}
closeOnEscape={!completed} closeOnEscape={!completed}
size={720} size={1440}
radius="lg" radius="lg"
padding="xl" padding="xl"
centered centered
@@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
styles={{ styles={{
header: { header: {
alignItems:"flex-start" alignItems: "flex-start",
}, },
title: { title: {
flex: 1 flex: 1,
} },
}} }}
title={ title={
completed ? null : ( completed ? null : (
@@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({
{completed ? ( {completed ? (
<OnboardingCompletePanel onClose={handleClose} /> <OnboardingCompletePanel onClose={handleClose} />
) : ( ) : (
<Stack gap="xl"> <Stack gap="xl">
{phase === "nationality" ? (
{phase === "nationality" ? ( <Stack gap="lg">
<Stack gap="lg"> <NationalitySelect
<NationalitySelect value={nationality}
value={nationality} onChange={setNationality}
onChange={setNationality} embedded
embedded />
/> <Group justify="flex-end" pt="xs">
<Group justify="flex-end" pt="xs"> <Button
<Button color="edr-green"
color="edr-green" onClick={handleNationalityContinue}
onClick={handleNationalityContinue} disabled={!nationality}
disabled={!nationality} rightSection={<ArrowRight size={16} />}
rightSection={<ArrowRight size={16} />} >
> Continue
Continue </Button>
</Button> </Group>
</Group> </Stack>
</Stack> ) : phase === "role" ? (
) : phase === "role" ? ( <Stack gap="lg">
<Stack gap="lg"> <OnboardingRoleSelect
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded /> value={roles}
{startError && ( onChange={setRoles}
<Text size="sm" c="red"> embedded
{startError} />
</Text> {startError && (
)} <Text size="sm" c="red">
<Group justify="space-between" pt="xs"> {startError}
<Button </Text>
variant="default" )}
leftSection={<ArrowLeft size={16} />} <Group justify="space-between" pt="xs">
onClick={() => setPhase("nationality")} <Button
> variant="default"
Back leftSection={<ArrowLeft size={16} />}
</Button> onClick={() => setPhase("nationality")}
<Button >
color="edr-green" Back
onClick={handleRolesContinue} </Button>
disabled={!rolesValid} <Button
loading={startMutation.isPending} color="edr-green"
rightSection={ onClick={handleRolesContinue}
startMutation.isPending ? undefined : <ArrowRight size={16} /> disabled={!rolesValid}
} loading={startMutation.isPending}
> rightSection={
Continue startMutation.isPending ? undefined : (
</Button> <ArrowRight size={16} />
</Group> )
</Stack> }
) : ( >
<CompanyProfileForm {...formProps} /> Continue
)} </Button>
</Stack> </Group>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
</Stack>
)} )}
</Modal> </Modal>
); );
@@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
className="flex h-16 w-16 items-center justify-center rounded-full" className="flex h-16 w-16 items-center justify-center rounded-full"
style={{ background: "var(--mantine-color-edr-green-1)" }} style={{ background: "var(--mantine-color-edr-green-1)" }}
> >
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" /> <PartyPopper
size={32}
className="text-[var(--mantine-color-edr-green-7)]"
/>
</Box> </Box>
<Box> <Box>
@@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
style={{ background: "var(--mantine-color-edr-green-0)" }} style={{ background: "var(--mantine-color-edr-green-0)" }}
> >
<Group gap="sm" wrap="nowrap" align="flex-start"> <Group gap="sm" wrap="nowrap" align="flex-start">
<Clock size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" /> <Clock
size={18}
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left"> <Text size="sm" ta="left">
Each operational profile (importer, exporter, freight forwarder) is Each operational profile (importer, exporter, freight forwarder) is
reviewed and approved individually. reviewed and approved individually.
</Text> </Text>
</Group> </Group>
<Group gap="sm" wrap="nowrap" align="flex-start"> <Group gap="sm" wrap="nowrap" align="flex-start">
<ShieldCheck size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" /> <ShieldCheck
size={18}
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left"> <Text size="sm" ta="left">
You can start creating bookings under a profile as soon as it's You can start creating bookings under a profile as soon as it's
approved we'll let you know the moment that happens. approved we'll let you know the moment that happens.

View File

@@ -1,14 +1,8 @@
import { import { Anchor, Group, Stack, Text } from "@mantine/core";
Anchor, import { Paperclip } from "lucide-react";
Badge,
Card, import { SmartFileInput } from "@edr/ui-common";
FileInput, import type { IFileUploadSetting } from "@edr/types/freight";
Group,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { FileText, Paperclip, Upload } from "lucide-react";
import type { LicenseFile } from "@/services/companies.service"; import type { LicenseFile } from "@/services/companies.service";
@@ -20,6 +14,48 @@ const ROLE_LABELS: Record<string, string> = {
transporter: "Transporter", 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 { export interface RoleLicenseProfile {
id: string; id: string;
type: string; type: string;
@@ -38,8 +74,9 @@ interface RoleLicenseStepProps {
/** /**
* Final onboarding step: collect a business license (one or more files) for * 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 * each operational role the company holds. Each role gets its own SmartFileInput
* input; already-uploaded files are listed for context. * dropzone; already-uploaded files are listed (with download links) for context
* and surface the input's "uploaded" state.
*/ */
export default function RoleLicenseStep({ export default function RoleLicenseStep({
profiles, profiles,
@@ -60,37 +97,11 @@ export default function RoleLicenseStep({
{profiles.map((profile) => { {profiles.map((profile) => {
const label = ROLE_LABELS[profile.type] ?? profile.type; const label = ROLE_LABELS[profile.type] ?? profile.type;
const selected = value[profile.id] ?? []; const selected = value[profile.id] ?? [];
const hasAny = selected.length > 0 || profile.existingFiles.length > 0; const hasExisting = profile.existingFiles.length > 0;
return ( return (
<Card key={profile.id} padding="lg" withBorder> <>
<Group justify="space-between" mb="sm" wrap="nowrap"> {hasExisting && (
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color="edr-green"
>
<FileText size={20} />
</ThemeIcon>
<div>
<Text fw={700} c="edr-text" fz={15}>
{label} Business License
</Text>
<Text size="xs" c="edr-muted" ff="monospace">
{profile.reference}
</Text>
</div>
</Group>
{hasAny && (
<Badge color="edr-green" variant="light">
Provided
</Badge>
)}
</Group>
{profile.existingFiles.length > 0 && (
<Stack gap={4} mb="sm"> <Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => ( {profile.existingFiles.map((f) => (
<Group key={f.url} gap={6} wrap="nowrap"> <Group key={f.url} gap={6} wrap="nowrap">
@@ -108,20 +119,17 @@ export default function RoleLicenseStep({
</Stack> </Stack>
)} )}
<FileInput <SmartFileInput
multiple file={buildLicenseSetting(profile.id, label)}
clearable value={{ [LICENSE_FILE_KEY]: selected }}
accept="application/pdf,image/png,image/jpeg" uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
leftSection={<Upload size={16} />} onChange={(v) => {
placeholder={ const next = v[LICENSE_FILE_KEY];
profile.existingFiles.length > 0 const files = Array.isArray(next) ? next : next ? [next] : [];
? "Upload more / replace files" setFiles(profile.id, files);
: "Select license file(s)" }}
}
value={selected}
onChange={(files) => setFiles(profile.id, files ?? [])}
/> />
</Card> </>
); );
})} })}
</Stack> </Stack>

View File

@@ -18,23 +18,17 @@ import {
ArrowRight, ArrowRight,
CheckCircle2, CheckCircle2,
RotateCw, RotateCw,
ShieldCheck,
Smartphone, Smartphone,
UserCheck, UserCheck,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import type { AuthUser } from "@/types/auth"; import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
import { import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
ControlledPhoneField,
isValidPhone,
toEthiopianE164,
} from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import RoleLicenseStep, { import RoleLicenseStep, {
@@ -42,261 +36,21 @@ import RoleLicenseStep, {
} from "@/components/onboarding/RoleLicenseStep"; } from "@/components/onboarding/RoleLicenseStep";
import ETradeInfo from "@/components/onboarding/ETradeInfo"; import ETradeInfo from "@/components/onboarding/ETradeInfo";
import { extractApiError } from "@/utils/result"; import { extractApiError } from "@/utils/result";
import {
type CompanyStep = type CompanyStep,
| "company" type FormData,
| "personnel" onboardingSchema,
| "contact" stepFields,
| "verify" } from "./companyProfileForm/schema";
| "poa" import {
| "documents" buildPayload,
| "additional"; maskPhone,
samePhone,
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ stepPayload,
const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9); toFormValues,
const samePhone = (a?: string | null, b?: string | null) => { } from "./companyProfileForm/helpers";
const da = phoneDigits(a); import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
return da.length === 9 && da === phoneDigits(b); import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
};
/** 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<typeof onboardingSchema>;
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
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<UpdateProfilePayload> {
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 (
<Stack gap={2}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" c="edr-text" fw={500}>
{value && value.trim() ? value : "—"}
</Text>
</Stack>
);
}
export default function CompanyProfileForm({ export default function CompanyProfileForm({
documentSettingCode, documentSettingCode,
@@ -316,6 +70,8 @@ export default function CompanyProfileForm({
licenseFiles, licenseFiles,
onLicenseChange, onLicenseChange,
submitError, submitError,
uploadedDocumentKeys,
onUploadDocuments,
}: { }: {
documentSettingCode: string; documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>; documentFiles?: Record<string, File | File[] | null>;
@@ -345,6 +101,16 @@ export default function CompanyProfileForm({
onLicenseChange?: (value: Record<string, File[]>) => void; onLicenseChange?: (value: Record<string, File[]>) => void;
/** Server error from the final submit (uploads/complete), shown verbatim. */ /** Server error from the final submit (uploads/complete), shown verbatim. */
submitError?: string | null; 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<CompanyStep>(initialStep ?? "company"); const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -356,17 +122,40 @@ export default function CompanyProfileForm({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]); }, [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 // 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); const wasOpen = useRef(resyncOpen);
useEffect(() => { useEffect(() => {
if (resyncOpen && !wasOpen.current && initialStep) { if (resyncOpen && !wasOpen.current && initialStep) {
userNavigatedRef.current = false;
setStep(initialStep); setStep(initialStep);
setSaveError(null); setSaveError(null);
} }
wasOpen.current = resyncOpen; wasOpen.current = resyncOpen;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [resyncOpen]); }, [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< const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null> Record<string, File | File[] | null>
>({}); >({});
@@ -410,7 +199,6 @@ export default function CompanyProfileForm({
woreda: "", woreda: "",
kebele: "", kebele: "",
houseNo: "", houseNo: "",
etradePhone: "",
contactPersonName: "", contactPersonName: "",
contactPersonPosition: "", contactPersonPosition: "",
contactPersonEmail: "", contactPersonEmail: "",
@@ -482,7 +270,7 @@ export default function CompanyProfileForm({
setValue("kebele", data.kebele); setValue("kebele", data.kebele);
setValue("houseNo", data.houseNo); setValue("houseNo", data.houseNo);
setValue( setValue(
"etradePhone", "companyPhone",
toEthiopianE164(data.regularPhone || data.mobilePhone), toEthiopianE164(data.regularPhone || data.mobilePhone),
); );
// companyAddress is composed reactively from the address fields below, so // 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). */ // "Same as …" links. A checked card prefills the target step's fields from the
const useGmAsContact = () => { // source step and disables them (kept mirrored while linked); unchecking clears
setValue("contactPersonName", watch("generalManagerName"), { // them and re-enables editing.
shouldValidate: true, const [contactSameAsGm, setContactSameAsGm] = useState(false);
}); const [poaSameAsContact, setPoaSameAsContact] = useState(false);
setValue("contactPersonEmail", watch("generalManagerEmail"));
setValue("contactPersonPhone", watch("generalManagerPhone"), { const gmName = watch("generalManagerName");
shouldValidate: true, 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 togglePoaSameAsContact = (checked: boolean) => {
const useContactAsPoa = () => { setPoaSameAsContact(checked);
setValue("poaName", watch("contactPersonName")); if (!checked) {
setValue("poaEmail", watch("contactPersonEmail")); setValue("poaName", "");
setValue("poaPhone", watch("contactPersonPhone")); setValue("poaEmail", "");
}; setValue("poaPhone", "");
}
/** 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,
});
}; };
// --- Contact-phone SMS OTP verification ----------------------------------- // --- Contact-phone SMS OTP verification -----------------------------------
@@ -612,7 +421,7 @@ export default function CompanyProfileForm({
setOtpSent(false); setOtpSent(false);
// Persist the verified phone so the step resumes as "done" after a refresh // Persist the verified phone so the step resumes as "done" after a refresh
// (best-effort — the OTP itself already succeeded server-side). // (best-effort — the OTP itself already succeeded server-side).
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {}); onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { });
} catch (err) { } catch (err) {
setOtpError(extractApiError(err).message); setOtpError(extractApiError(err).message);
} finally { } finally {
@@ -675,6 +484,7 @@ export default function CompanyProfileForm({
); );
const nextStep = async () => { const nextStep = async () => {
userNavigatedRef.current = true;
if (step === "additional") { if (step === "additional") {
if (!licenseComplete) { if (!licenseComplete) {
setSaveError( setSaveError(
@@ -699,16 +509,34 @@ export default function CompanyProfileForm({
setStep(stepOrder[currentIdx + 1]); setStep(stepOrder[currentIdx + 1]);
return; return;
} }
// The documents step has nothing to persist; field steps validate + save // The documents step auto-uploads whatever the user selected as they
// before advancing. // continue (partial uploads are allowed — required-doc completeness is
if (step !== "documents") { // re-checked on resume). A failed upload holds them on the step.
const ok = await saveCurrentStep(); if (step === "documents") {
if (!ok) return; 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]); setStep(stepOrder[currentIdx + 1]);
}; };
const prevStep = () => { const prevStep = () => {
userNavigatedRef.current = true;
setSaveError(null); setSaveError(null);
if (currentIdx === 0) onBack(); if (currentIdx === 0) onBack();
else setStep(stepOrder[currentIdx - 1]); else setStep(stepOrder[currentIdx - 1]);
@@ -864,11 +692,6 @@ export default function CompanyProfileForm({
error={errors.houseNo?.message} error={errors.houseNo?.message}
{...register("houseNo")} {...register("houseNo")}
/> />
<ControlledPhoneField
control={control}
name="etradePhone"
label="Phone"
/>
</SimpleGrid> </SimpleGrid>
</> </>
)} )}
@@ -917,33 +740,17 @@ export default function CompanyProfileForm({
{step === "contact" && ( {step === "contact" && (
<> <>
<Group justify="space-between" align="center" wrap="nowrap"> <Text fw={600} size="sm" c="edr-text">
<Text fw={600} size="sm" c="edr-text"> Contact Person
Contact Person </Text>
</Text> {watch("generalManagerName") && (
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}> <LinkCheckboxCard
<Button checked={contactSameAsGm}
variant="light" onToggle={toggleContactSameAsGm}
color="edr-green" title="Same as General Manager"
size="xs" description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
leftSection={<UserCheck size={14} />} />
onClick={useLoggedInUserAsContact} )}
>
Use me
</Button>
{watch("generalManagerName") && (
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useGmAsContact}
>
Use General Manager
</Button>
)}
</Group>
</Group>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Name" label="Name"
@@ -978,12 +785,6 @@ export default function CompanyProfileForm({
{step === "verify" && ( {step === "verify" && (
<Stack gap="md"> <Stack gap="md">
<Group gap="xs" align="center">
<ShieldCheck size={18} className="text-[var(--mantine-color-edr-green-7)]" />
<Text fw={600} size="sm" c="edr-text">
Verify the contact person
</Text>
</Group>
<Text size="sm" c="edr-muted"> <Text size="sm" c="edr-muted">
We'll text a one-time code to the contact person's phone to We'll text a one-time code to the contact person's phone to
confirm it's reachable. This is required before you continue. confirm it's reachable. This is required before you continue.
@@ -1009,7 +810,10 @@ export default function CompanyProfileForm({
) : ( ) : (
<Stack gap="sm"> <Stack gap="sm">
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<Smartphone size={16} className="text-[var(--mantine-color-edr-muted)]" /> <Smartphone
size={16}
className="text-[var(--mantine-color-edr-muted)]"
/>
<Text size="sm" c="edr-text"> <Text size="sm" c="edr-text">
{maskPhone(contactPhoneE164)} {maskPhone(contactPhoneE164)}
</Text> </Text>
@@ -1028,15 +832,17 @@ export default function CompanyProfileForm({
</Button> </Button>
) : ( ) : (
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" c="edr-muted">
Enter the 6-digit code we sent to{" "}
{maskPhone(contactPhoneE164)}.
</Text>
<PinInput <PinInput
length={6} length={6}
type="number" type="number"
oneTimeCode oneTimeCode
value={otpCode} value={otpCode}
placeholder="0"
styles={{
input: {
textAlign: "center",
},
}}
onChange={setOtpCode} onChange={setOtpCode}
/> />
<Group gap="sm"> <Group gap="sm">
@@ -1056,7 +862,9 @@ export default function CompanyProfileForm({
disabled={resendIn > 0 || sendingOtp} disabled={resendIn > 0 || sendingOtp}
leftSection={<RotateCw size={14} />} leftSection={<RotateCw size={14} />}
> >
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} {resendIn > 0
? `Resend in ${resendIn}s`
: "Resend code"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -1078,24 +886,18 @@ export default function CompanyProfileForm({
{step === "poa" && ( {step === "poa" && (
<> <>
<Group justify="space-between" align="center" wrap="nowrap"> <Text size="sm" c="edr-muted">
<Text size="sm" c="edr-muted"> Power of Attorney details are optional. Fill them in if you have
Power of Attorney details are optional. Fill them in if you them, or skip to continue.
have them, or skip to continue. </Text>
</Text> {watch("contactPersonName") && (
{watch("contactPersonName") && ( <LinkCheckboxCard
<Button checked={poaSameAsContact}
variant="light" onToggle={togglePoaSameAsContact}
color="edr-green" title="Same as contact person"
size="xs" description="Reuse the contact person's name, email and phone. Uncheck to enter different details."
leftSection={<UserCheck size={14} />} />
onClick={useContactAsPoa} )}
style={{ flexShrink: 0 }}
>
Use contact person
</Button>
)}
</Group>
<TextInput <TextInput
label="PoA Name" label="PoA Name"
placeholder="Authorized Representative Name" placeholder="Authorized Representative Name"
@@ -1147,6 +949,8 @@ export default function CompanyProfileForm({
<SmartFileInput <SmartFileInput
file={uploadSetting} file={uploadSetting}
value={documentFiles} value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
containerClassName="lg:grid grid-cols-2 items-stretch"
onChange={setDocumentFiles} onChange={setDocumentFiles}
/> />
)} )}
@@ -1210,19 +1014,12 @@ export default function CompanyProfileForm({
} }
loading={isPending || saving} loading={isPending || saving}
rightSection={ rightSection={
!isPending && !isPending && !saving && step !== "additional" ? (
!saving &&
step !== "additional" &&
step !== "documents" ? (
<ArrowRight size={16} /> <ArrowRight size={16} />
) : undefined ) : undefined
} }
> >
{step === "documents" || step === "verify" {step === "additional" ? "Submit for review" : "Continue"}
? "Continue"
: step === "additional"
? "Submit for review"
: "Save & Continue"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -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 (
<UnstyledButton
onClick={() => 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)]!"
}`}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<div
className={`mt-px flex h-5 w-5 shrink-0 items-center justify-center rounded-[6px] border transition-colors ${checked
? "border-[var(--mantine-color-edr-green-6)] bg-[var(--mantine-color-edr-green-6)] text-white"
: "border-[var(--mantine-color-gray-4)] bg-white"
}`}
>
{checked && <Check size={14} strokeWidth={3} />}
</div>
<div>
<Text fw={600} size="sm" c="edr-text" lh={1.25}>
{title}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{description}
</Text>
</div>
</Group>
</UnstyledButton>
);
}
export default LinkCheckboxCard;

View File

@@ -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 (
<Stack gap={2}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" c="edr-text" fw={500}>
{value && value.trim() ? value : "—"}
</Text>
</Stack>
);
}
export default ReadOnlyField;

View File

@@ -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<UpdateProfilePayload> {
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 ?? "",
};
}

View File

@@ -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<typeof onboardingSchema>;
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
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: [],
};

View File

@@ -3,15 +3,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { Currency } from '@prisma/client'; 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<string, Currency> = { export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
Ethiopian: Currency.ETB, ETHIOPIAN: Currency.ETB,
Djiboutian: Currency.DJF, DJIBOUTIAN: Currency.DJF,
}; };
export function resolveCurrencyFromNationality(nationality?: string): Currency { export function resolveCurrencyFromNationality(nationality?: string): Currency {
if (!nationality) return Currency.ETB; if (!nationality) return Currency.ETB;
return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD; return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD;
} }
export class FareCalculateDto { export class FareCalculateDto {
@@ -29,7 +29,7 @@ export class FareCalculateDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'Ethiopian', 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; @IsOptional() @IsString() nationality?: string;

View File

@@ -295,20 +295,34 @@ export class NotificationsService {
{ category: 'PAYMENT', deepLink: `edr://tickets/${ref}` }, { 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. // Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
if (!ticket || !booking) { if (!ticket || !booking) {
this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`); 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.`; 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.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; return;
} }
// SMS — short pointer (no HTML/QR over SMS). // SMS — short pointer (no HTML/QR over SMS).
await this.deliverSms( if (smsPhone) {
passengerId, await this.smsClient.sendSms({
`EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`, 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. // EMAIL — rich HTML ticket with plain-text fallback.
await this.deliverEmail( await this.deliverEmail(

View File

@@ -128,12 +128,16 @@ export class PaymentsController {
@ApiOperation({ @ApiOperation({
summary: "List payment systems supported by the platform", summary: "List payment systems supported by the platform",
description: 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 }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(@Query("region") region?: PaymentRegionEnum) { getMethods(
return this.service.getSupportedPaymentMethods(region); @Query("currency") currency?: string,
@Query("region") region?: PaymentRegionEnum,
) {
return this.service.getSupportedPaymentMethods(region, currency);
} }
@Get("checkout") @Get("checkout")

View File

@@ -473,7 +473,7 @@ export class PaymentsService {
}); });
} }
getSupportedPaymentMethods(region?: PaymentRegionEnum) { getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
return this.prisma.paymentMethod.findMany({ return this.prisma.paymentMethod.findMany({
where: { where: {
enabled: true, enabled: true,
@@ -487,6 +487,7 @@ export class PaymentsService {
}, },
} }
: {}), : {}),
...(currency ? { currency: currency.toUpperCase() } : {}),
}, },
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
}); });

View File

@@ -4,6 +4,7 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto';
import { CurrencyService } from '../currency/currency.service'; import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service'; import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service'; import { SegmentsService } from '../segments/segments.service';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
const POINTS_TO_MINOR = 10; const POINTS_TO_MINOR = 10;
@@ -307,9 +308,13 @@ export class SearchService {
(new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000, (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 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 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 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({ results.push({
type: 'TRANSIT', type: 'TRANSIT',
@@ -318,7 +323,9 @@ export class SearchService {
connectionMinutes, connectionMinutes,
leg1: leg1Result, leg1: leg1Result,
leg2: leg2Result, leg2: leg2Result,
displayCurrency,
combinedMinFareMinor, combinedMinFareMinor,
combinedMinFareDisplay,
// Convenience top-level fields so round-trip filter can read them uniformly // Convenience top-level fields so round-trip filter can read them uniformly
departureAt: leg1Result.departureAt, departureAt: leg1Result.departureAt,
arrivalAt: leg2Result.arrivalAt, arrivalAt: leg2Result.arrivalAt,
@@ -379,6 +386,8 @@ export class SearchService {
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality);
return { return {
type: 'DIRECT', type: 'DIRECT',
scheduleId: schedule.id, 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 })), .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
availabilityByClass, availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
displayCurrency,
faresByClass, faresByClass,
coachTypes, coachTypes,
}; };
@@ -467,7 +477,7 @@ export class SearchService {
const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); 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 const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor; : totalMinor;
@@ -494,7 +504,9 @@ export class SearchService {
originStationId: string, originStationId: string,
destinationStationId: string, destinationStationId: string,
nationality?: string, nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> { ): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality);
const seatClassIds: string[] = Array.from( const seatClassIds: string[] = Array.from(
new Set( new Set(
schedule.coachAssignments schedule.coachAssignments
@@ -535,8 +547,10 @@ export class SearchService {
scheduleId: schedule.id, scheduleId: schedule.id,
}); });
return { return {
seatClassName: fare.seatClassName, seatClassName: fare.seatClassName,
baseFareMinor: fare.baseFarePerPassengerMinor, baseFareMinor: fare.baseFarePerPassengerMinor,
displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
}; };
} catch (error) { } catch (error) {
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); 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) { if (validResults.length > 0) {
return validResults; return validResults;
} }
@@ -573,9 +589,12 @@ export class SearchService {
if (fareRules.length > 0) { if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
return fareRules.map(rule => ({ return fareRules.map(rule => ({
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
baseFareMinor: rule.baseFareMinor, baseFareMinor: rule.baseFareMinor,
displayCurrency,
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
})); }));
} }
} }
@@ -586,13 +605,13 @@ export class SearchService {
private async buildCoachTypeDetails( private async buildCoachTypeDetails(
schedule: any, schedule: any,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
): Promise<Array<{ ): Promise<Array<{
coachTypeId: string; coachTypeId: string;
coachTypeName: string; coachTypeName: string;
coachTypeCode: string; coachTypeCode: string;
coachId: string; coachId: string;
classes: Array<{ name: string; baseFareMinor: number }>; classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
}>> { }>> {
const coachTypeMap = new Map< const coachTypeMap = new Map<
string, string,
@@ -621,9 +640,14 @@ export class SearchService {
.map((className) => { .map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className); const fareInfo = faresByClass.find((f) => f.seatClassName === className);
if (!fareInfo) return null; 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); .sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({ result.push({

View File

@@ -307,9 +307,9 @@ export class SeatsService {
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`); 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) 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])); const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
@@ -334,28 +334,34 @@ export class SeatsService {
select: { seatIds: true, createdBy: true }, 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) { for (const h of activeHolds) {
const rawSeatIds = h.seatIds as string[];
try { try {
if (h.createdBy?.trimStart().startsWith('{')) { if (h.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(h.createdBy); const meta = JSON.parse(h.createdBy);
const holdFrom = seqOf(meta.originStationId); const holdFrom = seqOf(meta.originStationId);
const holdTo = seqOf(meta.destinationStationId); const holdTo = seqOf(meta.destinationStationId);
if (holdFrom !== undefined && holdTo !== undefined) { parsedHolds.push({
parsedHolds.push({ seatIds: rawSeatIds,
seatIds: h.seatIds, from: holdFrom ?? 0,
from: holdFrom, to: holdTo ?? Number.MAX_SAFE_INTEGER,
to: holdTo, passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
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 { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) { 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 (!legsOverlap) continue;
if (hold.seatIds.includes(seatId)) { 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( throw new ConflictException(
`Passenger already holds a seat on this journey leg`, `Passenger already holds a seat on this journey leg`,
); );
@@ -386,12 +392,14 @@ export class SeatsService {
if (!seg.seatId) continue; if (!seg.seatId) continue;
const segFrom = seqOf(seg.departureStationId); const segFrom = seqOf(seg.departureStationId);
const segTo = seqOf(seg.arrivalStationId); const segTo = seqOf(seg.arrivalStationId);
if (segFrom !== undefined && segTo !== undefined) { // If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
if (segFrom < reqTo && reqFrom < segTo) { const overlaps = (segFrom === undefined || segTo === undefined)
throw new ConflictException( ? true
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`, : 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 })), 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({ return tx.seatHold.create({
data: { data: {
scheduleId: dto.scheduleId, scheduleId: dto.scheduleId,
@@ -541,11 +556,16 @@ export class SeatsService {
async releaseHold(holdId: string) { async releaseHold(holdId: string) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
if (!hold) throw new NotFoundException('Hold not found'); 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 }; return { released: true, holdId };
} }
// Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy.
async confirmSeats(_seatIds: string[]) {} async confirmSeats(_seatIds: string[]) {}
// Delete the Journey (and its JourneySegments) scoped to this booking. // Delete the Journey (and its JourneySegments) scoped to this booking.
@@ -719,7 +739,18 @@ export class SeatsService {
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_MINUTE)
async expireHolds() { 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() } } }); await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
} }
} }

View File

@@ -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 { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
@@ -15,6 +15,8 @@ interface OfflineValidation {
@Injectable() @Injectable()
export class TicketsService { export class TicketsService {
private readonly logger = new Logger(TicketsService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly notifications: NotificationsService, 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') { if (booking.status !== 'CONFIRMED') {
throw new HttpException( if (booking.status === 'PENDING_PAYMENT') {
{ this.logger.warn(
status: 'error', `Booking ${bookingId} is PENDING_PAYMENT but payment intent SUCCEEDED — webhook likely missed. Auto-confirming before ticket generation.`,
message: 'Payment not completed', );
code: 400, await this.prisma.booking.update({
detail: `Booking status: ${booking.status}`, where: { id: bookingId },
}, data: { status: 'CONFIRMED' },
HttpStatus.BAD_REQUEST, });
); } 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 // Build a compact multi-leg payload for the QR so gate scanners see all legs

View File

@@ -8,6 +8,9 @@ import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination'; import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; 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 ConfirmDialog from '@/components/ui/ConfirmDialog';
import { bookingsApi, apiClient } from '@/lib/api'; import { bookingsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils'; import { formatCurrency, formatDateTime } from '@/lib/utils';
@@ -26,7 +29,9 @@ const SectionHeader = ({ title }: { title: string }) => (
</h3> </h3>
); );
export default function BookingsPage() { function BookingsPageContent() {
const canManage = usePermission(PERMS.bookings.manage);
const canCancel = usePermission(PERMS.bookings.cancel);
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' }); const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
const [showExtraFilters, setShowExtraFilters] = useState(false); const [showExtraFilters, setShowExtraFilters] = useState(false);
@@ -481,3 +486,11 @@ export default function BookingsPage() {
</div> </div>
); );
} }
export default function BookingsPage() {
return (
<PermissionGuard permission={PERMS.bookings.view}>
<BookingsPageContent />
</PermissionGuard>
);
}

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; 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 { Ticket, Users, DollarSign, Percent } from 'lucide-react';
import StatCard from '@/components/dashboard/StatCard'; import StatCard from '@/components/dashboard/StatCard';
import DataTable from '@/components/ui/DataTable'; 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']; const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
export default function DashboardPage() { function DashboardPageContent() {
const { data: stats, isLoading: statsLoading } = useQuery({ const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['dashboard-stats'], queryKey: ['dashboard-stats'],
queryFn: dashboardApi.getStats, queryFn: dashboardApi.getStats,
@@ -237,3 +239,11 @@ export default function DashboardPage() {
</div> </div>
); );
} }
export default function DashboardPage() {
return (
<PermissionGuard permission={PERMS.dashboard}>
<DashboardPageContent />
</PermissionGuard>
);
}

View File

@@ -42,7 +42,12 @@ export default function LoginPage() {
await login(email, password); await login(email, password);
router.push('/dashboard'); router.push('/dashboard');
} catch (err: any) { } 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 { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -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}</>;
}

View File

@@ -39,49 +39,65 @@ import {
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTheme } from '@/lib/theme-store'; 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', title: 'Overview',
items: [ items: [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, permission: PERMS.dashboard },
] ]
}, },
{ {
title: 'Operations', title: 'Operations',
items: [ items: [
{ name: 'Bookings', href: '/bookings', icon: Ticket }, { name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
{ name: 'Passengers', href: '/passengers', icon: Users }, { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
{ name: 'Tickets', href: '/tickets', icon: FileText }, { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
{ name: 'Lugagges', href: '/excess-baggage', icon: Banknote }, { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
] ]
}, },
{ {
title: 'Tourism', title: 'Tourism',
items: [ items: [
{ name: 'Packages', href: '/packages', icon: Package }, { name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare }, { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
] ]
}, },
{ {
title: 'Master Data', title: 'Master Data',
items: [ items: [
{ name: 'Stations', href: '/stations', icon: MapPin }, { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.admin },
{ name: 'Trains', href: '/trains', icon: Train }, { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.admin },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 }, { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.admin },
{ name: 'Seats', href: '/seats', icon: Armchair }, { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.admin },
{ name: 'Classes', href: '/classes', icon: Settings }, { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.admin },
{ name: 'Routes', href: '/routes', icon: Route }, { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.admin },
{ name: 'Schedules', href: '/schedules', icon: Calendar }, { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.admin },
] ]
}, },
{ {
title: 'Financial', title: 'Financial',
items: [ items: [
{ name: 'Fares', href: '/pricing', icon: DollarSign }, { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
{ name: 'Currencies', href: '/currencies', icon: Banknote }, { name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
{ name: 'Payments', href: '/payments', icon: CreditCard }, { name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Promos', href: '/promos', icon: Gift }, { 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', title: 'Security & Compliance',
items: [ items: [
{ name: 'Logs', href: '/audit', icon: AlertTriangle }, { name: 'Audit Logs', href: '/audit', icon: AlertTriangle, permission: PERMS.audit.view },
{ name: 'Fraud', href: '/fraud', icon: Shield }, { name: 'Fraud Detection', href: '/fraud', icon: Shield, permission: PERMS.fraud.view },
{ name: 'Verifayda', href: '/verifayda', icon: UserCheck }, { name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck, permission: PERMS.admin },
] ]
}, },
{ {
title: 'Analytics & Reports', title: 'Analytics & Reports',
items: [ items: [
{ name: 'Reports', href: '/reports', icon: BarChart3 }, { name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Operational', href: '/operational-reports', icon: FileText }, { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
] ]
}, },
{ {
title: 'System', title: 'System',
items: [ items: [
{ name: 'Agents', href: '/agents', icon: Briefcase }, { name: 'Agent Operations', href: '/agents', icon: Briefcase, permission: PERMS.agents.view },
{ name: 'Users', href: '/settings/users', icon: Users }, { name: 'User Management', href: '/settings/users', icon: Users, permission: PERMS.admin },
{ name: 'Settings', href: '/settings', icon: Settings }, { name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin },
{ name: 'Health', href: '/health', icon: Activity }, { name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin },
] ]
} }
]; ];
export default function Sidebar() { export default function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { user, logout } = useAuthStore(); const { user, logout, hasPermission } = useAuthStore();
const { isDark, toggleTheme } = useTheme(); const { isDark, toggleTheme } = useTheme();
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
@@ -154,7 +170,12 @@ export default function Sidebar() {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6"> <nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
{navigationSections.map((section) => ( {navigationSections.map((section) => {
const visibleItems = section.items.filter(
(item) => !item.permission || hasPermission(item.permission)
);
if (visibleItems.length === 0) return null;
return (
<div key={section.title}> <div key={section.title}>
{!isCollapsed && ( {!isCollapsed && (
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-white/60 dark:text-slate-400"> <h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-white/60 dark:text-slate-400">
@@ -162,7 +183,7 @@ export default function Sidebar() {
</h3> </h3>
)} )}
<div className="space-y-1"> <div className="space-y-1">
{section.items.map((item) => { {visibleItems.map((item) => {
// Special handling for Settings to avoid conflict with User Management // Special handling for Settings to avoid conflict with User Management
let isActive; let isActive;
if (item.href === '/settings') { if (item.href === '/settings') {
@@ -194,7 +215,8 @@ export default function Sidebar() {
})} })}
</div> </div>
</div> </div>
))} );
})}
</nav> </nav>
</div> </div>

View File

@@ -1,3 +1,5 @@
'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import { AdminUser } from '@/types'; import { AdminUser } from '@/types';
import axios from 'axios'; import axios from 'axios';
@@ -20,9 +22,10 @@ interface AuthState {
logout: () => void; logout: () => void;
setUser: (user: AdminUser, token: string) => void; setUser: (user: AdminUser, token: string) => void;
initialize: () => void; initialize: () => void;
hasPermission: (key: string) => boolean;
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set, get) => ({
user: null, user: null,
token: null, token: null,
refreshToken: null, refreshToken: null,
@@ -34,7 +37,11 @@ export const useAuthStore = create<AuthState>((set) => ({
const userStr = localStorage.getItem('auth_user'); const userStr = localStorage.getItem('auth_user');
if (token && userStr) { if (token && userStr) {
try { 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 }); set({ user, token, isAuthenticated: true });
} catch { } catch {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
@@ -51,23 +58,49 @@ export const useAuthStore = create<AuthState>((set) => ({
const { token, refreshToken } = loginData; const { token, refreshToken } = loginData;
if (!token) throw new Error('No token received from server'); 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`, { const meRes = await axios.get(`${API_URL}/v1/auth/me`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
const iamUser = meRes.data?.data ?? meRes.data; 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 = { const user: AdminUser = {
id: iamUser.id, id: iamUser.id,
email: iamUser.email, email: iamUser.email,
fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email, fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email,
role: mapIamRole(iamUser.roles ?? []), role: mapIamRole(iamUser.roles ?? []),
active: true, active: true,
permissions,
isSuperAdmin,
isOrgAdmin,
}; };
localStorage.setItem('auth_token', token); localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user)); localStorage.setItem('auth_user', JSON.stringify(user));
if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken); 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 }); set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true });
}, },
@@ -76,10 +109,18 @@ export const useAuthStore = create<AuthState>((set) => ({
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
localStorage.removeItem('auth_refresh_token'); localStorage.removeItem('auth_refresh_token');
localStorage.removeItem('auth_user'); localStorage.removeItem('auth_user');
document.cookie = 'auth_token=; path=/; max-age=0';
set({ user: null, token: null, refreshToken: null, isAuthenticated: false }); set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
}, },
setUser: (user: AdminUser, token: string) => { setUser: (user: AdminUser, token: string) => {
set({ user, token, isAuthenticated: true }); 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);
},
})); }));

View File

@@ -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;

View File

@@ -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 && <button>Cancel Booking</button>}
*/
export function usePermission(key: string): boolean {
return useAuthStore((s) => s.hasPermission(key));
}

View File

@@ -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).*)'],
};

View File

@@ -96,6 +96,9 @@ export interface AdminUser {
fullName: string; fullName: string;
role: 'ADMIN' | 'AGENT' | 'SUPERVISOR'; role: 'ADMIN' | 'AGENT' | 'SUPERVISOR';
active: boolean; active: boolean;
permissions: string[];
isSuperAdmin: boolean;
isOrgAdmin: boolean;
} }
// Re-export EDR types // Re-export EDR types

View File

@@ -1,9 +1,42 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store'; 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 (
<div
className="relative"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
onFocus={() => setVisible(true)}
onBlur={() => setVisible(false)}
>
{children}
<div
role="tooltip"
className={`absolute bottom-[calc(100%+8px)] left-1/2 -translate-x-1/2 w-52 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg px-3 py-2.5 shadow-xl pointer-events-none transition-all duration-150 z-50 ${
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1'
}`}
>
<ul className="space-y-1">
{content.map((item, i) => (
<li key={i} className="flex items-center gap-1.5">
<span className="text-green-400 flex-shrink-0"></span>
{item}
</li>
))}
</ul>
{/* Arrow */}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" />
</div>
</div>
);
}
export default function AuthCheckPage() { export default function AuthCheckPage() {
const router = useRouter(); const router = useRouter();
@@ -19,122 +52,54 @@ export default function AuthCheckPage() {
} }
}, [isAuthenticated, router]); }, [isAuthenticated, router]);
const handleSignIn = () => {
router.push('/login?redirect=/booking/passengers');
};
const handleGuest = () => {
router.push('/booking/passengers');
};
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-primary-50 dark:from-gray-900 dark:to-gray-800 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col items-center justify-center px-4">
<div className="container mx-auto px-4"> <div className="w-full max-w-sm">
<div className="max-w-5xl mx-auto"> <h1 className="text-2xl font-bold text-center text-gray-900 dark:text-gray-100 mb-1">
{/* Header */} Continue your booking
<div className="text-center mb-12 animate-fade-in"> </h1>
<h1 className="section-title">Continue your booking</h1> <p className="text-sm text-center text-gray-500 dark:text-gray-400 mb-8">
<p className="section-subtitle mt-2"> Choose how you&apos;d like to proceed
Sign in to access saved profiles or continue as a guest </p>
</p>
</div>
{/* Options Grid */} <div className="flex flex-col gap-3">
<div className="grid md:grid-cols-2 gap-8 mb-8"> <Tooltip content={[
{/* Sign In Option */} 'Saved passenger details',
<div 'View booking history',
onClick={handleSignIn} 'Faster future bookings',
className="card-interactive group" ]}>
<button
onClick={() => router.push('/login?redirect=/booking/passengers')}
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 bg-primary hover:bg-primary/90 active:scale-[0.98] text-white font-semibold rounded-xl transition-all shadow-md shadow-primary/20"
> >
<div className="text-center"> <LogIn className="w-5 h-5 flex-shrink-0" />
<div className="w-20 h-20 bg-gradient-to-br from-primary to-primary-700 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow"> Sign in
<LogIn className="w-10 h-10 text-white" />
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Sign in</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 text-balance">
Access your saved passenger profiles and booking history for faster checkout
</p>
{/* Benefits */}
<div className="space-y-3 mb-6 text-left">
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-primary dark:text-gray-300 text-xs"></span>
</div>
<span className="text-sm text-gray-700 dark:text-gray-300">Saved passenger details</span>
</div>
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-primary dark:text-gray-300 text-xs"></span>
</div>
<span className="text-sm text-gray-700 dark:text-gray-300">View booking history</span>
</div>
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-primary dark:text-gray-300 text-xs"></span>
</div>
<span className="text-sm text-gray-700 dark:text-gray-300">Faster future bookings</span>
</div>
</div>
<button className="btn-primary w-full text-center">
Sign in to continue
</button>
</div>
</div>
{/* Guest Option */}
<div
onClick={handleGuest}
className="card-interactive group"
>
<div className="text-center">
<div className="w-20 h-20 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow">
<UserPlus className="w-10 h-10 text-gray-700 dark:text-gray-300" />
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Continue as guest</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 text-balance">
Book without an account. You can create one after completing your booking
</p>
{/* Benefits */}
<div className="space-y-3 mb-6 text-left">
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<Clock className="w-3 h-3 text-gray-600 dark:text-gray-400" />
</div>
<span className="text-sm text-gray-700 dark:text-gray-300">Quick checkout process</span>
</div>
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<Shield className="w-3 h-3 text-gray-600 dark:text-gray-400" />
</div>
<span className="text-sm text-gray-700 dark:text-gray-300">No account required</span>
</div>
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<UserPlus className="w-3 h-3 text-gray-600 dark:text-gray-400" />
</div>
<span className="text-sm text-gray-700 dark:text-gray-300">Create account later (optional)</span>
</div>
</div>
<button className="btn-secondary w-full text-center">
Continue as guest
</button>
</div>
</div>
</div>
{/* Back Link */}
<div className="text-center">
<button
onClick={() => router.push('/booking/results')}
className="btn-ghost"
>
Back to Results
</button> </button>
</div> </Tooltip>
<Tooltip content={[
'No account required',
'Quick checkout',
'Create account later (optional)',
]}>
<button
onClick={() => router.push('/booking/passengers')}
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 active:scale-[0.98] text-gray-800 dark:text-gray-100 font-semibold rounded-xl border border-gray-200 dark:border-gray-700 transition-all shadow-sm"
>
<UserPlus className="w-5 h-5 flex-shrink-0" />
Continue as guest
</button>
</Tooltip>
</div>
<div className="mt-8 text-center">
<button
onClick={() => router.push('/booking/results')}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Back to results
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -8,7 +8,7 @@ import { useBookingStore } from '@/lib/booking-store';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useState, useEffect, useRef } from 'react'; 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'; 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']; 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 }), ...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }),
}); });
router.push(`/booking/results?${params}`); 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}>
<ChevronLeft className="w-4 h-4" />
Back Back
</button> </button>
<button type="submit" className="btn-primary flex-1" disabled={saving}> <button type="submit" className="btn-primary flex-1" disabled={saving}>

View File

@@ -14,6 +14,7 @@ import {
Wallet, Wallet,
Loader2, Loader2,
CheckCircle, CheckCircle,
ChevronLeft,
} from "lucide-react"; } from "lucide-react";
const getIconForMethod = (methodId: string) => { const getIconForMethod = (methodId: string) => {
@@ -22,21 +23,33 @@ const getIconForMethod = (methodId: string) => {
return Smartphone; return Smartphone;
}; };
const NATIONALITY_TO_CURRENCY: Record<string, 'ETB' | 'DJF' | 'USD'> = {
ETHIOPIAN: 'ETB',
DJIBOUTIAN: 'DJF',
};
export default function PaymentPage() { export default function PaymentPage() {
const router = useRouter(); const router = useRouter();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
const { selectedCurrency, setPaymentIntent, updateStatus } = const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null); const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null); const [paymentError, setPaymentError] = useState<string | null>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const displayCurrency: 'ETB' | 'DJF' | 'USD' =
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD';
// Keep payment store in sync so the mutation picks up the right currency.
useEffect(() => {
setCurrency(displayCurrency);
}, [displayCurrency, setCurrency]);
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({ const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
queryKey: ['paymentMethods'], queryKey: ['paymentMethods', displayCurrency],
queryFn: async () => { queryFn: async () => {
const response = await apiClient.get<PaymentMethod[]>('/payments/methods'); const response = await apiClient.get<PaymentMethod[]>(`/payments/methods?currency=${displayCurrency}`);
return Array.isArray(response) ? response : []; return Array.isArray(response) ? response : [];
}, },
}); });
@@ -120,7 +133,7 @@ export default function PaymentPage() {
bookingId, bookingId,
method: selectedMethod, method: selectedMethod,
paymentMethodId: selectedPaymentMethod.id, paymentMethodId: selectedPaymentMethod.id,
currency: selectedCurrency, currency: displayCurrency,
amountMinor: totalAmount, amountMinor: totalAmount,
}); });
}; };
@@ -152,428 +165,252 @@ export default function PaymentPage() {
); );
} }
// Reusable journey leg timeline block
const JourneyLeg = ({ schedule, color = 'primary', label, fare }: { schedule: any; color?: string; label: string; fare?: number }) => {
const dotColor = color === 'blue' ? 'border-blue-500' : 'border-primary';
const lineColor = color === 'blue' ? 'from-blue-500' : 'from-primary';
const badgeBg = color === 'blue' ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'bg-primary/10 text-primary';
return (
<div>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2 h-2 rounded-full ${color === 'blue' ? 'bg-blue-500' : 'bg-primary'}`} />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{label}</span>
<span className={`ml-auto text-xs px-2 py-0.5 rounded-full font-medium ${badgeBg}`}>
{schedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
</span>
</div>
<div className="flex">
<div className="flex flex-col items-center w-7 flex-shrink-0">
<div className={`w-3 h-3 rounded-full border-4 ${dotColor} bg-white dark:bg-gray-900 z-10`} />
<div className={`w-0.5 flex-1 bg-gradient-to-b ${lineColor} via-gray-300 dark:via-gray-700 to-gray-300 my-1.5`} />
<div className="w-3 h-3 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
</div>
<div className="flex-1 flex flex-col pl-2">
<div className="pb-5">
<div className="text-lg font-bold text-gray-900 dark:text-white">
{schedule?.departureTime ? format(new Date(schedule.departureTime), 'HH:mm') : '--:--'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{schedule?.departureTime ? format(new Date(schedule.departureTime), 'EEE, MMM d') : ''}
</div>
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.origin}</div>
</div>
<div className="pb-5 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>{schedule?.duration}</span>
<span>Train {schedule?.trainNumber}</span>
</div>
<div>
<div className="text-lg font-bold text-gray-900 dark:text-white">
{schedule?.arrivalTime ? format(new Date(schedule.arrivalTime), 'HH:mm') : '--:--'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{schedule?.arrivalTime ? format(new Date(schedule.arrivalTime), 'EEE, MMM d') : ''}
</div>
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.destination}</div>
</div>
</div>
</div>
{fare !== undefined && (
<div className="mt-3 pt-2 border-t border-gray-100 dark:border-gray-800 flex justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">{label} fare</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{displayCurrency} {(fare / 100).toFixed(2)}</span>
</div>
)}
</div>
);
};
// Order summary card — used in right sticky column (desktop) and inline (mobile)
const OrderSummary = () => (
<div className="card space-y-4">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Order summary
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">Ref: {pnr}</span>
</h2>
{isRoundTrip ? (
<div className="space-y-4">
<JourneyLeg schedule={outboundSchedule} label="Outbound" fare={outboundBaseFare} />
<div className="border-t-2 border-dashed border-gray-200 dark:border-gray-700 pt-4">
<JourneyLeg schedule={inboundSchedule} color="blue" label="Return" fare={inboundBaseFare} />
</div>
</div>
) : (
<JourneyLeg schedule={selectedSchedule} label="Your journey" />
)}
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-1.5">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>Passengers</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}
</span>
</div>
<div className="flex justify-between items-center pt-1">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
<span className="text-xl font-bold text-primary">{displayCurrency} {(totalAmount / 100).toFixed(2)}</span>
</div>
</div>
{/* Pay + back buttons — desktop sidebar only */}
<div className="hidden lg:flex flex-col gap-2 pt-1">
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs"> {paymentError}</p>
)}
<button
onClick={handlePayment}
disabled={!selectedMethod || isProcessing}
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : (
`Pay ${displayCurrency} ${(totalAmount / 100).toFixed(2)}`
)}
</button>
<button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center pt-1">
🔒 Secure & encrypted payment
</p>
</div>
</div>
);
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100"> <h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
Complete payment
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Booking reference:{" "}
<span className="font-bold text-primary">{pnr}</span>
</p>
{/* Payment Processing Overlay */} {/* Payment Processing Overlay */}
{isProcessing && ( {isProcessing && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md text-center"> <div className="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
{paymentMutation.isSuccess ? ( {paymentMutation.isSuccess ? (
<> <>
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" /> <CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Payment successful!</h3>
Payment successful! <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Redirecting to confirmation...</p>
</h3> <Loader2 className="w-6 h-6 text-primary animate-spin mx-auto" />
<p className="text-gray-600 dark:text-gray-400 mb-4">
Redirecting to confirmation...
</p>
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
</> </>
) : ( ) : (
<> <>
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" /> <Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Processing payment</h3>
Processing payment <p className="text-sm text-gray-500 dark:text-gray-400">Please wait...</p>
</h3>
<p className="text-gray-600 dark:text-gray-400">
Please wait while we process your payment...
</p>
</> </>
)} )}
</div> </div>
</div> </div>
)} )}
{/* Order Summary */} {/* Two-column grid */}
<div className="card mb-6"> <div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
<h2 className="text-xl font-semibold mb-6 text-gray-900 dark:text-gray-100">
Order summary
</h2>
<div className="space-y-6">
{isRoundTrip ? (
<>
{/* Outbound Journey */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 bg-primary rounded-full" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Outbound Journey</span>
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
{outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
</span>
</div>
{/* Flight-style timeline */}
<div className="flex">
{/* Left column: Timeline with dots and line */}
<div className="flex flex-col items-center w-8 flex-shrink-0">
{/* Origin dot */}
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
{/* Vertical line */}
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
{/* Destination dot */}
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
</div>
{/* Right column: Content */}
<div className="flex-1 flex flex-col">
{/* Origin */}
<div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{outboundSchedule?.origin}
</div>
</div>
{/* Journey Info */} {/* Left column — payment methods (2/3 width) */}
<div className="pb-8"> <div className="lg:col-span-2 space-y-4">
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400"> <div className="card">
<div className="flex items-center gap-1.5"> <h2 className="text-base font-bold text-gray-900 dark:text-gray-100 mb-4">Select payment method</h2>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {loadingMethods ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <div className="flex items-center justify-center py-10 gap-2">
</svg> <Loader2 className="w-6 h-6 text-primary animate-spin" />
<span className="font-medium">{outboundSchedule?.duration}</span> <span className="text-sm text-gray-500 dark:text-gray-400">Loading payment methods...</span>
</div>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="font-medium">Train {outboundSchedule?.trainNumber}</span>
</div>
</div>
</div>
{/* Destination */}
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{outboundSchedule?.destination}
</div>
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Outbound fare</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(outboundBaseFare / 100).toFixed(2)}</span>
</div>
</div>
</div> </div>
) : error ? (
{/* Return Journey */} <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="pt-4 border-t-2 border-dashed border-gray-200 dark:border-gray-700"> <p className="text-red-800 dark:text-red-200 text-sm">Failed to load payment methods. Please refresh.</p>
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Return Journey</span>
<span className="ml-auto text-xs px-2 py-0.5 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full font-medium">
{inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
</span>
</div>
{/* Flight-style timeline */}
<div className="flex">
{/* Left column: Timeline with dots and line */}
<div className="flex flex-col items-center w-8 flex-shrink-0">
{/* Origin dot */}
<div className="w-4 h-4 rounded-full border-4 border-blue-500 bg-white dark:bg-gray-900 z-10" />
{/* Vertical line */}
<div className="w-0.5 flex-1 bg-gradient-to-b from-blue-500 via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
{/* Destination dot */}
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
</div>
{/* Right column: Content */}
<div className="flex-1 flex flex-col">
{/* Origin */}
<div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{inboundSchedule?.origin}
</div>
</div>
{/* Journey Info */}
<div className="pb-8">
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">{inboundSchedule?.duration}</span>
</div>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="font-medium">Train {inboundSchedule?.trainNumber}</span>
</div>
</div>
</div>
{/* Destination */}
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{inboundSchedule?.destination}
</div>
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Return fare</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(inboundBaseFare / 100).toFixed(2)}</span>
</div>
</div>
</div> </div>
</> ) : paymentMethods.length === 0 ? (
) : ( <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<> <p className="text-yellow-800 dark:text-yellow-200 text-sm">No payment methods available at the moment.</p>
{/* One-Way Journey */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 bg-primary rounded-full" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
{selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
</span>
</div>
{/* Flight-style timeline */}
<div className="flex">
{/* Left column: Timeline with dots and line */}
<div className="flex flex-col items-center w-8 flex-shrink-0">
{/* Origin dot */}
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
{/* Vertical line */}
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
{/* Destination dot */}
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
</div>
{/* Right column: Content */}
<div className="flex-1 flex flex-col">
{/* Origin */}
<div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{selectedSchedule?.origin}
</div>
</div>
{/* Journey Info */}
<div className="pb-8">
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">{selectedSchedule?.duration}</span>
</div>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="font-medium">Train {selectedSchedule?.trainNumber}</span>
</div>
</div>
</div>
{/* Destination */}
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{selectedSchedule?.destination}
</div>
</div>
</div>
</div>
</div> </div>
</> ) : (
)} <div className="space-y-3">
{paymentMethods.map((method) => {
{/* Passengers and Total */} const Icon = getIconForMethod(method.type);
<div className="pt-4 border-t-2 border-gray-200 dark:border-gray-700"> const isSelected = selectedMethod === method.type;
<div className="flex justify-between text-sm mb-3"> return (
<span className="text-gray-600 dark:text-gray-400"> <button
Passengers key={method.id}
</span> onClick={() => setSelectedMethod(method.type)}
<span className="font-medium text-gray-900 dark:text-gray-100"> disabled={isProcessing || !method.enabled}
{passengers.length} passenger{passengers.length !== 1 ? "s" : ""} className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
</span>
</div>
<div className="flex justify-between items-center pt-3 border-t border-gray-200 dark:border-gray-700">
<span className="text-base font-bold text-gray-900 dark:text-gray-100">
Total amount
</span>
<span className="text-2xl font-bold text-primary dark:text-gray-100">
ETB {(totalAmount / 100).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
{/* Payment Methods */}
<div className="card mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
Select payment method
</h2>
{loadingMethods ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
<p className="ml-2 text-gray-600 dark:text-gray-400">Loading payment methods...</p>
</div>
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-200 text-sm">
Failed to load payment methods. Please refresh the page.
</p>
</div>
) : paymentMethods.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-yellow-800 dark:text-yellow-200 text-sm">
No payment methods available at the moment.
</p>
</div>
) : (
<div className="space-y-3">
{paymentMethods.map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
<button
key={method.id}
onClick={() => setSelectedMethod(method.type)}
disabled={isProcessing || !method.enabled}
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
isSelected
? "border-primary bg-primary/10 dark:bg-primary/20 shadow-md"
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary"
} ${isProcessing || !method.enabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-3">
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center ${
isSelected isSelected
? "bg-primary" ? 'border-primary bg-primary/8 dark:bg-primary/15 shadow-md'
: "bg-gray-100 dark:bg-gray-700" : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50'
}`} } ${isProcessing || !method.enabled ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
<Icon <div className="flex items-center gap-3">
className={`w-6 h-6 ${isSelected ? "text-white" : "text-primary"}`} <div className={`w-11 h-11 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700'}`}>
/> <Icon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
</div> </div>
<div className="flex-1"> <div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100"> <p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
{method.displayName} <p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
</p> </div>
<p className="text-sm text-gray-600 dark:text-gray-400"> {isSelected && (
{method.region} · {method.currency} <CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />
</p> )}
</div>
{isSelected && (
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-white" />
</div> </div>
)} </button>
</div> );
</button> })}
); </div>
})} )}
</div> </div>
)}
</div>
{/* Action Buttons */} {/* Order summary inline — mobile only */}
<div className="flex flex-col gap-3"> <div className="lg:hidden">
<button <OrderSummary />
onClick={handlePayment} </div>
disabled={!selectedMethod || isProcessing}
className={`btn-primary w-full py-4 text-lg font-semibold ${
!selectedMethod || isProcessing
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
{isProcessing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
Processing...
</span>
) : (
`Pay ETB ${(totalAmount / 100).toFixed(2)}`
)}
</button>
<button
onClick={() => router.back()}
disabled={isProcessing}
className="btn-secondary w-full py-2"
>
Back to review
</button>
</div>
{/* Error Message */}
{paymentError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
<p className="text-red-800 dark:text-red-200 text-sm font-medium">
{paymentError}
</p>
</div> </div>
)}
{/* Security Notice */} {/* Right column — sticky order summary (desktop only) */}
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg"> <div className="hidden lg:block">
<p className="text-xs text-gray-600 dark:text-gray-400 text-center"> <div className="sticky top-6">
🔒 Your payment is secure and encrypted. We do not store your <OrderSummary />
payment information. </div>
</p> </div>
</div>
</div>{/* end grid */}
</div> </div>
</div> </div>
{/* Mobile sticky bottom bar */}
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
<div className="flex items-center justify-between mb-2.5">
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
<span className="text-lg font-bold text-primary">{displayCurrency} {(totalAmount / 100).toFixed(2)}</span>
</div>
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs mb-2"> {paymentError}</p>
)}
<div className="flex gap-3">
<button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
<button
onClick={handlePayment}
disabled={!selectedMethod || isProcessing}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : (
`Pay ${displayCurrency} ${(totalAmount / 100).toFixed(2)}`
)}
</button>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -3,7 +3,7 @@
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store'; import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react'; import { useEffect, Suspense } from 'react';
import { XCircle, Loader2, RefreshCw } from 'lucide-react'; import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react';
function TelebirrFailureContent() { function TelebirrFailureContent() {
const router = useRouter(); const router = useRouter();
@@ -36,7 +36,8 @@ function TelebirrFailureContent() {
Try Again Try Again
</button> </button>
<button onClick={() => router.push('/booking/review')} <button onClick={() => router.push('/booking/review')}
className="btn-secondary w-full"> className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back to Review Back to Review
</button> </button>
</div> </div>

View File

@@ -3,7 +3,7 @@
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store'; import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react'; import { useEffect, Suspense } from 'react';
import { XCircle, Loader2, RefreshCw } from 'lucide-react'; import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react';
function WaafiFailureContent() { function WaafiFailureContent() {
const router = useRouter(); const router = useRouter();
@@ -39,7 +39,8 @@ function WaafiFailureContent() {
Try Again Try Again
</button> </button>
<button onClick={() => router.push('/booking/review')} <button onClick={() => router.push('/booking/review')}
className="btn-secondary w-full"> className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back to Review Back to Review
</button> </button>
</div> </div>

View File

@@ -159,12 +159,17 @@ export default function ResultsPage() {
// Find the coach type to get pricing info // Find the coach type to get pricing info
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); 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 hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60; const minutes = (schedule.durationMinutes || 0) % 60;
const durationStr = `${hours}h ${minutes}m`; const durationStr = `${hours}h ${minutes}m`;
const scheduleData = { const scheduleData = {
id: scheduleId, id: scheduleId,
trainNumber: schedule.trainNumber, trainNumber: schedule.trainNumber,
@@ -175,6 +180,7 @@ export default function ResultsPage() {
duration: durationStr, duration: durationStr,
baseFareAdult: minFare, baseFareAdult: minFare,
baseFareChild: minFare, baseFareChild: minFare,
displayCurrency: fareCurrency,
selectedSeatClass: selectedCoachType.name, selectedSeatClass: selectedCoachType.name,
selectedSeatClassName: selectedCoachType.name, selectedSeatClassName: selectedCoachType.name,
selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeId: selectedCoachType.id,
@@ -213,13 +219,22 @@ export default function ResultsPage() {
const scheduleId = schedule.scheduleId || schedule.id || ''; const scheduleId = schedule.scheduleId || schedule.id || '';
const selectedCoachType = selectedCoachTypes[scheduleId]; 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 lowestFare = null;
let displayCurrency = schedule.displayCurrency || 'ETB';
if (schedule.coachTypes?.length) { 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; 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) { } 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 hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60; const minutes = (schedule.durationMinutes || 0) % 60;
@@ -287,7 +302,7 @@ export default function ResultsPage() {
<div className="text-center lg:text-right"> <div className="text-center lg:text-right">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div> <div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
<div className="text-3xl font-bold text-primary"> <div className="text-3xl font-bold text-primary">
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} {lowestFare ? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div> <div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
{selectedCoachType && ( {selectedCoachType && (
@@ -536,7 +551,8 @@ export default function ResultsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2"> <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
{coachTypes.map((coachType: any, index: number) => { {coachTypes.map((coachType: any, index: number) => {
const isSelected = selectedCoachType?.id === coachType.coachTypeId; 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); const CoachIcon = getCoachIcon(coachType.coachTypeName);
return ( return (
@@ -585,7 +601,7 @@ export default function ResultsPage() {
}`}> }`}>
{(minPrice / 100).toFixed(2)} {(minPrice / 100).toFixed(2)}
</span> </span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">ETB</span> <span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{coachCurrency}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -615,10 +631,10 @@ export default function ResultsPage() {
</div> </div>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white"> <span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
{(cls.baseFareMinor / 100).toFixed(2)} {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
</span> </span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium"> <span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
ETB {cls.displayCurrency ?? coachCurrency}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
// Helper function to decode JWT token and extract passengerId // Helper function to decode JWT token and extract passengerId
function getPassengerIdFromToken(token: string): string | null { function getPassengerIdFromToken(token: string): string | null {
@@ -58,6 +59,14 @@ export default function ReviewPage() {
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; 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<string, string> = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' };
const displayCurrency: string =
(isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ??
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ??
'USD';
useEffect(() => { useEffect(() => {
if (!seatHold?.expiresAt) return; if (!seatHold?.expiresAt) return;
@@ -79,55 +88,54 @@ export default function ReviewPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [seatHold]); }, [seatHold]);
const buildSeatLabel = (seat: any): string => {
const base: string = seat.number || seat.label || seat.seatNumber || '';
if (!base) return 'N/A';
const posMap: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
const suffix = seat.bedPosition ? (posMap[seat.bedPosition] ?? '') : '';
return suffix ? `${base}${suffix}` : base;
};
useEffect(() => { useEffect(() => {
const fetchSeatDetails = async () => { const fetchSeatDetails = async () => {
try { try {
const details: Record<string, string> = {}; const details: Record<string, string> = {};
// Fetch outbound seat details // Fetch outbound seat details
if (isRoundTrip && outboundSchedule?.id) { if (isRoundTrip && outboundSchedule?.id) {
const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`); const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`);
const outboundCoaches = outboundSeatMap?.coaches || []; const outboundSeats = (outboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []);
const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []);
passengers.forEach(p => { passengers.forEach(p => {
if ((p as any).outboundSeatId) { if ((p as any).outboundSeatId) {
const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId); const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId);
if (seat) { if (seat) details[`outbound-${(p as any).outboundSeatId}`] = buildSeatLabel(seat);
details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
}
} }
}); });
} }
// Fetch inbound seat details // Fetch inbound seat details
if (isRoundTrip && inboundSchedule?.id) { if (isRoundTrip && inboundSchedule?.id) {
const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`); const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`);
const inboundCoaches = inboundSeatMap?.coaches || []; const inboundSeats = (inboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []);
const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []);
passengers.forEach(p => { passengers.forEach(p => {
if ((p as any).inboundSeatId) { if ((p as any).inboundSeatId) {
const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId); const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId);
if (seat) { if (seat) details[`inbound-${(p as any).inboundSeatId}`] = buildSeatLabel(seat);
details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
}
} }
}); });
} }
// Fetch one-way seat details // Fetch one-way seat details
if (!isRoundTrip && selectedSchedule?.id) { if (!isRoundTrip && selectedSchedule?.id) {
const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
const coaches = seatMapData?.coaches || []; const allSeats = (seatMapData?.coaches || []).flatMap((coach: any) => coach.seats || []);
const allSeats = coaches.flatMap((coach: any) => coach.seats || []);
passengers.forEach(p => { passengers.forEach(p => {
if (p.seatId) { if (p.seatId) {
const seat = allSeats.find((s: any) => s.id === p.seatId); const seat = allSeats.find((s: any) => s.id === p.seatId);
if (seat) { if (seat) details[p.seatId] = buildSeatLabel(seat);
details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A';
}
} }
}); });
} }
@@ -249,7 +257,7 @@ export default function ReviewPage() {
destinationStationId: searchCriteria.destinationStationId, destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId, seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: 'ETB', displayCurrency: displayCurrency,
passengers: passengers.map((p) => { passengers: passengers.map((p) => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
return { return {
@@ -288,7 +296,7 @@ export default function ReviewPage() {
destinationStationId: searchCriteria.destinationStationId, destinationStationId: searchCriteria.destinationStationId,
seatClassId: seatClassId, seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: 'ETB', displayCurrency: displayCurrency,
passengers: passengers.map(p => { passengers: passengers.map(p => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
return { return {
@@ -373,21 +381,88 @@ export default function ReviewPage() {
}, 0); }, 0);
const total = baseFare; const total = baseFare;
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
const FareSidebar = () => (
<div className="card space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Fare breakdown
</h2>
{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 (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
<div className="flex justify-between mb-0.5">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
{p.name || `Passenger ${i + 1}`}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{displayCurrency} {(passengerTotal / 100).toFixed(2)}
</span>
</div>
{isRoundTrip && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound</span>
<span>{displayCurrency} {(outFare / 100).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Return</span>
<span>{displayCurrency} {(inFare / 100).toFixed(2)}</span>
</div>
</div>
)}
</div>
);
})}
<div className="flex justify-between items-center pt-1 border-t border-gray-200 dark:border-gray-700">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
<span className="text-xl font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
</div>
{/* Action buttons — visible only in desktop sidebar */}
<div className="hidden lg:flex flex-col gap-2 pt-2">
{createBookingMutation.isError && (
<p className="text-red-600 dark:text-red-400 text-xs">
{createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'}
</p>
)}
<button
onClick={handleConfirm}
disabled={createBookingMutation.isPending}
className="btn-primary w-full"
>
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
</button>
<button onClick={() => router.back()} className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
Back
</button>
</div>
</div>
);
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review your booking</h1> <h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Review your booking</h1>
{seatHold && ( {seatHold && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-4 flex items-center gap-2">
<p className="text-yellow-800 dark:text-yellow-200"> <span className="text-yellow-800 dark:text-yellow-200 text-sm">
Your seats will be released in: <span className="font-bold">{timeLeft}</span> Seats held for: <span className="font-bold">{timeLeft}</span>
</p> </span>
</div> </div>
)} )}
<div className="space-y-6"> {/* Two-column layout on desktop */}
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
{/* Left column — trip details + passengers */}
<div className="lg:col-span-2 space-y-4">
{/* Outbound Trip Details */} {/* Outbound Trip Details */}
{isRoundTrip && outboundSchedule && ( {isRoundTrip && outboundSchedule && (
<div className="card overflow-hidden"> <div className="card overflow-hidden">
@@ -646,67 +721,47 @@ export default function ReviewPage() {
</div> </div>
</div> </div>
<div className="card"> {/* Fare breakdown — visible only on mobile (desktop shows it in right column) */}
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare breakdown</h2> <div className="lg:hidden mt-4">
<div className="space-y-3"> <FareSidebar />
{passengers.map((p, i) => { </div>
const outFare = outboundSchedule?.baseFareAdult || 0;
const inFare = inboundSchedule?.baseFareAdult || 0; </div>{/* end left column */}
const onewayFare = selectedSchedule?.baseFareAdult || 0;
const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare; {/* Right column — sticky fare card (desktop only) */}
return ( <div className="hidden lg:block">
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-3 last:border-0"> <div className="sticky top-6">
<div className="flex justify-between mb-1"> <FareSidebar />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{p.name || `Passenger ${i + 1}`}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
ETB {(passengerTotal / 100).toFixed(2)}
</span>
</div>
{isRoundTrip && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound</span>
<span>ETB {(outFare / 100).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Return</span>
<span>ETB {(inFare / 100).toFixed(2)}</span>
</div>
</div>
)}
</div>
);
})}
<div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2">
<span className="text-gray-900 dark:text-gray-100">Total</span>
<span className="text-primary dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
</div>
</div> </div>
</div> </div>
<div className="flex gap-4"> </div>{/* end grid */}
<button onClick={() => router.back()} className="btn-secondary flex-1"> </div>
Back </div>
</button>
<button {/* Mobile sticky bottom bar */}
onClick={handleConfirm} <div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
disabled={createBookingMutation.isPending} <div className="flex items-center justify-between mb-2.5">
className="btn-primary flex-1" <span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
> <span className="text-lg font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`} </div>
</button> {createBookingMutation.isError && (
</div> <p className="text-red-600 dark:text-red-400 text-xs mb-2">
{createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'}
{createBookingMutation.isError && ( </p>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4"> )}
<p className="text-red-800 dark:text-red-200 text-sm"> <div className="flex gap-3">
{createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred while creating your booking. Please try again.'} <button onClick={() => router.back()} className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2">
</p> <ChevronLeft className="w-4 h-4" />
</div> Back
)} </button>
</div> <button
onClick={handleConfirm}
disabled={createBookingMutation.isPending}
className="btn-primary flex-1 py-2.5"
>
{createBookingMutation.isPending ? 'Creating...' : `Confirm ${isAuthenticated ? '' : '& pay'}`}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,15 @@ import Image from "next/image";
import CustomModal from "@/components/CustomModal"; import CustomModal from "@/components/CustomModal";
const BED_POSITION_SUFFIX: Record<string, string> = { 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 BedCard = memo(({ bed, isSelected, onToggle }: any) => {
const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
const bedPosition = bed.bedPosition || ""; const bedPosition = bed.bedPosition || "";
@@ -364,11 +373,7 @@ export default function SeatsPage() {
return { return {
...p, ...p,
outboundSeatId: selectedSeats[i], outboundSeatId: selectedSeats[i],
outboundSeatNumber: outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
seatData?.number ||
seatData?.label ||
seatData?.seatNumber ||
"",
}; };
}); });
setPassengers(updatedPassengers); setPassengers(updatedPassengers);
@@ -401,18 +406,13 @@ export default function SeatsPage() {
return { return {
...p, ...p,
inboundSeatId: selectedSeats[i], inboundSeatId: selectedSeats[i],
inboundSeatNumber: inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
seatData?.number ||
seatData?.label ||
seatData?.seatNumber ||
"",
}; };
} }
return { return {
...p, ...p,
seatId: selectedSeats[i], seatId: selectedSeats[i],
seatNumber: seatNumber: seatData ? buildSeatLabel(seatData) : '',
seatData?.number || seatData?.label || seatData?.seatNumber || "",
}; };
}); });
setPassengers(updatedPassengers); setPassengers(updatedPassengers);
@@ -456,8 +456,7 @@ export default function SeatsPage() {
return { return {
...p, ...p,
seatId: autoSelectedSeats[i], seatId: autoSelectedSeats[i],
seatNumber: seatNumber: seatData ? buildSeatLabel(seatData) : '',
seatData?.number || seatData?.label || seatData?.seatNumber || "",
}; };
}); });
setPassengers(updatedPassengers); setPassengers(updatedPassengers);

View File

@@ -44,6 +44,7 @@ export interface SelectedSchedule {
duration: string; duration: string;
baseFareAdult: number; baseFareAdult: number;
baseFareChild: number; baseFareChild: number;
displayCurrency: string;
selectedSeatClass?: string; selectedSeatClass?: string;
selectedSeatClassName?: string; selectedSeatClassName?: string;
} }

View File

@@ -38,7 +38,7 @@ export interface Schedule {
baseFareChild?: number; baseFareChild?: number;
availableSeats?: number; availableSeats?: number;
availabilityByClass?: Record<string, number>; // API returns this availabilityByClass?: Record<string, number>; // 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<{ coachTypes?: Array<{
coachId: string; coachId: string;
coachTypeName: string; coachTypeName: string;
@@ -46,8 +46,13 @@ export interface Schedule {
classes: Array<{ classes: Array<{
name: string; name: string;
baseFareMinor: number; baseFareMinor: number;
displayCurrency?: string;
displayAmountMinor?: number;
}>; }>;
}>; }>;
displayCurrency?: string;
combinedMinFareMinor?: number;
combinedMinFareDisplay?: number;
serviceClass?: string; serviceClass?: string;
status?: string; status?: string;
hasAvailability?: boolean; hasAvailability?: boolean;

Binary file not shown.

Binary file not shown.

View File

@@ -1,8 +1,5 @@
import React, { useState, useMemo, useRef } from "react"; import React, { useState, useMemo, useRef } from "react";
import { import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight";
IFileUploadSetting,
IFileUploadField,
} from "@edr/types/freight";
import { import {
UploadCloud, UploadCloud,
FileText, FileText,
@@ -24,12 +21,20 @@ export interface SmartFileInputProps {
onChange?: (value: Record<string, File | File[] | null>) => void; onChange?: (value: Record<string, File | File[] | null>) => void;
/** External form errors mapped by fileKey. */ /** External form errors mapped by fileKey. */
errors?: Record<string, string>; errors?: Record<string, string>;
/**
* 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 state for the entire file input group. */
disabled?: boolean; disabled?: boolean;
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */ /** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
variant?: "default" | "minimal"; variant?: "default" | "minimal";
/** Optional custom container CSS classes. */ /** Optional custom container CSS classes. */
className?: string; className?: string;
containerClassName?: string;
} }
/** Helper to format file sizes in bytes to a human-readable 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. */ /** Render a suitable icon based on file extension. */
function FileIcon({ name, className }: { name: string; className?: string }) { function FileIcon({ name, className }: { name: string; className?: string }) {
const ext = name.split(".").pop()?.toLowerCase() || ""; const ext = name.split(".").pop()?.toLowerCase() || "";
if (ext === "pdf") { if (ext === "pdf") {
return <FileText className={cn("text-red-500", className)} />; return <FileText className={cn("text-red-500", className)} />;
} }
if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) { if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) {
return <ImageIcon className={cn("text-blue-500", className)} />; return <ImageIcon className={cn("text-blue-500", className)} />;
} }
if (["csv", "xls", "xlsx"].includes(ext)) { if (["csv", "xls", "xlsx"].includes(ext)) {
return <FileText className={cn("text-emerald-500", className)} />; return <FileText className={cn("text-emerald-500", className)} />;
} }
if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) { if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) {
return <File className={cn("text-amber-500", className)} />; return <File className={cn("text-amber-500", className)} />;
} }
return <File className={cn("text-slate-400", className)} />; return <File className={cn("text-slate-400", className)} />;
} }
@@ -70,16 +75,20 @@ export function SmartFileInput({
value, value,
onChange, onChange,
errors, errors,
uploadedKeys,
disabled = false, disabled = false,
variant = "default", variant = "default",
className, className,
containerClassName,
}: SmartFileInputProps) { }: SmartFileInputProps) {
// Local state to manage files when the component is used in an uncontrolled manner // Local state to manage files when the component is used in an uncontrolled manner
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>({}); const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>(
{},
);
// Local validation errors // Local validation errors
const [localErrors, setLocalErrors] = useState<Record<string, string>>({}); const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
// Drag-and-drop state active per field // Drag-and-drop state active per field
const [dragActive, setDragActive] = useState<Record<string, boolean>>({}); const [dragActive, setDragActive] = useState<Record<string, boolean>>({});
@@ -93,10 +102,13 @@ export function SmartFileInput({
// Create a map of fields for quick lookup // Create a map of fields for quick lookup
const fieldsMap = useMemo(() => { const fieldsMap = useMemo(() => {
return file.fields.reduce((acc, currentField) => { return file.fields.reduce(
acc[currentField.fileKey] = currentField; (acc, currentField) => {
return acc; acc[currentField.fileKey] = currentField;
}, {} as Record<string, IFileUploadField>); return acc;
},
{} as Record<string, IFileUploadField>,
);
}, [file.fields]); }, [file.fields]);
// Resolve current files list for a field // Resolve current files list for a field
@@ -109,8 +121,8 @@ export function SmartFileInput({
const handleFilesChange = (fieldKey: string, newFiles: File[]) => { const handleFilesChange = (fieldKey: string, newFiles: File[]) => {
const field = fieldsMap[fieldKey]; const field = fieldsMap[fieldKey];
if (!field) return; if (!field) return;
const newValue = field.isMultiple ? newFiles : (newFiles[0] || null); const newValue = field.isMultiple ? newFiles : newFiles[0] || null;
if (onChange) { if (onChange) {
const updatedValues = { const updatedValues = {
@@ -129,10 +141,10 @@ export function SmartFileInput({
const processFiles = (field: IFileUploadField, incomingFiles: File[]) => { const processFiles = (field: IFileUploadField, incomingFiles: File[]) => {
const currentFiles = getFilesForField(field.fileKey); const currentFiles = getFilesForField(field.fileKey);
const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1; const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
// Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf') // Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf')
const allowedExts = field.allowedExtensions.map((ext) => const allowedExts = field.allowedExtensions.map((ext) =>
ext.toLowerCase().replace(/^\./, "") ext.toLowerCase().replace(/^\./, ""),
); );
let validIncoming: File[] = []; let validIncoming: File[] = [];
@@ -140,13 +152,12 @@ export function SmartFileInput({
for (const fileObj of incomingFiles) { for (const fileObj of incomingFiles) {
const ext = fileObj.name.split(".").pop()?.toLowerCase() || ""; const ext = fileObj.name.split(".").pop()?.toLowerCase() || "";
const isExtValid = const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext);
allowedExts.length === 0 || allowedExts.includes(ext);
const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024; const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024;
if (!isExtValid) { if (!isExtValid) {
errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join( errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join(
", " ", ",
)}`; )}`;
break; break;
} }
@@ -187,7 +198,11 @@ export function SmartFileInput({
handleFilesChange(field.fileKey, newFilesList); 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (disabled) return; if (disabled) return;
@@ -208,7 +223,7 @@ export function SmartFileInput({
const handleFileSelect = ( const handleFileSelect = (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
field: IFileUploadField field: IFileUploadField,
) => { ) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
const filesArray = Array.from(e.target.files); const filesArray = Array.from(e.target.files);
@@ -246,179 +261,275 @@ export function SmartFileInput({
{file.description} {file.description}
</div> </div>
)} )}
{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 <div className={cn("flex flex-col gap-6", containerClassName)}>
const acceptString = field.allowedExtensions {sortedFields.map((field) => {
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) const currentFiles = getFilesForField(field.fileKey);
.join(","); 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 ( // Format accepted files for the HTML input element
<div key={field.id || field.fileKey} className="flex flex-col gap-2"> const acceptString = field.allowedExtensions
{/* Field Header */} .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1"> .join(",");
<label className="text-sm font-semibold text-foreground flex items-center gap-1">
{field.fileLabel}
{field.isRequired && (
<span className="text-destructive font-bold" aria-hidden="true">
*
</span>
)}
</label>
<span className="text-xs text-muted-foreground">
Max size: {field.maxSizeMb}MB
{field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`}
</span>
</div>
{/* Help / Description Text */} return (
{field.helpText && ( <div
<p className="text-xs text-muted-foreground">{field.helpText}</p> key={field.id || field.fileKey}
)} className="flex flex-col gap-2"
>
{/* Selected Files List */} {/* Field Header */}
{currentFiles.length > 0 && ( <div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
<div className="flex flex-col gap-2"> <label className="text-sm font-semibold text-foreground flex items-center gap-1.5">
{currentFiles.map((fileObj, idx) => ( <span className="flex items-center gap-1">
<div {field.fileLabel}
key={`${fileObj.name}-${idx}`} {field.isRequired && (
className={cn( <span
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs", className="text-destructive font-bold"
fieldError ? "border-destructive/30" : "border-border" aria-hidden="true"
>
*
</span>
)} )}
> </span>
<div className="flex items-center gap-3 min-w-0"> {isUploaded && (
<div className="p-2 bg-muted rounded-md flex items-center justify-center"> <span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
<FileIcon name={fileObj.name} className="h-5 w-5" /> <CheckCircle2 className="h-3 w-3" /> Already uploaded
</div> </span>
)}
<div className="min-w-0"> </label>
<p className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" title={fileObj.name}>
{fileObj.name} <span className="text-xs text-muted-foreground">
</p> Max size: {field.maxSizeMb}MB
<div className="flex items-center gap-2 mt-0.5"> {field.isMultiple &&
<span className="text-xs text-muted-foreground"> ` • Files: ${currentFiles.length}/${maxFiles}`}
{formatBytes(fileObj.size)} </span>
</span> </div>
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
<CheckCircle2 className="h-3 w-3" /> Ready {/* Help / Description Text */}
</span> {field.helpText && (
<p className="text-xs text-muted-foreground">
{field.helpText}
</p>
)}
{/* Selected Files List */}
{currentFiles.length > 0 && (
<div className="flex flex-col gap-2">
{currentFiles.map((fileObj, idx) => (
<div
key={`${fileObj.name}-${idx}`}
className={cn(
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
fieldError ? "border-destructive/30" : "border-border",
)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
<FileIcon name={fileObj.name} className="h-5 w-5" />
</div>
<div className="min-w-0">
<p
className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md"
title={fileObj.name}
>
{fileObj.name}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
{formatBytes(fileObj.size)}
</span>
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
<CheckCircle2 className="h-3 w-3" /> Ready
</span>
</div>
</div> </div>
</div> </div>
<button
type="button"
disabled={disabled}
onClick={() => removeFile(field.fileKey, idx)}
className={cn(
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
disabled && "opacity-50 pointer-events-none",
)}
aria-label={`Remove file ${fileObj.name}`}
>
<Trash2 className="h-4 w-4" />
</button>
{/* Hidden inputs to represent file details in traditional form submissions */}
<input
type="hidden"
name={
field.isMultiple
? `${field.fileKey}[]`
: field.fileKey
}
value={fileObj.name}
/>
</div>
))}
</div>
)}
{/* Dropzone area */}
{!reachedLimit &&
(variant === "minimal" ? (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() =>
fileInputRefs.current[field.fileKey]?.click()
}
className="gap-1.5 cursor-pointer"
>
<UploadCloud className="h-4 w-4 text-muted-foreground" />
<span>{isUploaded ? "Replace File" : "Upload File"}</span>
</Button>
<input
type="file"
ref={(el) => {
if (fileInputRefs.current) {
fileInputRefs.current[field.fileKey] = el;
}
}}
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
className="hidden"
/>
<span className="text-xs text-muted-foreground">
Accepts:{" "}
{field.allowedExtensions.join(", ").toUpperCase() ||
"All"}
</span>
</div>
) : isUploaded ? (
// Uploaded state: a solid success panel that still doubles as a
// replace target (click anywhere or drag a new file onto it).
<div
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
onDrop={(e) => handleDrop(e, field)}
className={cn(
"group relative flex items-center gap-4 rounded-lg border p-4 transition-all",
isDragOver
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input
type="file"
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
id={`file-input-${field.fileKey}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
aria-label={`Replace ${field.fileLabel}`}
/>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400">
{isDragOver ? (
<UploadCloud className="h-5 w-5 animate-bounce" />
) : (
<CheckCircle2 className="h-5 w-5" />
)}
</div> </div>
<button <div className="min-w-0 flex-1">
type="button" <p className="text-sm font-semibold text-foreground">
disabled={disabled} {isDragOver ? "Drop to replace" : "Document uploaded"}
onClick={() => removeFile(field.fileKey, idx)} </p>
className={cn( <p className="mt-0.5 text-xs text-muted-foreground">
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors", {isDragOver
disabled && "opacity-50 pointer-events-none" ? "Release to replace the document on file."
)} : "Saved to your application. Drag a new file here or click to replace it."}
aria-label={`Remove file ${fileObj.name}`} </p>
> </div>
<Trash2 className="h-4 w-4" />
</button> <span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
<UploadCloud className="h-3.5 w-3.5" />
{/* Hidden inputs to represent file details in traditional form submissions */} Replace
</span>
</div>
) : (
<div
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
onDrop={(e) => handleDrop(e, field)}
className={cn(
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
isDragOver
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError &&
"border-destructive hover:border-destructive/80",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input <input
type="hidden" type="file"
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey} multiple={field.isMultiple}
value={fileObj.name} accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
id={`file-input-${field.fileKey}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/> />
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
<UploadCloud
className={cn(
"h-6 w-6 text-muted-foreground",
isDragOver && "text-primary animate-bounce",
)}
/>
</div>
<p className="text-sm font-semibold text-foreground">
Drag & drop your file here, or{" "}
<span className="text-primary font-bold hover:underline">
browse
</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
Supported formats:{" "}
{field.allowedExtensions.join(", ").toUpperCase() ||
"All"}
</p>
</div> </div>
))} ))}
</div>
)}
{/* Dropzone area */} {/* Validation Error Message */}
{!reachedLimit && ( {fieldError && (
variant === "minimal" ? ( <div className="flex items-center gap-1.5 mt-1 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200">
<div className="flex flex-wrap items-center gap-3"> <AlertCircle className="h-3.5 w-3.5" />
<Button <span>{fieldError}</span>
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => fileInputRefs.current[field.fileKey]?.click()}
className="gap-1.5 cursor-pointer"
>
<UploadCloud className="h-4 w-4 text-muted-foreground" />
<span>Upload File</span>
</Button>
<input
type="file"
ref={(el) => {
if (fileInputRefs.current) {
fileInputRefs.current[field.fileKey] = el;
}
}}
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
className="hidden"
/>
<span className="text-xs text-muted-foreground">
Accepts: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
</span>
</div> </div>
) : ( )}
<div </div>
onDragOver={(e) => handleDrag(e, field.fileKey, true)} );
onDragLeave={(e) => handleDrag(e, field.fileKey, false)} })}
onDrop={(e) => handleDrop(e, field)} </div>
className={cn(
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
isDragOver
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError && "border-destructive hover:border-destructive/80",
disabled && "opacity-50 pointer-events-none cursor-not-allowed"
)}
>
<input
type="file"
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
id={`file-input-${field.fileKey}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
<UploadCloud className={cn("h-6 w-6 text-muted-foreground", isDragOver && "text-primary animate-bounce")} />
</div>
<p className="text-sm font-semibold text-foreground">
Drag & drop your file here, or <span className="text-primary font-bold hover:underline">browse</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
Supported formats: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
</p>
</div>
)
)}
{/* Validation Error Message */}
{fieldError && (
<div className="flex items-center gap-1.5 mt-1 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200">
<AlertCircle className="h-3.5 w-3.5" />
<span>{fieldError}</span>
</div>
)}
</div>
);
})}
</div> </div>
); );
} }

276
pnpm-lock.yaml generated
View File

@@ -10,7 +10,7 @@ importers:
dependencies: dependencies:
'@mantine/dates': '@mantine/dates':
specifier: ^9.3.2 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: devDependencies:
'@commitlint/cli': '@commitlint/cli':
specifier: ^19.5.0 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))) 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': '@tria-plc/api-common':
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz 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': '@tria-plc/iamapi-common':
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz
version: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e) version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)
amqp-connection-manager: amqp-connection-manager:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0(amqplib@2.0.1) version: 5.0.0(amqplib@2.0.1)
@@ -105,9 +105,15 @@ importers:
class-validator: class-validator:
specifier: ^0.14.1 specifier: ^0.14.1
version: 0.14.4 version: 0.14.4
cross-env:
specifier: ^10.1.0
version: 10.1.0
dotenv: dotenv:
specifier: ^17.4.2 specifier: ^17.4.2
version: 17.4.2 version: 17.4.2
dotenv-cli:
specifier: ^11.0.0
version: 11.0.0
handlebars: handlebars:
specifier: ^4.7.9 specifier: ^4.7.9
version: 4.7.9 version: 4.7.9
@@ -215,8 +221,8 @@ importers:
specifier: ^5.100.11 specifier: ^5.100.11
version: 5.101.0(react@19.2.6) version: 5.101.0(react@19.2.6)
'@tria-plc/iamui': '@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7) version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
axios: axios:
specifier: ^1.7.7 specifier: ^1.7.7
version: 1.17.0 version: 1.17.0
@@ -327,8 +333,8 @@ importers:
specifier: ^5.59.0 specifier: ^5.59.0
version: 5.101.0(react@19.2.6) version: 5.101.0(react@19.2.6)
'@tria-plc/iamui': '@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7) version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
axios: axios:
specifier: ^1.7.7 specifier: ^1.7.7
version: 1.17.0 version: 1.17.0
@@ -1476,6 +1482,9 @@ packages:
'@emotion/weak-memoize@0.4.0': '@emotion/weak-memoize@0.4.0':
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} 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': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2034,10 +2043,10 @@ packages:
react: ^19.2.0 react: ^19.2.0
react-dom: ^19.2.0 react-dom: ^19.2.0
'@mantine/core@9.4.0': '@mantine/core@9.3.2':
resolution: {integrity: sha512-BvTzaJ5Nut4ZiWLCcy1/cRXE8yjCgxsBhyAynY1pEg6XBw/fChEqSgbEB2dybOnkiAxvYt89TrnTrBBo9qJtmg==} resolution: {integrity: sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==}
peerDependencies: peerDependencies:
'@mantine/hooks': 9.4.0 '@mantine/hooks': 9.3.2
react: ^19.2.0 react: ^19.2.0
react-dom: ^19.2.0 react-dom: ^19.2.0
@@ -2050,11 +2059,11 @@ packages:
react: ^18.x || ^19.x react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x react-dom: ^18.x || ^19.x
'@mantine/dates@9.4.0': '@mantine/dates@9.3.2':
resolution: {integrity: sha512-15iCWxykutEVoFUPTq4z+An3YQZb7UH6Fwzb3stsVhTMLm6FyGLYSSB6PL+9sBV1TPllIJSm1IizY2SSTDGqQQ==} resolution: {integrity: sha512-MzHrXGoOb3rCnHBlsI/BPAjC8K5tZ4f8pzg66tsTsupVCQBaBpHSVatQwNRJ6K2JC9pyCyTa9BL803C8AiWycA==}
peerDependencies: peerDependencies:
'@mantine/core': 9.4.0 '@mantine/core': 9.3.2
'@mantine/hooks': 9.4.0 '@mantine/hooks': 9.3.2
dayjs: '>=1.0.0' dayjs: '>=1.0.0'
react: ^19.2.0 react: ^19.2.0
react-dom: ^19.2.0 react-dom: ^19.2.0
@@ -2069,8 +2078,8 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.0 react: ^19.2.0
'@mantine/hooks@9.4.0': '@mantine/hooks@9.3.2':
resolution: {integrity: sha512-SUkge8KlhyLoSSKhEHERx7jLotNu632kldhQPlLn5kbOuJixoEbU8fKcuwpXSsZTtzRgCCT2IkoN+rORhtZTHg==} resolution: {integrity: sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==}
peerDependencies: peerDependencies:
react: ^19.2.0 react: ^19.2.0
@@ -4087,9 +4096,31 @@ packages:
rxjs: ^7.8.0 rxjs: ^7.8.0
typeorm: ^0.3.0 typeorm: ^0.3.0
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.0.3.tgz': '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz':
resolution: {integrity: sha512-aZhIeNq2Uui7TUkG88G9QTBqdhaVB5kvqfd47HLgVE7qKkVyfjlm4nwlSWnHh5MO+CjgP9vRi6ILwMJx1ULO8g==, tarball: file:local-packages/tria-plc-iamui-0.0.3.tgz} resolution: {integrity: sha512-zW7dIEnoai9NSagFsjdH3CyU4dizhFnV9AkO0sOb8MJhFGaifFaMLyWkB9QMQvUIxFRziOcqw3vR0yjqLmTNPQ==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz}
version: 0.0.3 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'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
react: ^18.3.1 || ^19.0.0 react: ^18.3.1 || ^19.0.0
@@ -5734,6 +5765,11 @@ packages:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
engines: {node: '>=18.x'} engines: {node: '>=18.x'}
cross-env@10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'}
hasBin: true
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -6069,6 +6105,10 @@ packages:
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
dotenv-cli@11.0.0:
resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==}
hasBin: true
dotenv-expand@12.0.3: dotenv-expand@12.0.3:
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -11778,6 +11818,8 @@ snapshots:
'@emotion/weak-memoize@0.4.0': {} '@emotion/weak-memoize@0.4.0': {}
'@epic-web/invariant@1.0.0': {}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
optional: true optional: true
@@ -12440,10 +12482,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@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: dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@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 clsx: 2.1.1
react: 19.2.6 react: 19.2.6
react-dom: 19.2.6(react@19.2.6) react-dom: 19.2.6(react@19.2.6)
@@ -12453,19 +12495,19 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@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: 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/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': 9.3.0(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6)
clsx: 2.1.1 clsx: 2.1.1
dayjs: 1.11.21 dayjs: 1.11.21
react: 19.2.6 react: 19.2.6
react-dom: 19.2.6(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: 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/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.4.0(react@19.2.6) '@mantine/hooks': 9.3.2(react@19.2.6)
clsx: 2.1.1 clsx: 2.1.1
dayjs: 1.11.21 dayjs: 1.11.21
react: 19.2.6 react: 19.2.6
@@ -12483,7 +12525,7 @@ snapshots:
dependencies: dependencies:
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)':
dependencies: dependencies:
react: 19.2.6 react: 19.2.6
@@ -15191,6 +15233,50 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {} '@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)': '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)':
dependencies: 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/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 - debug
- supports-color - 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)': '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)':
dependencies: 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/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' - '@faker-js/faker'
- supports-color - 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: dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) '@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) '@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) '@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/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/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/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) '@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) '@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 '@types/luxon': 3.7.1
luxon: 3.7.2 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: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -17575,6 +17622,13 @@ snapshots:
dependencies: dependencies:
is-obj: 2.0.0 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: dotenv-expand@12.0.3:
dependencies: dependencies:
dotenv: 16.6.1 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): 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: 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/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/hooks': 7.17.8(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6) '@tabler/icons-react': 3.44.0(react@19.2.6)
'@tanstack/match-sorter-utils': 8.19.4 '@tanstack/match-sorter-utils': 8.19.4

View File

@@ -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
```

View File

@@ -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"
}
}

View File

@@ -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);