mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
interchange document generation and acknowledgement
This commit is contained in:
@@ -20,7 +20,12 @@
|
||||
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
|
||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
@@ -39,14 +44,15 @@
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz",
|
||||
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"libphonenumber-js": "^1.13.6",
|
||||
"minio": "7.1.3",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
@@ -44,30 +44,19 @@ export class FirstMileService {
|
||||
* paid before any first-mile work proceeds. Throws if the reference is
|
||||
* unknown or the booking has not reached PAID status.
|
||||
*/
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId, {
|
||||
relations: { serviceType: true },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return null;
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
bookingId: booking.id,
|
||||
advancedPayment: 0,
|
||||
});
|
||||
return this.acceptEligibleBooking(booking);
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
|
||||
const [booking] = await this.bookingsRepository.findAll({
|
||||
where: { reference: bookingReference },
|
||||
relations: { serviceType: true },
|
||||
@@ -75,15 +64,39 @@ export class FirstMileService {
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return null;
|
||||
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
||||
}
|
||||
|
||||
return this.acceptEligibleBooking(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared accept path: validates payment + first-mile eligibility, rejects an
|
||||
* already-assigned booking, then creates the first-mile record. Throws a
|
||||
* meaningful HTTP error instead of returning null so the client can surface
|
||||
* why an accept was refused.
|
||||
*/
|
||||
private async acceptEligibleBooking(booking: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
paymentStatus?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): Promise<FirstMile> {
|
||||
const label = booking.reference ?? booking.id;
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
return null;
|
||||
throw new BadRequestException(`Booking ${label} is not paid`);
|
||||
}
|
||||
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
return null;
|
||||
throw new BadRequestException(`Booking ${label} does not require a first mile`);
|
||||
}
|
||||
|
||||
const existing = await this.findByBookingId(booking.id);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
|
||||
}
|
||||
|
||||
return this.create({
|
||||
@@ -174,10 +187,17 @@ export class FirstMileService {
|
||||
}
|
||||
|
||||
private bookingRequestsFirstMile(booking: {
|
||||
tradeDirection?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): boolean {
|
||||
return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile);
|
||||
// Export bookings always need a first mile (pickup → origin yard); the
|
||||
// pickup address is captured at assignment time, not required upfront.
|
||||
return Boolean(
|
||||
booking.tradeDirection === 'EXPORT' ||
|
||||
booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile,
|
||||
);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -77,6 +77,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
||||
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||
import { HealthCheck } from "./features/health/HealthCheck";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -374,6 +375,7 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<Route path="/health" element={<HealthCheck />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
LayoutGrid,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@@ -84,9 +84,6 @@ export default function CustomerDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const approveMutation = useMutation(
|
||||
api.customers.setCompanyStatus.mutationOptions(),
|
||||
);
|
||||
const bookingsQuery = useQuery(
|
||||
api.customers.bookings.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
@@ -394,28 +391,12 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
@@ -546,9 +527,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -567,9 +548,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -588,9 +569,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import {
|
||||
UserManagementApp,
|
||||
type UserManagementRuntimeOptions,
|
||||
type UserManagementSessionSeed,
|
||||
} from "@tria-plc/iamui";
|
||||
} from '@tria-plc/iamui';
|
||||
import { iamConfig } from './iamConfig';
|
||||
|
||||
import { getCookie } from "@/auth/cookies";
|
||||
function readCookieValue(name: string): string | null {
|
||||
const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
|
||||
const match = document.cookie.match(
|
||||
new RegExp(`(?:^|; )${escaped}=([^;]*)`),
|
||||
);
|
||||
|
||||
import { iamConfig } from "./iamConfig";
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function readInitialSession(): UserManagementSessionSeed | null {
|
||||
const token = getCookie("auth-token");
|
||||
const token =
|
||||
localStorage.getItem('fhc-backoffice-auth-token') ??
|
||||
readCookieValue('auth-token');
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshToken = getCookie("refresh-token") ?? undefined;
|
||||
const refreshToken =
|
||||
localStorage.getItem('fhc-backoffice-auth-refresh-token') ??
|
||||
readCookieValue('refresh-token') ??
|
||||
undefined;
|
||||
|
||||
return {
|
||||
token,
|
||||
@@ -47,15 +58,14 @@ export default function UserManagementHostPage() {
|
||||
rootRef.current = createRoot(mountNode);
|
||||
}
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, "");
|
||||
const iamApiUrl = "/um-api";
|
||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, '');
|
||||
const runtime: UserManagementRuntimeOptions = {
|
||||
basename: "/um",
|
||||
basename: '/um',
|
||||
apiBaseUrl,
|
||||
apiUrl: iamApiUrl,
|
||||
recordApiUrl: iamApiUrl,
|
||||
chronicleUrl: iamApiUrl,
|
||||
auditApiUrl: iamApiUrl,
|
||||
apiUrl: `${apiBaseUrl}/api`,
|
||||
recordApiUrl: `${apiBaseUrl}/api`,
|
||||
chronicleUrl: `${apiBaseUrl}/api`,
|
||||
auditApiUrl: `${apiBaseUrl}/api`,
|
||||
};
|
||||
|
||||
rootRef.current.render(
|
||||
@@ -78,5 +88,5 @@ export default function UserManagementHostPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mountRef} style={{ position: "fixed", inset: 0 }} />;
|
||||
return <div ref={mountRef} style={{ position: 'fixed', inset: 0 }} />;
|
||||
}
|
||||
|
||||
@@ -379,6 +379,9 @@ const FirstMilePage = () => {
|
||||
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
|
||||
const res = await firstMileService.accept(reference);
|
||||
const created = res.data;
|
||||
if (!created?.id) {
|
||||
throw new Error("First-mile leg was not created for this booking.");
|
||||
}
|
||||
if (vehicleId) await firstMileService.update(created.id, { vehicleId });
|
||||
return created;
|
||||
},
|
||||
@@ -387,8 +390,11 @@ const FirstMilePage = () => {
|
||||
toast({ title: "Booking accepted", description: "First-mile leg created successfully." });
|
||||
closeAccept();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Accept failed", variant: "destructive" });
|
||||
onError: (err: unknown) => {
|
||||
const description =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
(err instanceof Error ? err.message : undefined);
|
||||
toast({ title: "Accept failed", description, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -407,7 +413,6 @@ const FirstMilePage = () => {
|
||||
paidBookings.filter(
|
||||
(booking) =>
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
Boolean(booking.firstMilePickupAddress?.trim()) &&
|
||||
!existingFirstMileBookingIds.has(booking.id),
|
||||
),
|
||||
[existingFirstMileBookingIds, paidBookings],
|
||||
|
||||
@@ -11,97 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const streamBrowserifyPath = require.resolve("stream-browserify");
|
||||
|
||||
function createIamApiAdapter(apiBaseUrl: string): Plugin {
|
||||
const upstreamBaseUrl = `${apiBaseUrl.replace(/\/+$/, "")}/api`;
|
||||
|
||||
return {
|
||||
name: "iam-api-adapter",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/um-api", async (req, res) => {
|
||||
const requestPath = req.url ?? "/";
|
||||
const normalizedPath = requestPath.replace(/^\/+/, "");
|
||||
const targetUrl = new URL(normalizedPath, `${upstreamBaseUrl}/`);
|
||||
|
||||
try {
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!value || key.toLowerCase() === "host") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
headers.append(key, item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.set(key, value);
|
||||
}
|
||||
|
||||
const body =
|
||||
req.method === "GET" || req.method === "HEAD"
|
||||
? undefined
|
||||
: await new Promise<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 }) => {
|
||||
const env = loadEnv(mode, __dirname, "");
|
||||
const apiBaseUrl =
|
||||
env.VITE_BASE_API_URL?.trim() || "http://localhost:3000";
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss(), createIamApiAdapter(apiBaseUrl)],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({
|
||||
(company?.company?.nationality as CompanyNationality | null) ?? null;
|
||||
|
||||
// Resume position from the backend-persisted step.
|
||||
const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep)
|
||||
const resumeFormStep: FormStep = FORM_STEPS.includes(
|
||||
onboardingStep as FormStep,
|
||||
)
|
||||
? (onboardingStep as FormStep)
|
||||
: "company";
|
||||
|
||||
@@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({
|
||||
const idx = FORM_STEPS.indexOf(step as FormStep);
|
||||
if (idx < 0 || idx <= furthestIdxRef.current) return;
|
||||
furthestIdxRef.current = idx;
|
||||
api.companies.setOnboardingStep.call({ step }).catch(() => {});
|
||||
api.companies.setOnboardingStep.call({ step }).catch(() => { });
|
||||
}, []);
|
||||
|
||||
// Mirror the form's step locally (for the header/pill) and persist it.
|
||||
@@ -321,7 +323,7 @@ export default function OnboardingWizardDialog({
|
||||
|
||||
// Note: no "back to role selection" — once the draft is created the role(s)
|
||||
// are fixed; the form's first-step Back is a no-op so progress never resets.
|
||||
const handleBackToRoles = useCallback(() => {}, []);
|
||||
const handleBackToRoles = useCallback(() => { }, []);
|
||||
|
||||
// Save the current step's fields to the draft (PATCH /profile). Returns the
|
||||
// server error message on failure so the form can show it (e.g. duplicate TIN).
|
||||
@@ -339,6 +341,31 @@ export default function OnboardingWizardDialog({
|
||||
[],
|
||||
);
|
||||
|
||||
// Auto-upload the documents the user just selected as they leave the documents
|
||||
// step. Only the in-memory selections are sent; once uploaded they're cleared
|
||||
// (so the final submit never re-uploads them) and the requirements query is
|
||||
// refreshed so the "Already uploaded" badges light up. Partial uploads are
|
||||
// allowed — the user may continue even with required docs still outstanding.
|
||||
const handleUploadDocuments = useCallback(async (): Promise<
|
||||
{ ok: true } | { ok: false; error: string }
|
||||
> => {
|
||||
const companyId = company?.company?.id;
|
||||
const hasNew = Object.values(documentFiles).some(
|
||||
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
||||
);
|
||||
if (!companyId || !hasNew) return { ok: true };
|
||||
try {
|
||||
await companiesService.uploadDocuments(companyId, documentFiles);
|
||||
setDocumentFiles({});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: api.companies.onboardingRequirements.queryKey(),
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractApiError(err).message };
|
||||
}
|
||||
}, [company?.company?.id, documentFiles, queryClient]);
|
||||
|
||||
// Final confirm step → finalize onboarding (no company create; it already
|
||||
// exists as a draft that's been filled in step-by-step).
|
||||
const handleSubmit = useCallback(
|
||||
@@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({
|
||||
requirementsQuery.data?.documentSettingCode ??
|
||||
documentSettingCode(effectiveNationality);
|
||||
|
||||
// Server-confirmed document state, used both to badge already-uploaded fields
|
||||
// and to keep a refreshed resume from over-shooting the documents step.
|
||||
const requirementDocuments = requirementsQuery.data?.documents ?? [];
|
||||
const uploadedDocumentKeys = requirementDocuments
|
||||
.filter((d) => d.uploaded)
|
||||
.map((d) => d.fileKey);
|
||||
// If any REQUIRED document is still missing, the resume must not rest past the
|
||||
// documents step (don't skip to Business License) — clamp it back. This only
|
||||
// changes the target once requirements load; the form follows the correction
|
||||
// as long as the user hasn't navigated yet.
|
||||
const requiredDocsMissing = requirementDocuments.some(
|
||||
(d) => d.isRequired && !d.uploaded,
|
||||
);
|
||||
const effectiveResumeStep: FormStep =
|
||||
requiredDocsMissing &&
|
||||
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
||||
? "documents"
|
||||
: resumeFormStep;
|
||||
|
||||
const formProps = {
|
||||
documentSettingCode: resolvedDocumentSettingCode,
|
||||
documentFiles,
|
||||
@@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({
|
||||
isPending: finishMutation.isPending,
|
||||
onBack: handleBackToRoles,
|
||||
hideFirstStepBack: true,
|
||||
initialStep: resumeFormStep,
|
||||
initialStep: effectiveResumeStep,
|
||||
resyncOpen: opened,
|
||||
onStepChange: handleStepChange,
|
||||
onSaveStep: saveStep,
|
||||
@@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({
|
||||
roleProfiles,
|
||||
licenseFiles,
|
||||
onLicenseChange: setLicenseFiles,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments: handleUploadDocuments,
|
||||
// Surface a failed final submit (license/document upload or complete) inside
|
||||
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
||||
// the submit step.
|
||||
@@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({
|
||||
withCloseButton={!completed}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={!completed}
|
||||
size={720}
|
||||
size={1440}
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
centered
|
||||
@@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||
styles={{
|
||||
header: {
|
||||
alignItems:"flex-start"
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
title: {
|
||||
flex: 1
|
||||
}
|
||||
flex: 1,
|
||||
},
|
||||
}}
|
||||
title={
|
||||
completed ? null : (
|
||||
@@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({
|
||||
{completed ? (
|
||||
<OnboardingCompletePanel onClose={handleClose} />
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
rightSection={
|
||||
startMutation.isPending ? undefined : <ArrowRight size={16} />
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
<Stack gap="xl">
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect
|
||||
value={roles}
|
||||
onChange={setRoles}
|
||||
embedded
|
||||
/>
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
rightSection={
|
||||
startMutation.isPending ? undefined : (
|
||||
<ArrowRight size={16} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
@@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full"
|
||||
style={{ background: "var(--mantine-color-edr-green-1)" }}
|
||||
>
|
||||
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" />
|
||||
<PartyPopper
|
||||
size={32}
|
||||
className="text-[var(--mantine-color-edr-green-7)]"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
@@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
style={{ background: "var(--mantine-color-edr-green-0)" }}
|
||||
>
|
||||
<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">
|
||||
Each operational profile (importer, exporter, freight forwarder) is
|
||||
reviewed and approved individually.
|
||||
</Text>
|
||||
</Group>
|
||||
<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">
|
||||
You can start creating bookings under a profile as soon as it's
|
||||
approved — we'll let you know the moment that happens.
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Card,
|
||||
FileInput,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { FileText, Paperclip, Upload } from "lucide-react";
|
||||
import { Anchor, Group, Stack, Text } from "@mantine/core";
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import type { IFileUploadSetting } from "@edr/types/freight";
|
||||
|
||||
import type { LicenseFile } from "@/services/companies.service";
|
||||
|
||||
@@ -20,6 +14,48 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
transporter: "Transporter",
|
||||
};
|
||||
|
||||
/** Field key the synthesized per-profile upload setting is keyed on. */
|
||||
const LICENSE_FILE_KEY = "business_license";
|
||||
|
||||
/**
|
||||
* Build a single-field upload setting so each profile's license input can reuse
|
||||
* the shared SmartFileInput (same dropzone + "uploaded" state as the documents
|
||||
* step), instead of a bespoke file picker.
|
||||
*/
|
||||
function buildLicenseSetting(
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
): IFileUploadSetting {
|
||||
return {
|
||||
id: `license-setting-${profileId}`,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
code: "business_license",
|
||||
label: "Business license",
|
||||
description: null,
|
||||
entity: "customer",
|
||||
fields: [
|
||||
{
|
||||
id: `${LICENSE_FILE_KEY}-${profileId}`,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
settingId: `license-setting-${profileId}`,
|
||||
fileKey: LICENSE_FILE_KEY,
|
||||
fileLabel: `Upload ${profileName} Business license file(s)`,
|
||||
helpText: null,
|
||||
isRequired: true,
|
||||
isMultiple: true,
|
||||
maxFiles: 10,
|
||||
allowedExtensions: ["pdf", "png", "jpg", "jpeg"],
|
||||
maxSizeMb: 10,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export interface RoleLicenseProfile {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -38,8 +74,9 @@ interface RoleLicenseStepProps {
|
||||
|
||||
/**
|
||||
* Final onboarding step: collect a business license (one or more files) for
|
||||
* each operational role the company holds. Each role gets its own multi-file
|
||||
* input; already-uploaded files are listed for context.
|
||||
* each operational role the company holds. Each role gets its own SmartFileInput
|
||||
* dropzone; already-uploaded files are listed (with download links) for context
|
||||
* and surface the input's "uploaded" state.
|
||||
*/
|
||||
export default function RoleLicenseStep({
|
||||
profiles,
|
||||
@@ -60,37 +97,11 @@ export default function RoleLicenseStep({
|
||||
{profiles.map((profile) => {
|
||||
const label = ROLE_LABELS[profile.type] ?? profile.type;
|
||||
const selected = value[profile.id] ?? [];
|
||||
const hasAny = selected.length > 0 || profile.existingFiles.length > 0;
|
||||
const hasExisting = profile.existingFiles.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={profile.id} padding="lg" withBorder>
|
||||
<Group justify="space-between" mb="sm" wrap="nowrap">
|
||||
<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 && (
|
||||
<>
|
||||
{hasExisting && (
|
||||
<Stack gap={4} mb="sm">
|
||||
{profile.existingFiles.map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
@@ -108,20 +119,17 @@ export default function RoleLicenseStep({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FileInput
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder={
|
||||
profile.existingFiles.length > 0
|
||||
? "Upload more / replace files"
|
||||
: "Select license file(s)"
|
||||
}
|
||||
value={selected}
|
||||
onChange={(files) => setFiles(profile.id, files ?? [])}
|
||||
<SmartFileInput
|
||||
file={buildLicenseSetting(profile.id, label)}
|
||||
value={{ [LICENSE_FILE_KEY]: selected }}
|
||||
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
|
||||
onChange={(v) => {
|
||||
const next = v[LICENSE_FILE_KEY];
|
||||
const files = Array.isArray(next) ? next : next ? [next] : [];
|
||||
setFiles(profile.id, files);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
@@ -18,23 +18,17 @@ import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import {
|
||||
ControlledPhoneField,
|
||||
isValidPhone,
|
||||
toEthiopianE164,
|
||||
} from "@/components/PhoneField";
|
||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import RoleLicenseStep, {
|
||||
@@ -42,261 +36,21 @@ import RoleLicenseStep, {
|
||||
} from "@/components/onboarding/RoleLicenseStep";
|
||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
type CompanyStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "verify"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
|
||||
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
||||
const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9);
|
||||
const samePhone = (a?: string | null, b?: string | null) => {
|
||||
const da = phoneDigits(a);
|
||||
return da.length === 9 && da === phoneDigits(b);
|
||||
};
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z
|
||||
.string()
|
||||
.min(1, "Company phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||
// standalone input — the granular fields live in the registration section.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.length(10, "VAT number must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
licenceNumber: z.string().optional(),
|
||||
statusDescription: z.string().optional(),
|
||||
dateRegistered: z.string().optional(),
|
||||
renewedFrom: z.string().optional(),
|
||||
renewalDate: z.string().optional(),
|
||||
renewedTo: z.string().optional(),
|
||||
// Address fields are user-entered and required (the registration/license
|
||||
// fields above are read-only confirmations pulled from eTrade).
|
||||
region: z.string().min(1, "Region is required"),
|
||||
zone: z.string().min(1, "Zone is required"),
|
||||
woreda: z.string().min(1, "Woreda is required"),
|
||||
kebele: z.string().min(1, "Kebele is required"),
|
||||
houseNo: z.string().min(1, "House number is required"),
|
||||
etradePhone: z.string().optional(),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPosition: z.string().optional(),
|
||||
contactPersonEmail: z
|
||||
.string()
|
||||
.email("Invalid email address")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
contactPersonPhone: z
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
generalManagerName: z.string().min(1, "Manager name is required"),
|
||||
generalManagerEmail: z.string().email("Invalid Manager email"),
|
||||
generalManagerPhone: z
|
||||
.string()
|
||||
.min(1, "Manager phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
poaName: z.string().optional(),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaAddress: z.string().optional(),
|
||||
poaEmail: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<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>
|
||||
);
|
||||
}
|
||||
import {
|
||||
type CompanyStep,
|
||||
type FormData,
|
||||
onboardingSchema,
|
||||
stepFields,
|
||||
} from "./companyProfileForm/schema";
|
||||
import {
|
||||
buildPayload,
|
||||
maskPhone,
|
||||
samePhone,
|
||||
stepPayload,
|
||||
toFormValues,
|
||||
} from "./companyProfileForm/helpers";
|
||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
||||
|
||||
export default function CompanyProfileForm({
|
||||
documentSettingCode,
|
||||
@@ -316,6 +70,8 @@ export default function CompanyProfileForm({
|
||||
licenseFiles,
|
||||
onLicenseChange,
|
||||
submitError,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
@@ -345,6 +101,16 @@ export default function CompanyProfileForm({
|
||||
onLicenseChange?: (value: Record<string, File[]>) => void;
|
||||
/** Server error from the final submit (uploads/complete), shown verbatim. */
|
||||
submitError?: string | null;
|
||||
/** fileKeys whose company document is already uploaded server-side (resume). */
|
||||
uploadedDocumentKeys?: string[];
|
||||
/**
|
||||
* Auto-upload the currently-selected company documents (the Documents step's
|
||||
* "Continue" action). Resolves to an error message string on failure so the
|
||||
* step can surface it and hold the user in place.
|
||||
*/
|
||||
onUploadDocuments?: () => Promise<
|
||||
{ ok: true } | { ok: false; error: string }
|
||||
>;
|
||||
}) {
|
||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -356,17 +122,40 @@ export default function CompanyProfileForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
// Tracks whether the user has manually navigated the form this session. While
|
||||
// false, the form still follows the parent's resume target (initialStep) —
|
||||
// which can shift to an earlier step once server data lands (e.g. a required
|
||||
// document turns out to be un-uploaded, so we must not rest on a later step).
|
||||
const userNavigatedRef = useRef(false);
|
||||
|
||||
// On reopen, jump to the furthest step reached (initialStep) so progress
|
||||
// never appears to reset.
|
||||
// never appears to reset. Re-arm the follow-the-parent behaviour too.
|
||||
const wasOpen = useRef(resyncOpen);
|
||||
useEffect(() => {
|
||||
if (resyncOpen && !wasOpen.current && initialStep) {
|
||||
userNavigatedRef.current = false;
|
||||
setStep(initialStep);
|
||||
setSaveError(null);
|
||||
}
|
||||
wasOpen.current = resyncOpen;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resyncOpen]);
|
||||
|
||||
// Follow a parent-driven resume correction: if initialStep changes (the wizard
|
||||
// re-clamps it back once onboarding requirements load — e.g. a required
|
||||
// document is still missing, so it must not skip ahead to Business License),
|
||||
// adopt it, but only while the user hasn't started navigating themselves.
|
||||
const lastInitialStep = useRef(initialStep);
|
||||
useEffect(() => {
|
||||
if (initialStep && initialStep !== lastInitialStep.current) {
|
||||
lastInitialStep.current = initialStep;
|
||||
if (!userNavigatedRef.current) {
|
||||
setStep(initialStep);
|
||||
setSaveError(null);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialStep]);
|
||||
const [internalFiles, setInternalFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
@@ -410,7 +199,6 @@ export default function CompanyProfileForm({
|
||||
woreda: "",
|
||||
kebele: "",
|
||||
houseNo: "",
|
||||
etradePhone: "",
|
||||
contactPersonName: "",
|
||||
contactPersonPosition: "",
|
||||
contactPersonEmail: "",
|
||||
@@ -482,7 +270,7 @@ export default function CompanyProfileForm({
|
||||
setValue("kebele", data.kebele);
|
||||
setValue("houseNo", data.houseNo);
|
||||
setValue(
|
||||
"etradePhone",
|
||||
"companyPhone",
|
||||
toEthiopianE164(data.regularPhone || data.mobilePhone),
|
||||
);
|
||||
// companyAddress is composed reactively from the address fields below, so
|
||||
@@ -512,33 +300,54 @@ export default function CompanyProfileForm({
|
||||
});
|
||||
};
|
||||
|
||||
/** Copy the General Manager into the Contact Person fields (still editable). */
|
||||
const useGmAsContact = () => {
|
||||
setValue("contactPersonName", watch("generalManagerName"), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
setValue("contactPersonEmail", watch("generalManagerEmail"));
|
||||
setValue("contactPersonPhone", watch("generalManagerPhone"), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
// them and re-enables editing.
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||
|
||||
const gmName = watch("generalManagerName");
|
||||
const gmEmail = watch("generalManagerEmail");
|
||||
const gmPhone = watch("generalManagerPhone");
|
||||
const contactName = watch("contactPersonName");
|
||||
const contactEmail = watch("contactPersonEmail");
|
||||
const contactPhone = watch("contactPersonPhone");
|
||||
|
||||
// While linked, mirror the source values into the (disabled) target fields so
|
||||
// the copy stays current even if the user goes back and edits the source.
|
||||
useEffect(() => {
|
||||
if (!contactSameAsGm) return;
|
||||
setValue("contactPersonName", gmName ?? "", { shouldValidate: true });
|
||||
setValue("contactPersonEmail", gmEmail ?? "");
|
||||
setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!poaSameAsContact) return;
|
||||
setValue("poaName", contactName ?? "");
|
||||
setValue("poaEmail", contactEmail ?? "");
|
||||
setValue("poaPhone", contactPhone ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poaSameAsContact, contactName, contactEmail, contactPhone]);
|
||||
|
||||
const toggleContactSameAsGm = (checked: boolean) => {
|
||||
setContactSameAsGm(checked);
|
||||
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
||||
if (!checked) {
|
||||
setValue("contactPersonName", "");
|
||||
setValue("contactPersonEmail", "");
|
||||
setValue("contactPersonPhone", "");
|
||||
}
|
||||
};
|
||||
|
||||
/** Copy the Contact Person into the PoA fields (still editable). */
|
||||
const useContactAsPoa = () => {
|
||||
setValue("poaName", watch("contactPersonName"));
|
||||
setValue("poaEmail", watch("contactPersonEmail"));
|
||||
setValue("poaPhone", watch("contactPersonPhone"));
|
||||
};
|
||||
|
||||
/** Populate the Contact Person from the currently logged-in user. */
|
||||
const useLoggedInUserAsContact = () => {
|
||||
setValue("contactPersonName", user?.name?.en ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
if (user?.email) setValue("contactPersonEmail", user.email);
|
||||
setValue("contactPersonPhone", user?.phoneNumber ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
const togglePoaSameAsContact = (checked: boolean) => {
|
||||
setPoaSameAsContact(checked);
|
||||
if (!checked) {
|
||||
setValue("poaName", "");
|
||||
setValue("poaEmail", "");
|
||||
setValue("poaPhone", "");
|
||||
}
|
||||
};
|
||||
|
||||
// --- Contact-phone SMS OTP verification -----------------------------------
|
||||
@@ -612,7 +421,7 @@ export default function CompanyProfileForm({
|
||||
setOtpSent(false);
|
||||
// Persist the verified phone so the step resumes as "done" after a refresh
|
||||
// (best-effort — the OTP itself already succeeded server-side).
|
||||
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {});
|
||||
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { });
|
||||
} catch (err) {
|
||||
setOtpError(extractApiError(err).message);
|
||||
} finally {
|
||||
@@ -675,6 +484,7 @@ export default function CompanyProfileForm({
|
||||
);
|
||||
|
||||
const nextStep = async () => {
|
||||
userNavigatedRef.current = true;
|
||||
if (step === "additional") {
|
||||
if (!licenseComplete) {
|
||||
setSaveError(
|
||||
@@ -699,16 +509,34 @@ export default function CompanyProfileForm({
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
return;
|
||||
}
|
||||
// The documents step has nothing to persist; field steps validate + save
|
||||
// before advancing.
|
||||
if (step !== "documents") {
|
||||
const ok = await saveCurrentStep();
|
||||
if (!ok) return;
|
||||
// The documents step auto-uploads whatever the user selected as they
|
||||
// continue (partial uploads are allowed — required-doc completeness is
|
||||
// re-checked on resume). A failed upload holds them on the step.
|
||||
if (step === "documents") {
|
||||
if (onUploadDocuments) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await onUploadDocuments();
|
||||
if (!res.ok) {
|
||||
setSaveError(res.error);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
setSaveError(null);
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
return;
|
||||
}
|
||||
// Field steps validate + save before advancing.
|
||||
const ok = await saveCurrentStep();
|
||||
if (!ok) return;
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
userNavigatedRef.current = true;
|
||||
setSaveError(null);
|
||||
if (currentIdx === 0) onBack();
|
||||
else setStep(stepOrder[currentIdx - 1]);
|
||||
@@ -864,11 +692,6 @@ export default function CompanyProfileForm({
|
||||
error={errors.houseNo?.message}
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="etradePhone"
|
||||
label="Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
@@ -917,33 +740,17 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "contact" && (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Contact Person
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
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>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Contact Person
|
||||
</Text>
|
||||
{watch("generalManagerName") && (
|
||||
<LinkCheckboxCard
|
||||
checked={contactSameAsGm}
|
||||
onToggle={toggleContactSameAsGm}
|
||||
title="Same as General Manager"
|
||||
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
@@ -978,12 +785,6 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "verify" && (
|
||||
<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">
|
||||
We'll text a one-time code to the contact person's phone to
|
||||
confirm it's reachable. This is required before you continue.
|
||||
@@ -1009,7 +810,10 @@ export default function CompanyProfileForm({
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<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">
|
||||
{maskPhone(contactPhoneE164)}
|
||||
</Text>
|
||||
@@ -1028,15 +832,17 @@ export default function CompanyProfileForm({
|
||||
</Button>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Enter the 6-digit code we sent to{" "}
|
||||
{maskPhone(contactPhoneE164)}.
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
styles={{
|
||||
input: {
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
<Group gap="sm">
|
||||
@@ -1056,7 +862,9 @@ export default function CompanyProfileForm({
|
||||
disabled={resendIn > 0 || sendingOtp}
|
||||
leftSection={<RotateCw size={14} />}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
{resendIn > 0
|
||||
? `Resend in ${resendIn}s`
|
||||
: "Resend code"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -1078,24 +886,18 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "poa" && (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Power of Attorney details are optional. Fill them in if you
|
||||
have them, or skip to continue.
|
||||
</Text>
|
||||
{watch("contactPersonName") && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useContactAsPoa}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Use contact person
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" c="edr-muted">
|
||||
Power of Attorney details are optional. Fill them in if you have
|
||||
them, or skip to continue.
|
||||
</Text>
|
||||
{watch("contactPersonName") && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsContact}
|
||||
onToggle={togglePoaSameAsContact}
|
||||
title="Same as contact person"
|
||||
description="Reuse the contact person's name, email and phone. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
@@ -1147,6 +949,8 @@ export default function CompanyProfileForm({
|
||||
<SmartFileInput
|
||||
file={uploadSetting}
|
||||
value={documentFiles}
|
||||
uploadedKeys={uploadedDocumentKeys}
|
||||
containerClassName="lg:grid grid-cols-2 items-stretch"
|
||||
onChange={setDocumentFiles}
|
||||
/>
|
||||
)}
|
||||
@@ -1210,19 +1014,12 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
loading={isPending || saving}
|
||||
rightSection={
|
||||
!isPending &&
|
||||
!saving &&
|
||||
step !== "additional" &&
|
||||
step !== "documents" ? (
|
||||
!isPending && !saving && step !== "additional" ? (
|
||||
<ArrowRight size={16} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{step === "documents" || step === "verify"
|
||||
? "Continue"
|
||||
: step === "additional"
|
||||
? "Submit for review"
|
||||
: "Save & Continue"}
|
||||
{step === "additional" ? "Submit for review" : "Continue"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -3,15 +3,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
// Nationality → home currency mapping
|
||||
// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup)
|
||||
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
|
||||
Ethiopian: Currency.ETB,
|
||||
Djiboutian: Currency.DJF,
|
||||
ETHIOPIAN: Currency.ETB,
|
||||
DJIBOUTIAN: Currency.DJF,
|
||||
};
|
||||
|
||||
export function resolveCurrencyFromNationality(nationality?: string): Currency {
|
||||
if (!nationality) return Currency.ETB;
|
||||
return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD;
|
||||
return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD;
|
||||
}
|
||||
|
||||
export class FareCalculateDto {
|
||||
@@ -29,7 +29,7 @@ export class FareCalculateDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Ethiopian',
|
||||
description: 'Passenger nationality. Determines the billing currency: Ethiopian → ETB, Djiboutian → DJF, other → USD. Defaults to ETB.',
|
||||
description: 'Passenger nationality. Determines the billing currency: ETHIOPIAN → ETB, DJIBOUTIAN → DJF, other → USD. Case-insensitive. Defaults to ETB.',
|
||||
})
|
||||
@IsOptional() @IsString() nationality?: string;
|
||||
|
||||
|
||||
@@ -295,20 +295,34 @@ export class NotificationsService {
|
||||
{ category: 'PAYMENT', deepLink: `edr://tickets/${ref}` },
|
||||
);
|
||||
|
||||
// Resolve SMS phone: prefer the IAM user's stored number, fall back to the phone
|
||||
// the passenger entered on the booking form (contactPhone).
|
||||
const contactPhone: string | null = (booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null;
|
||||
const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null;
|
||||
const smsPhone = iamPhone ?? contactPhone;
|
||||
|
||||
// Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
|
||||
if (!ticket || !booking) {
|
||||
this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`);
|
||||
const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`;
|
||||
await this.deliverEmail(passengerId, `Payment received — ${ref}`, text);
|
||||
await this.deliverSms(passengerId, text);
|
||||
if (smsPhone) {
|
||||
await this.smsClient.sendSms({ to: smsPhone, message: text }).catch(() => null);
|
||||
} else {
|
||||
this.logger.warn(`No SMS phone for booking ${ref}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// SMS — short pointer (no HTML/QR over SMS).
|
||||
await this.deliverSms(
|
||||
passengerId,
|
||||
`EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
|
||||
);
|
||||
if (smsPhone) {
|
||||
await this.smsClient.sendSms({
|
||||
to: smsPhone,
|
||||
message: `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
|
||||
}).catch(() => null);
|
||||
} else {
|
||||
this.logger.warn(`No SMS phone for booking ${ref}`);
|
||||
}
|
||||
|
||||
// EMAIL — rich HTML ticket with plain-text fallback.
|
||||
await this.deliverEmail(
|
||||
|
||||
@@ -128,12 +128,16 @@ export class PaymentsController {
|
||||
@ApiOperation({
|
||||
summary: "List payment systems supported by the platform",
|
||||
description:
|
||||
"Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.",
|
||||
"Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.",
|
||||
})
|
||||
@ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." })
|
||||
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
|
||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||
getMethods(@Query("region") region?: PaymentRegionEnum) {
|
||||
return this.service.getSupportedPaymentMethods(region);
|
||||
getMethods(
|
||||
@Query("currency") currency?: string,
|
||||
@Query("region") region?: PaymentRegionEnum,
|
||||
) {
|
||||
return this.service.getSupportedPaymentMethods(region, currency);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
|
||||
@@ -473,7 +473,7 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
|
||||
return this.prisma.paymentMethod.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
@@ -487,6 +487,7 @@ export class PaymentsService {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(currency ? { currency: currency.toUpperCase() } : {}),
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
@@ -307,9 +308,13 @@ export class SearchService {
|
||||
(new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000,
|
||||
);
|
||||
|
||||
const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
|
||||
const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg1MinDisplay = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg2MinDisplay = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity);
|
||||
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
|
||||
const combinedMinFareDisplay = (isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) + (isFinite(leg2MinDisplay) ? leg2MinDisplay : 0);
|
||||
const displayCurrency = leg1Result.displayCurrency ?? leg2Result.displayCurrency ?? Currency.ETB;
|
||||
|
||||
results.push({
|
||||
type: 'TRANSIT',
|
||||
@@ -318,7 +323,9 @@ export class SearchService {
|
||||
connectionMinutes,
|
||||
leg1: leg1Result,
|
||||
leg2: leg2Result,
|
||||
displayCurrency,
|
||||
combinedMinFareMinor,
|
||||
combinedMinFareDisplay,
|
||||
// Convenience top-level fields so round-trip filter can read them uniformly
|
||||
departureAt: leg1Result.departureAt,
|
||||
arrivalAt: leg2Result.arrivalAt,
|
||||
@@ -379,6 +386,8 @@ export class SearchService {
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality);
|
||||
|
||||
return {
|
||||
type: 'DIRECT',
|
||||
scheduleId: schedule.id,
|
||||
@@ -395,6 +404,7 @@ export class SearchService {
|
||||
.map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
displayCurrency,
|
||||
faresByClass,
|
||||
coachTypes,
|
||||
};
|
||||
@@ -467,7 +477,7 @@ export class SearchService {
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
|
||||
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
@@ -494,7 +504,9 @@ export class SearchService {
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
nationality?: string,
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
||||
|
||||
const seatClassIds: string[] = Array.from(
|
||||
new Set(
|
||||
schedule.coachAssignments
|
||||
@@ -535,8 +547,10 @@ export class SearchService {
|
||||
scheduleId: schedule.id,
|
||||
});
|
||||
return {
|
||||
seatClassName: fare.seatClassName,
|
||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||
seatClassName: fare.seatClassName,
|
||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||
displayCurrency: fare.billingCurrency as Currency,
|
||||
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
|
||||
@@ -545,7 +559,9 @@ export class SearchService {
|
||||
}),
|
||||
);
|
||||
|
||||
const validResults = results.filter((r): r is { seatClassName: string; baseFareMinor: number } => r !== null);
|
||||
const validResults = results.filter(
|
||||
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
|
||||
);
|
||||
if (validResults.length > 0) {
|
||||
return validResults;
|
||||
}
|
||||
@@ -573,9 +589,12 @@ export class SearchService {
|
||||
if (fareRules.length > 0) {
|
||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
||||
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
||||
return fareRules.map(rule => ({
|
||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
displayCurrency,
|
||||
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -586,13 +605,13 @@ export class SearchService {
|
||||
|
||||
private async buildCoachTypeDetails(
|
||||
schedule: any,
|
||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
|
||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
||||
): Promise<Array<{
|
||||
coachTypeId: string;
|
||||
coachTypeName: string;
|
||||
coachTypeCode: string;
|
||||
coachId: string;
|
||||
classes: Array<{ name: string; baseFareMinor: number }>;
|
||||
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
|
||||
}>> {
|
||||
const coachTypeMap = new Map<
|
||||
string,
|
||||
@@ -621,9 +640,14 @@ export class SearchService {
|
||||
.map((className) => {
|
||||
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||
if (!fareInfo) return null;
|
||||
return { name: className, baseFareMinor: fareInfo.baseFareMinor };
|
||||
return {
|
||||
name: className,
|
||||
baseFareMinor: fareInfo.baseFareMinor,
|
||||
displayCurrency: fareInfo.displayCurrency,
|
||||
displayAmountMinor: fareInfo.displayAmountMinor,
|
||||
};
|
||||
})
|
||||
.filter((c): c is { name: string; baseFareMinor: number } => c !== null)
|
||||
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
|
||||
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||
|
||||
result.push({
|
||||
|
||||
@@ -307,9 +307,9 @@ export class SeatsService {
|
||||
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD');
|
||||
if (blocked.length > 0)
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`);
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||
|
||||
@@ -334,28 +334,34 @@ export class SeatsService {
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = [];
|
||||
for (const h of activeHolds) {
|
||||
const rawSeatIds = h.seatIds as string[];
|
||||
try {
|
||||
if (h.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(h.createdBy);
|
||||
const holdFrom = seqOf(meta.originStationId);
|
||||
const holdTo = seqOf(meta.destinationStationId);
|
||||
if (holdFrom !== undefined && holdTo !== undefined) {
|
||||
parsedHolds.push({
|
||||
seatIds: h.seatIds,
|
||||
from: holdFrom,
|
||||
to: holdTo,
|
||||
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||
});
|
||||
}
|
||||
parsedHolds.push({
|
||||
seatIds: rawSeatIds,
|
||||
from: holdFrom ?? 0,
|
||||
to: holdTo ?? Number.MAX_SAFE_INTEGER,
|
||||
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||
legUnknown: holdFrom === undefined || holdTo === undefined,
|
||||
});
|
||||
} else {
|
||||
// Legacy plain-string createdBy — can't determine leg; block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Malformed JSON — block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
for (const hold of parsedHolds) {
|
||||
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
|
||||
const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to);
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
if (hold.seatIds.includes(seatId)) {
|
||||
@@ -364,7 +370,7 @@ export class SeatsService {
|
||||
);
|
||||
}
|
||||
|
||||
if (hold.passengerIds.includes(passengerId)) {
|
||||
if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(
|
||||
`Passenger already holds a seat on this journey leg`,
|
||||
);
|
||||
@@ -386,12 +392,14 @@ export class SeatsService {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
const segTo = seqOf(seg.arrivalStationId);
|
||||
if (segFrom !== undefined && segTo !== undefined) {
|
||||
if (segFrom < reqTo && reqFrom < segTo) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||
);
|
||||
}
|
||||
// If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
|
||||
const overlaps = (segFrom === undefined || segTo === undefined)
|
||||
? true
|
||||
: segFrom < reqTo && reqFrom < segTo;
|
||||
if (overlaps) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +409,13 @@ export class SeatsService {
|
||||
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
||||
};
|
||||
|
||||
// Mark seats as HELD so the status check catches them immediately on any
|
||||
// subsequent hold attempt (avoids relying solely on the SeatHold table scan).
|
||||
await tx.seat.updateMany({
|
||||
where: { id: { in: seatIds } },
|
||||
data: { status: 'HELD' },
|
||||
});
|
||||
|
||||
return tx.seatHold.create({
|
||||
data: {
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -541,11 +556,16 @@ export class SeatsService {
|
||||
async releaseHold(holdId: string) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
||||
if (!hold) throw new NotFoundException('Hold not found');
|
||||
await this.prisma.seatHold.delete({ where: { id: holdId } });
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.seat.updateMany({
|
||||
where: { id: { in: hold.seatIds as string[] }, status: 'HELD' },
|
||||
data: { status: 'AVAILABLE' },
|
||||
}),
|
||||
this.prisma.seatHold.delete({ where: { id: holdId } }),
|
||||
]);
|
||||
return { released: true, holdId };
|
||||
}
|
||||
|
||||
// Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy.
|
||||
async confirmSeats(_seatIds: string[]) {}
|
||||
|
||||
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
||||
@@ -719,7 +739,18 @@ export class SeatsService {
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expireHolds() {
|
||||
// Holds are temporary and don't create Journey rows — just delete expired ones.
|
||||
const expired = await this.prisma.seatHold.findMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
select: { id: true, seatIds: true },
|
||||
});
|
||||
if (expired.length === 0) return;
|
||||
|
||||
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
|
||||
// Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched.
|
||||
await this.prisma.seat.updateMany({
|
||||
where: { id: { in: expiredSeatIds }, status: 'HELD' },
|
||||
data: { status: 'AVAILABLE' },
|
||||
});
|
||||
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -15,6 +15,8 @@ interface OfflineValidation {
|
||||
|
||||
@Injectable()
|
||||
export class TicketsService {
|
||||
private readonly logger = new Logger(TicketsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
@@ -154,17 +156,29 @@ export class TicketsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Booking not in CONFIRMED state (safety net — should align with SUCCEEDED)
|
||||
// Booking not in CONFIRMED state — could be a webhook delivery failure.
|
||||
// If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT,
|
||||
// self-heal here rather than rejecting a legitimately paid booking.
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Payment not completed',
|
||||
code: 400,
|
||||
detail: `Booking status: ${booking.status}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
if (booking.status === 'PENDING_PAYMENT') {
|
||||
this.logger.warn(
|
||||
`Booking ${bookingId} is PENDING_PAYMENT but payment intent SUCCEEDED — webhook likely missed. Auto-confirming before ticket generation.`,
|
||||
);
|
||||
await this.prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: 'CONFIRMED' },
|
||||
});
|
||||
} else {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Payment not completed',
|
||||
code: 400,
|
||||
detail: `Booking status: ${booking.status}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
||||
|
||||
@@ -8,6 +8,9 @@ import Badge from '@/components/ui/Badge';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { usePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { bookingsApi, apiClient } from '@/lib/api';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
@@ -26,7 +29,9 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
</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 [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
@@ -481,3 +486,11 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookingsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.bookings.view}>
|
||||
<BookingsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { Ticket, Users, DollarSign, Percent } from 'lucide-react';
|
||||
import StatCard from '@/components/dashboard/StatCard';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
@@ -11,7 +13,7 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, R
|
||||
|
||||
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
|
||||
export default function DashboardPage() {
|
||||
function DashboardPageContent() {
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: dashboardApi.getStats,
|
||||
@@ -237,3 +239,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.dashboard}>
|
||||
<DashboardPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,12 @@ export default function LoginPage() {
|
||||
await login(email, password);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.');
|
||||
const msg = err.message || err.response?.data?.message || '';
|
||||
if (msg === 'ACCESS_DENIED') {
|
||||
setError('This account does not have back-office access. Contact your administrator.');
|
||||
} else {
|
||||
setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -39,49 +39,65 @@ import {
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
const navigationSections = [
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: 'Overview',
|
||||
items: [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, permission: PERMS.dashboard },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Operations',
|
||||
items: [
|
||||
{ name: 'Bookings', href: '/bookings', icon: Ticket },
|
||||
{ name: 'Passengers', href: '/passengers', icon: Users },
|
||||
{ name: 'Tickets', href: '/tickets', icon: FileText },
|
||||
{ name: 'Lugagges', href: '/excess-baggage', icon: Banknote },
|
||||
{ name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
|
||||
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
|
||||
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
|
||||
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Tourism',
|
||||
items: [
|
||||
{ name: 'Packages', href: '/packages', icon: Package },
|
||||
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare },
|
||||
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
|
||||
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Master Data',
|
||||
items: [
|
||||
{ name: 'Stations', href: '/stations', icon: MapPin },
|
||||
{ name: 'Trains', href: '/trains', icon: Train },
|
||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
|
||||
{ name: 'Seats', href: '/seats', icon: Armchair },
|
||||
{ name: 'Classes', href: '/classes', icon: Settings },
|
||||
{ name: 'Routes', href: '/routes', icon: Route },
|
||||
{ name: 'Schedules', href: '/schedules', icon: Calendar },
|
||||
{ name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.admin },
|
||||
{ name: 'Trains', href: '/trains', icon: Train, permission: PERMS.admin },
|
||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.admin },
|
||||
{ name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.admin },
|
||||
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.admin },
|
||||
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.admin },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Financial',
|
||||
items: [
|
||||
{ name: 'Fares', href: '/pricing', icon: DollarSign },
|
||||
{ name: 'Currencies', href: '/currencies', icon: Banknote },
|
||||
{ name: 'Payments', href: '/payments', icon: CreditCard },
|
||||
{ name: 'Promos', href: '/promos', icon: Gift },
|
||||
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
|
||||
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
|
||||
{ name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
|
||||
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Customer Services',
|
||||
items: [
|
||||
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view },
|
||||
{ name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
|
||||
{ name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send },
|
||||
]
|
||||
},
|
||||
// {
|
||||
@@ -95,32 +111,32 @@ const navigationSections = [
|
||||
{
|
||||
title: 'Security & Compliance',
|
||||
items: [
|
||||
{ name: 'Logs', href: '/audit', icon: AlertTriangle },
|
||||
{ name: 'Fraud', href: '/fraud', icon: Shield },
|
||||
{ name: 'Verifayda', href: '/verifayda', icon: UserCheck },
|
||||
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle, permission: PERMS.audit.view },
|
||||
{ name: 'Fraud Detection', href: '/fraud', icon: Shield, permission: PERMS.fraud.view },
|
||||
{ name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck, permission: PERMS.admin },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Analytics & Reports',
|
||||
items: [
|
||||
{ name: 'Reports', href: '/reports', icon: BarChart3 },
|
||||
{ name: 'Operational', href: '/operational-reports', icon: FileText },
|
||||
{ name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
|
||||
{ name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ name: 'Agents', href: '/agents', icon: Briefcase },
|
||||
{ name: 'Users', href: '/settings/users', icon: Users },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
{ name: 'Health', href: '/health', icon: Activity },
|
||||
{ name: 'Agent Operations', href: '/agents', icon: Briefcase, permission: PERMS.agents.view },
|
||||
{ name: 'User Management', href: '/settings/users', icon: Users, permission: PERMS.admin },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { user, logout, hasPermission } = useAuthStore();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -154,7 +170,12 @@ export default function Sidebar() {
|
||||
|
||||
{/* Navigation */}
|
||||
<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}>
|
||||
{!isCollapsed && (
|
||||
<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>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
{visibleItems.map((item) => {
|
||||
// Special handling for Settings to avoid conflict with User Management
|
||||
let isActive;
|
||||
if (item.href === '/settings') {
|
||||
@@ -194,7 +215,8 @@ export default function Sidebar() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { AdminUser } from '@/types';
|
||||
import axios from 'axios';
|
||||
@@ -20,9 +22,10 @@ interface AuthState {
|
||||
logout: () => void;
|
||||
setUser: (user: AdminUser, token: string) => void;
|
||||
initialize: () => void;
|
||||
hasPermission: (key: string) => boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
@@ -34,7 +37,11 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
const userStr = localStorage.getItem('auth_user');
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
const user = JSON.parse(userStr) as AdminUser;
|
||||
// backfill for sessions stored before permissions were added
|
||||
if (!user.permissions) user.permissions = [];
|
||||
if (user.isSuperAdmin === undefined) user.isSuperAdmin = false;
|
||||
if (user.isOrgAdmin === undefined) user.isOrgAdmin = false;
|
||||
set({ user, token, isAuthenticated: true });
|
||||
} catch {
|
||||
localStorage.removeItem('auth_token');
|
||||
@@ -51,23 +58,49 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
const { token, refreshToken } = loginData;
|
||||
if (!token) throw new Error('No token received from server');
|
||||
|
||||
// Step 2: fetch full user info with the token
|
||||
// Step 2: fetch full user from IAM /v1/auth/me — returns session.userInfo
|
||||
// employee is an array here (unlike /auth/me which transforms it to a single object via parseToken)
|
||||
const meRes = await axios.get(`${API_URL}/v1/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const iamUser = meRes.data?.data ?? meRes.data;
|
||||
|
||||
// Role permissions — flat array in data.permissions
|
||||
const rolePerms = (iamUser.permissions ?? []).map((p: any) => String(p.key));
|
||||
// Position permissions — employee[] is an array here; positions[].permissions[] merged by IAM
|
||||
const employeeArr: any[] = Array.isArray(iamUser.employee) ? iamUser.employee : [];
|
||||
const positionPerms = employeeArr.flatMap((emp: any) =>
|
||||
(emp.positions ?? []).flatMap((pos: any) =>
|
||||
(pos.permissions ?? []).map((p: any) => String(p.key))
|
||||
)
|
||||
);
|
||||
const permissions = Array.from(new Set([...rolePerms, ...positionPerms]));
|
||||
|
||||
const isSuperAdmin = iamUser.isSuperAdmin ?? false;
|
||||
const isOrgAdmin = iamUser.isOrganizationAdmin ?? false;
|
||||
|
||||
// Block individual (passenger) accounts — backoffice requires at least one of:
|
||||
// super admin, org admin, an employee position, or an explicit permission.
|
||||
if (!isSuperAdmin && !isOrgAdmin && employeeArr.length === 0 && permissions.length === 0) {
|
||||
throw new Error('ACCESS_DENIED');
|
||||
}
|
||||
|
||||
const user: AdminUser = {
|
||||
id: iamUser.id,
|
||||
email: iamUser.email,
|
||||
fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email,
|
||||
role: mapIamRole(iamUser.roles ?? []),
|
||||
active: true,
|
||||
permissions,
|
||||
isSuperAdmin,
|
||||
isOrgAdmin,
|
||||
};
|
||||
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken);
|
||||
// cookie lets middleware detect auth without reading localStorage
|
||||
document.cookie = `auth_token=${token}; path=/; SameSite=Lax`;
|
||||
|
||||
set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true });
|
||||
},
|
||||
@@ -76,10 +109,18 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_refresh_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
document.cookie = 'auth_token=; path=/; max-age=0';
|
||||
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
setUser: (user: AdminUser, token: string) => {
|
||||
set({ user, token, isAuthenticated: true });
|
||||
},
|
||||
|
||||
hasPermission: (key: string) => {
|
||||
const { user } = get();
|
||||
if (!user) return false;
|
||||
if (user.isSuperAdmin || user.isOrgAdmin) return true;
|
||||
return user.permissions.includes(key);
|
||||
},
|
||||
}));
|
||||
|
||||
42
apps/edr-passenger-web/backoffice/src/lib/permissions.ts
Normal file
42
apps/edr-passenger-web/backoffice/src/lib/permissions.ts
Normal 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;
|
||||
15
apps/edr-passenger-web/backoffice/src/lib/use-permission.ts
Normal file
15
apps/edr-passenger-web/backoffice/src/lib/use-permission.ts
Normal 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));
|
||||
}
|
||||
24
apps/edr-passenger-web/backoffice/src/middleware.ts
Normal file
24
apps/edr-passenger-web/backoffice/src/middleware.ts
Normal 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).*)'],
|
||||
};
|
||||
@@ -96,6 +96,9 @@ export interface AdminUser {
|
||||
fullName: string;
|
||||
role: 'ADMIN' | 'AGENT' | 'SUPERVISOR';
|
||||
active: boolean;
|
||||
permissions: string[];
|
||||
isSuperAdmin: boolean;
|
||||
isOrgAdmin: boolean;
|
||||
}
|
||||
|
||||
// Re-export EDR types
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { LogIn, UserPlus, Shield, Clock } from 'lucide-react';
|
||||
import { LogIn, UserPlus, ChevronLeft } from 'lucide-react';
|
||||
|
||||
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<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() {
|
||||
const router = useRouter();
|
||||
@@ -19,122 +52,54 @@ export default function AuthCheckPage() {
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const handleSignIn = () => {
|
||||
router.push('/login?redirect=/booking/passengers');
|
||||
};
|
||||
|
||||
const handleGuest = () => {
|
||||
router.push('/booking/passengers');
|
||||
};
|
||||
|
||||
return (
|
||||
<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="container mx-auto px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h1 className="section-title">Continue your booking</h1>
|
||||
<p className="section-subtitle mt-2">
|
||||
Sign in to access saved profiles or continue as a guest
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-2xl font-bold text-center text-gray-900 dark:text-gray-100 mb-1">
|
||||
Continue your booking
|
||||
</h1>
|
||||
<p className="text-sm text-center text-gray-500 dark:text-gray-400 mb-8">
|
||||
Choose how you'd like to proceed
|
||||
</p>
|
||||
|
||||
{/* Options Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-8">
|
||||
{/* Sign In Option */}
|
||||
<div
|
||||
onClick={handleSignIn}
|
||||
className="card-interactive group"
|
||||
<div className="flex flex-col gap-3">
|
||||
<Tooltip content={[
|
||||
'Saved passenger details',
|
||||
'View booking history',
|
||||
'Faster future bookings',
|
||||
]}>
|
||||
<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">
|
||||
<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">
|
||||
<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
|
||||
<LogIn className="w-5 h-5 flex-shrink-0" />
|
||||
Sign in
|
||||
</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>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react';
|
||||
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe, ChevronLeft } from 'lucide-react';
|
||||
import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar';
|
||||
|
||||
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
@@ -951,7 +951,8 @@ export default function PassengersPage() {
|
||||
...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }),
|
||||
});
|
||||
router.push(`/booking/results?${params}`);
|
||||
}} className="btn-secondary flex-1" disabled={saving}>
|
||||
}} className="btn-secondary flex-1 flex items-center justify-center gap-2" disabled={saving}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Wallet,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
const getIconForMethod = (methodId: string) => {
|
||||
@@ -22,21 +23,33 @@ const getIconForMethod = (methodId: string) => {
|
||||
return Smartphone;
|
||||
};
|
||||
|
||||
const NATIONALITY_TO_CURRENCY: Record<string, 'ETB' | 'DJF' | 'USD'> = {
|
||||
ETHIOPIAN: 'ETB',
|
||||
DJIBOUTIAN: 'DJF',
|
||||
};
|
||||
|
||||
export default function PaymentPage() {
|
||||
const router = useRouter();
|
||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
|
||||
const { selectedCurrency, setPaymentIntent, updateStatus } =
|
||||
usePaymentStore();
|
||||
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
|
||||
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[]>({
|
||||
queryKey: ['paymentMethods'],
|
||||
queryKey: ['paymentMethods', displayCurrency],
|
||||
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 : [];
|
||||
},
|
||||
});
|
||||
@@ -120,7 +133,7 @@ export default function PaymentPage() {
|
||||
bookingId,
|
||||
method: selectedMethod,
|
||||
paymentMethodId: selectedPaymentMethod.id,
|
||||
currency: selectedCurrency,
|
||||
currency: displayCurrency,
|
||||
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 (
|
||||
<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="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
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>
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
|
||||
|
||||
{/* Payment Processing Overlay */}
|
||||
{isProcessing && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 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="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<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 ? (
|
||||
<>
|
||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
Payment successful!
|
||||
</h3>
|
||||
<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" />
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Payment successful!</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Redirecting to confirmation...</p>
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin mx-auto" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
Processing payment
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Please wait while we process your payment...
|
||||
</p>
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Processing payment</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait...</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="card mb-6">
|
||||
<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>
|
||||
{/* Two-column grid */}
|
||||
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
||||
|
||||
{/* 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">{outboundSchedule?.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 {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>
|
||||
{/* Left column — payment methods (2/3 width) */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="card">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 mb-4">Select payment method</h2>
|
||||
{loadingMethods ? (
|
||||
<div className="flex items-center justify-center py-10 gap-2">
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Loading payment methods...</span>
|
||||
</div>
|
||||
|
||||
{/* Return Journey */}
|
||||
<div className="pt-4 border-t-2 border-dashed border-gray-200 dark:border-gray-700">
|
||||
<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>
|
||||
) : 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.</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 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>
|
||||
) : 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Passengers and Total */}
|
||||
<div className="pt-4 border-t-2 border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between text-sm mb-3">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
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-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 ${
|
||||
) : (
|
||||
<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-xl border-2 transition-all text-left ${
|
||||
isSelected
|
||||
? "bg-primary"
|
||||
: "bg-gray-100 dark:bg-gray-700"
|
||||
}`}
|
||||
? 'border-primary bg-primary/8 dark:bg-primary/15 shadow-md'
|
||||
: '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
|
||||
className={`w-6 h-6 ${isSelected ? "text-white" : "text-primary"}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{method.displayName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{method.region} · {method.currency}
|
||||
</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 className="flex items-center gap-3">
|
||||
<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 className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={handlePayment}
|
||||
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>
|
||||
{/* Order summary inline — mobile only */}
|
||||
<div className="lg:hidden">
|
||||
<OrderSummary />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Notice */}
|
||||
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 text-center">
|
||||
🔒 Your payment is secure and encrypted. We do not store your
|
||||
payment information.
|
||||
</p>
|
||||
</div>
|
||||
{/* Right column — sticky order summary (desktop only) */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="sticky top-6">
|
||||
<OrderSummary />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{/* end grid */}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useEffect, Suspense } from 'react';
|
||||
import { XCircle, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react';
|
||||
|
||||
function TelebirrFailureContent() {
|
||||
const router = useRouter();
|
||||
@@ -36,7 +36,8 @@ function TelebirrFailureContent() {
|
||||
Try Again
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useEffect, Suspense } from 'react';
|
||||
import { XCircle, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react';
|
||||
|
||||
function WaafiFailureContent() {
|
||||
const router = useRouter();
|
||||
@@ -39,7 +39,8 @@ function WaafiFailureContent() {
|
||||
Try Again
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -159,12 +159,17 @@ export default function ResultsPage() {
|
||||
|
||||
// Find the coach type to get pricing info
|
||||
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code);
|
||||
const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0;
|
||||
|
||||
// Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed.
|
||||
const minFare = coachType?.classes.length
|
||||
? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor))
|
||||
: 0;
|
||||
const fareCurrency: string =
|
||||
coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB';
|
||||
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
const durationStr = `${hours}h ${minutes}m`;
|
||||
|
||||
|
||||
const scheduleData = {
|
||||
id: scheduleId,
|
||||
trainNumber: schedule.trainNumber,
|
||||
@@ -175,6 +180,7 @@ export default function ResultsPage() {
|
||||
duration: durationStr,
|
||||
baseFareAdult: minFare,
|
||||
baseFareChild: minFare,
|
||||
displayCurrency: fareCurrency,
|
||||
selectedSeatClass: selectedCoachType.name,
|
||||
selectedSeatClassName: selectedCoachType.name,
|
||||
selectedCoachTypeId: selectedCoachType.id,
|
||||
@@ -213,13 +219,22 @@ export default function ResultsPage() {
|
||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||
const selectedCoachType = selectedCoachTypes[scheduleId];
|
||||
|
||||
// Calculate lowest fare from coach types
|
||||
// Calculate lowest fare and display currency from coach types / faresByClass.
|
||||
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
||||
let lowestFare = null;
|
||||
let displayCurrency = schedule.displayCurrency || 'ETB';
|
||||
if (schedule.coachTypes?.length) {
|
||||
const allFares = schedule.coachTypes.flatMap(ct => ct.classes.map(c => c.baseFareMinor)).filter(f => f > 0);
|
||||
const allClasses = schedule.coachTypes.flatMap(ct => ct.classes);
|
||||
const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0);
|
||||
lowestFare = allFares.length ? Math.min(...allFares) : null;
|
||||
const firstWithCurrency = allClasses.find(c => c.displayCurrency);
|
||||
if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
|
||||
} else if (schedule.faresByClass?.length) {
|
||||
lowestFare = Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0));
|
||||
lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0));
|
||||
const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency);
|
||||
if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
|
||||
} else if (schedule.combinedMinFareDisplay) {
|
||||
lowestFare = schedule.combinedMinFareDisplay;
|
||||
}
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
@@ -287,7 +302,7 @@ export default function ResultsPage() {
|
||||
<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-3xl font-bold text-primary">
|
||||
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||
{lowestFare ? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
||||
{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">
|
||||
{coachTypes.map((coachType: any, index: number) => {
|
||||
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0;
|
||||
const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB';
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
return (
|
||||
@@ -585,7 +601,7 @@ export default function ResultsPage() {
|
||||
}`}>
|
||||
{(minPrice / 100).toFixed(2)}
|
||||
</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>
|
||||
@@ -615,10 +631,10 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<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 className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
ETB
|
||||
{cls.displayCurrency ?? coachCurrency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { format } from 'date-fns';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
// Helper function to decode JWT token and extract passengerId
|
||||
function getPassengerIdFromToken(token: string): string | null {
|
||||
@@ -58,6 +59,14 @@ export default function ReviewPage() {
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
// Prefer the currency already stored on the selected schedule (set from search results).
|
||||
// Fall back to deriving from nationality so the review page is never left with a stale value.
|
||||
const NATIONALITY_TO_CURRENCY: Record<string, string> = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' };
|
||||
const displayCurrency: string =
|
||||
(isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ??
|
||||
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ??
|
||||
'USD';
|
||||
|
||||
useEffect(() => {
|
||||
if (!seatHold?.expiresAt) return;
|
||||
|
||||
@@ -79,55 +88,54 @@ export default function ReviewPage() {
|
||||
return () => clearInterval(interval);
|
||||
}, [seatHold]);
|
||||
|
||||
const buildSeatLabel = (seat: any): string => {
|
||||
const base: string = seat.number || seat.label || seat.seatNumber || '';
|
||||
if (!base) return 'N/A';
|
||||
const posMap: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
|
||||
const suffix = seat.bedPosition ? (posMap[seat.bedPosition] ?? '') : '';
|
||||
return suffix ? `${base}${suffix}` : base;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSeatDetails = async () => {
|
||||
try {
|
||||
const details: Record<string, string> = {};
|
||||
|
||||
|
||||
// Fetch outbound seat details
|
||||
if (isRoundTrip && outboundSchedule?.id) {
|
||||
const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`);
|
||||
const outboundCoaches = outboundSeatMap?.coaches || [];
|
||||
const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
const outboundSeats = (outboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
passengers.forEach(p => {
|
||||
if ((p as any).outboundSeatId) {
|
||||
const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId);
|
||||
if (seat) {
|
||||
details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
||||
}
|
||||
if (seat) details[`outbound-${(p as any).outboundSeatId}`] = buildSeatLabel(seat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Fetch inbound seat details
|
||||
if (isRoundTrip && inboundSchedule?.id) {
|
||||
const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`);
|
||||
const inboundCoaches = inboundSeatMap?.coaches || [];
|
||||
const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
const inboundSeats = (inboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
passengers.forEach(p => {
|
||||
if ((p as any).inboundSeatId) {
|
||||
const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId);
|
||||
if (seat) {
|
||||
details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
||||
}
|
||||
if (seat) details[`inbound-${(p as any).inboundSeatId}`] = buildSeatLabel(seat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Fetch one-way seat details
|
||||
if (!isRoundTrip && selectedSchedule?.id) {
|
||||
const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
|
||||
const coaches = seatMapData?.coaches || [];
|
||||
const allSeats = coaches.flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
const allSeats = (seatMapData?.coaches || []).flatMap((coach: any) => coach.seats || []);
|
||||
|
||||
passengers.forEach(p => {
|
||||
if (p.seatId) {
|
||||
const seat = allSeats.find((s: any) => s.id === p.seatId);
|
||||
if (seat) {
|
||||
details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
||||
}
|
||||
if (seat) details[p.seatId] = buildSeatLabel(seat);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -249,7 +257,7 @@ export default function ReviewPage() {
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
displayCurrency: 'ETB',
|
||||
displayCurrency: displayCurrency,
|
||||
passengers: passengers.map((p) => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
@@ -288,7 +296,7 @@ export default function ReviewPage() {
|
||||
destinationStationId: searchCriteria.destinationStationId,
|
||||
seatClassId: seatClassId,
|
||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
displayCurrency: 'ETB',
|
||||
displayCurrency: displayCurrency,
|
||||
passengers: passengers.map(p => {
|
||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||
return {
|
||||
@@ -373,21 +381,88 @@ export default function ReviewPage() {
|
||||
}, 0);
|
||||
const total = baseFare;
|
||||
|
||||
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
|
||||
const FareSidebar = () => (
|
||||
<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 (
|
||||
<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="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 && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800 dark:text-yellow-200">
|
||||
⏱️ Your seats will be released in: <span className="font-bold">{timeLeft}</span>
|
||||
</p>
|
||||
<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">
|
||||
<span className="text-yellow-800 dark:text-yellow-200 text-sm">
|
||||
⏱️ Seats held for: <span className="font-bold">{timeLeft}</span>
|
||||
</span>
|
||||
</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 */}
|
||||
{isRoundTrip && outboundSchedule && (
|
||||
<div className="card overflow-hidden">
|
||||
@@ -646,67 +721,47 @@ export default function ReviewPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare breakdown</h2>
|
||||
<div className="space-y-3">
|
||||
{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-3 last:border-0">
|
||||
<div className="flex justify-between mb-1">
|
||||
<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>
|
||||
{/* Fare breakdown — visible only on mobile (desktop shows it in right column) */}
|
||||
<div className="lg:hidden mt-4">
|
||||
<FareSidebar />
|
||||
</div>
|
||||
|
||||
</div>{/* end left column */}
|
||||
|
||||
{/* Right column — sticky fare card (desktop only) */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="sticky top-6">
|
||||
<FareSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button onClick={() => router.back()} className="btn-secondary flex-1">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary flex-1"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createBookingMutation.isError && (
|
||||
<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">
|
||||
⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred while creating your booking. Please try again.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* end grid */}
|
||||
</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} {(total / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
{createBookingMutation.isError && (
|
||||
<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.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => router.back()} 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={handleConfirm}
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary flex-1 py-2.5"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating...' : `Confirm ${isAuthenticated ? '' : '& pay'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,15 @@ import Image from "next/image";
|
||||
|
||||
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 seatLabel = bed.label || bed.seatNumber || bed.number || "?";
|
||||
const bedPosition = bed.bedPosition || "";
|
||||
@@ -364,11 +373,7 @@ export default function SeatsPage() {
|
||||
return {
|
||||
...p,
|
||||
outboundSeatId: selectedSeats[i],
|
||||
outboundSeatNumber:
|
||||
seatData?.number ||
|
||||
seatData?.label ||
|
||||
seatData?.seatNumber ||
|
||||
"",
|
||||
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
@@ -401,18 +406,13 @@ export default function SeatsPage() {
|
||||
return {
|
||||
...p,
|
||||
inboundSeatId: selectedSeats[i],
|
||||
inboundSeatNumber:
|
||||
seatData?.number ||
|
||||
seatData?.label ||
|
||||
seatData?.seatNumber ||
|
||||
"",
|
||||
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber:
|
||||
seatData?.number || seatData?.label || seatData?.seatNumber || "",
|
||||
seatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
@@ -456,8 +456,7 @@ export default function SeatsPage() {
|
||||
return {
|
||||
...p,
|
||||
seatId: autoSelectedSeats[i],
|
||||
seatNumber:
|
||||
seatData?.number || seatData?.label || seatData?.seatNumber || "",
|
||||
seatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface SelectedSchedule {
|
||||
duration: string;
|
||||
baseFareAdult: number;
|
||||
baseFareChild: number;
|
||||
displayCurrency: string;
|
||||
selectedSeatClass?: string;
|
||||
selectedSeatClassName?: string;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface Schedule {
|
||||
baseFareChild?: number;
|
||||
availableSeats?: number;
|
||||
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<{
|
||||
coachId: string;
|
||||
coachTypeName: string;
|
||||
@@ -46,8 +46,13 @@ export interface Schedule {
|
||||
classes: Array<{
|
||||
name: string;
|
||||
baseFareMinor: number;
|
||||
displayCurrency?: string;
|
||||
displayAmountMinor?: number;
|
||||
}>;
|
||||
}>;
|
||||
displayCurrency?: string;
|
||||
combinedMinFareMinor?: number;
|
||||
combinedMinFareDisplay?: number;
|
||||
serviceClass?: string;
|
||||
status?: string;
|
||||
hasAvailability?: boolean;
|
||||
|
||||
BIN
local-packages/tria-plc-iamapi-common-0.7.7.tgz
Normal file
BIN
local-packages/tria-plc-iamapi-common-0.7.7.tgz
Normal file
Binary file not shown.
BIN
local-packages/tria-plc-iamui-0.1.1.tgz
Normal file
BIN
local-packages/tria-plc-iamui-0.1.1.tgz
Normal file
Binary file not shown.
@@ -1,8 +1,5 @@
|
||||
import React, { useState, useMemo, useRef } from "react";
|
||||
import {
|
||||
IFileUploadSetting,
|
||||
IFileUploadField,
|
||||
} from "@edr/types/freight";
|
||||
import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight";
|
||||
import {
|
||||
UploadCloud,
|
||||
FileText,
|
||||
@@ -24,12 +21,20 @@ export interface SmartFileInputProps {
|
||||
onChange?: (value: Record<string, File | File[] | null>) => void;
|
||||
/** External form errors mapped by fileKey. */
|
||||
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?: boolean;
|
||||
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
|
||||
variant?: "default" | "minimal";
|
||||
/** Optional custom container CSS classes. */
|
||||
className?: string;
|
||||
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
/** Helper to format file sizes in bytes to a human-readable string. */
|
||||
@@ -45,23 +50,23 @@ function formatBytes(bytes: number, decimals = 2) {
|
||||
/** Render a suitable icon based on file extension. */
|
||||
function FileIcon({ name, className }: { name: string; className?: string }) {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
|
||||
if (ext === "pdf") {
|
||||
return <FileText className={cn("text-red-500", className)} />;
|
||||
}
|
||||
|
||||
|
||||
if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) {
|
||||
return <ImageIcon className={cn("text-blue-500", className)} />;
|
||||
}
|
||||
|
||||
|
||||
if (["csv", "xls", "xlsx"].includes(ext)) {
|
||||
return <FileText className={cn("text-emerald-500", className)} />;
|
||||
}
|
||||
|
||||
|
||||
if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) {
|
||||
return <File className={cn("text-amber-500", className)} />;
|
||||
}
|
||||
|
||||
|
||||
return <File className={cn("text-slate-400", className)} />;
|
||||
}
|
||||
|
||||
@@ -70,16 +75,20 @@ export function SmartFileInput({
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
uploadedKeys,
|
||||
disabled = false,
|
||||
variant = "default",
|
||||
className,
|
||||
containerClassName,
|
||||
}: SmartFileInputProps) {
|
||||
// Local state to manage files when the component is used in an uncontrolled manner
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>({});
|
||||
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Local validation errors
|
||||
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
||||
|
||||
|
||||
// Drag-and-drop state active per field
|
||||
const [dragActive, setDragActive] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -93,10 +102,13 @@ export function SmartFileInput({
|
||||
|
||||
// Create a map of fields for quick lookup
|
||||
const fieldsMap = useMemo(() => {
|
||||
return file.fields.reduce((acc, currentField) => {
|
||||
acc[currentField.fileKey] = currentField;
|
||||
return acc;
|
||||
}, {} as Record<string, IFileUploadField>);
|
||||
return file.fields.reduce(
|
||||
(acc, currentField) => {
|
||||
acc[currentField.fileKey] = currentField;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, IFileUploadField>,
|
||||
);
|
||||
}, [file.fields]);
|
||||
|
||||
// Resolve current files list for a field
|
||||
@@ -109,8 +121,8 @@ export function SmartFileInput({
|
||||
const handleFilesChange = (fieldKey: string, newFiles: File[]) => {
|
||||
const field = fieldsMap[fieldKey];
|
||||
if (!field) return;
|
||||
|
||||
const newValue = field.isMultiple ? newFiles : (newFiles[0] || null);
|
||||
|
||||
const newValue = field.isMultiple ? newFiles : newFiles[0] || null;
|
||||
|
||||
if (onChange) {
|
||||
const updatedValues = {
|
||||
@@ -129,10 +141,10 @@ export function SmartFileInput({
|
||||
const processFiles = (field: IFileUploadField, incomingFiles: File[]) => {
|
||||
const currentFiles = getFilesForField(field.fileKey);
|
||||
const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||
|
||||
|
||||
// Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf')
|
||||
const allowedExts = field.allowedExtensions.map((ext) =>
|
||||
ext.toLowerCase().replace(/^\./, "")
|
||||
ext.toLowerCase().replace(/^\./, ""),
|
||||
);
|
||||
|
||||
let validIncoming: File[] = [];
|
||||
@@ -140,13 +152,12 @@ export function SmartFileInput({
|
||||
|
||||
for (const fileObj of incomingFiles) {
|
||||
const ext = fileObj.name.split(".").pop()?.toLowerCase() || "";
|
||||
const isExtValid =
|
||||
allowedExts.length === 0 || allowedExts.includes(ext);
|
||||
const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext);
|
||||
const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024;
|
||||
|
||||
if (!isExtValid) {
|
||||
errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join(
|
||||
", "
|
||||
", ",
|
||||
)}`;
|
||||
break;
|
||||
}
|
||||
@@ -187,7 +198,11 @@ export function SmartFileInput({
|
||||
handleFilesChange(field.fileKey, newFilesList);
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent, fieldKey: string, active: boolean) => {
|
||||
const handleDrag = (
|
||||
e: React.DragEvent,
|
||||
fieldKey: string,
|
||||
active: boolean,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
@@ -208,7 +223,7 @@ export function SmartFileInput({
|
||||
|
||||
const handleFileSelect = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
field: IFileUploadField
|
||||
field: IFileUploadField,
|
||||
) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
@@ -246,179 +261,275 @@ export function SmartFileInput({
|
||||
{file.description}
|
||||
</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
|
||||
const acceptString = field.allowedExtensions
|
||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||
.join(",");
|
||||
<div className={cn("flex flex-col gap-6", containerClassName)}>
|
||||
{sortedFields.map((field) => {
|
||||
const currentFiles = getFilesForField(field.fileKey);
|
||||
const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||
const reachedLimit = currentFiles.length >= maxFiles;
|
||||
const fieldError =
|
||||
errors?.[field.fileKey] || localErrors[field.fileKey];
|
||||
const isDragOver = dragActive[field.fileKey];
|
||||
// Already uploaded server-side and nothing newly picked to replace it.
|
||||
const isUploaded =
|
||||
(uploadedKeys?.includes(field.fileKey) ?? false) &&
|
||||
currentFiles.length === 0;
|
||||
|
||||
return (
|
||||
<div key={field.id || field.fileKey} className="flex flex-col gap-2">
|
||||
{/* Field Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
|
||||
<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>
|
||||
// Format accepted files for the HTML input element
|
||||
const acceptString = field.allowedExtensions
|
||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||
.join(",");
|
||||
|
||||
{/* Help / Description Text */}
|
||||
{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"
|
||||
return (
|
||||
<div
|
||||
key={field.id || field.fileKey}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{/* Field Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-1">
|
||||
{field.fileLabel}
|
||||
{field.isRequired && (
|
||||
<span
|
||||
className="text-destructive font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
{isUploaded && (
|
||||
<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">
|
||||
<CheckCircle2 className="h-3 w-3" /> Already uploaded
|
||||
</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 */}
|
||||
{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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{isDragOver ? "Drop to replace" : "Document uploaded"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{isDragOver
|
||||
? "Release to replace the document on file."
|
||||
: "Saved to your application. Drag a new file here or click to replace it."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
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
|
||||
type="hidden"
|
||||
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
|
||||
value={fileObj.name}
|
||||
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>
|
||||
))}
|
||||
</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>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>
|
||||
{/* 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
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
276
pnpm-lock.yaml
generated
276
pnpm-lock.yaml
generated
@@ -10,7 +10,7 @@ importers:
|
||||
dependencies:
|
||||
'@mantine/dates':
|
||||
specifier: ^9.3.2
|
||||
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.4.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
devDependencies:
|
||||
'@commitlint/cli':
|
||||
specifier: ^19.5.0
|
||||
@@ -86,10 +86,10 @@ importers:
|
||||
version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/api-common':
|
||||
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
|
||||
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)
|
||||
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)
|
||||
'@tria-plc/iamapi-common':
|
||||
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz
|
||||
version: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)
|
||||
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz
|
||||
version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)
|
||||
amqp-connection-manager:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(amqplib@2.0.1)
|
||||
@@ -105,9 +105,15 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.14.1
|
||||
version: 0.14.4
|
||||
cross-env:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
dotenv-cli:
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
handlebars:
|
||||
specifier: ^4.7.9
|
||||
version: 4.7.9
|
||||
@@ -215,8 +221,8 @@ importers:
|
||||
specifier: ^5.100.11
|
||||
version: 5.101.0(react@19.2.6)
|
||||
'@tria-plc/iamui':
|
||||
specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz
|
||||
version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.17.0
|
||||
@@ -327,8 +333,8 @@ importers:
|
||||
specifier: ^5.59.0
|
||||
version: 5.101.0(react@19.2.6)
|
||||
'@tria-plc/iamui':
|
||||
specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz
|
||||
version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.17.0
|
||||
@@ -1476,6 +1482,9 @@ packages:
|
||||
'@emotion/weak-memoize@0.4.0':
|
||||
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
|
||||
|
||||
'@epic-web/invariant@1.0.0':
|
||||
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2034,10 +2043,10 @@ packages:
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
'@mantine/core@9.4.0':
|
||||
resolution: {integrity: sha512-BvTzaJ5Nut4ZiWLCcy1/cRXE8yjCgxsBhyAynY1pEg6XBw/fChEqSgbEB2dybOnkiAxvYt89TrnTrBBo9qJtmg==}
|
||||
'@mantine/core@9.3.2':
|
||||
resolution: {integrity: sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==}
|
||||
peerDependencies:
|
||||
'@mantine/hooks': 9.4.0
|
||||
'@mantine/hooks': 9.3.2
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
@@ -2050,11 +2059,11 @@ packages:
|
||||
react: ^18.x || ^19.x
|
||||
react-dom: ^18.x || ^19.x
|
||||
|
||||
'@mantine/dates@9.4.0':
|
||||
resolution: {integrity: sha512-15iCWxykutEVoFUPTq4z+An3YQZb7UH6Fwzb3stsVhTMLm6FyGLYSSB6PL+9sBV1TPllIJSm1IizY2SSTDGqQQ==}
|
||||
'@mantine/dates@9.3.2':
|
||||
resolution: {integrity: sha512-MzHrXGoOb3rCnHBlsI/BPAjC8K5tZ4f8pzg66tsTsupVCQBaBpHSVatQwNRJ6K2JC9pyCyTa9BL803C8AiWycA==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 9.4.0
|
||||
'@mantine/hooks': 9.4.0
|
||||
'@mantine/core': 9.3.2
|
||||
'@mantine/hooks': 9.3.2
|
||||
dayjs: '>=1.0.0'
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
@@ -2069,8 +2078,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
'@mantine/hooks@9.4.0':
|
||||
resolution: {integrity: sha512-SUkge8KlhyLoSSKhEHERx7jLotNu632kldhQPlLn5kbOuJixoEbU8fKcuwpXSsZTtzRgCCT2IkoN+rORhtZTHg==}
|
||||
'@mantine/hooks@9.3.2':
|
||||
resolution: {integrity: sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
@@ -4087,9 +4096,31 @@ packages:
|
||||
rxjs: ^7.8.0
|
||||
typeorm: ^0.3.0
|
||||
|
||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.0.3.tgz':
|
||||
resolution: {integrity: sha512-aZhIeNq2Uui7TUkG88G9QTBqdhaVB5kvqfd47HLgVE7qKkVyfjlm4nwlSWnHh5MO+CjgP9vRi6ILwMJx1ULO8g==, tarball: file:local-packages/tria-plc-iamui-0.0.3.tgz}
|
||||
version: 0.0.3
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz':
|
||||
resolution: {integrity: sha512-zW7dIEnoai9NSagFsjdH3CyU4dizhFnV9AkO0sOb8MJhFGaifFaMLyWkB9QMQvUIxFRziOcqw3vR0yjqLmTNPQ==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz}
|
||||
version: 0.7.7
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@nestjs/axios': ^4.0.0
|
||||
'@nestjs/common': ^11.0.0
|
||||
'@nestjs/core': ^11.0.0
|
||||
'@nestjs/jwt': ^11.0.0
|
||||
'@nestjs/microservices': ^11.0.0
|
||||
'@nestjs/passport': ^11.0.0
|
||||
'@nestjs/swagger': ^11.0.0
|
||||
'@nestjs/throttler': ^6.0.0
|
||||
'@nestjs/typeorm': ^11.0.0
|
||||
'@tria-plc/api-common': '*'
|
||||
axios: ^1.9.0
|
||||
class-transformer: ^0.5.1
|
||||
class-validator: ^0.14.1
|
||||
reflect-metadata: ^0.2.0
|
||||
rxjs: ^7.8.0
|
||||
typeorm: ^0.3.0
|
||||
|
||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz':
|
||||
resolution: {integrity: sha512-FTihoH0lIqKV/0s+FTJZokQw8xX3XztXIqT8eEYEZgDM2xyzVSCHY8pHCUxw0MQtiR8R97zxZJ/o192eWt+E/g==, tarball: file:local-packages/tria-plc-iamui-0.1.1.tgz}
|
||||
version: 0.1.1
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18.3.1 || ^19.0.0
|
||||
@@ -5734,6 +5765,11 @@ packages:
|
||||
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
|
||||
engines: {node: '>=18.x'}
|
||||
|
||||
cross-env@10.1.0:
|
||||
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -6069,6 +6105,10 @@ packages:
|
||||
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dotenv-cli@11.0.0:
|
||||
resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==}
|
||||
hasBin: true
|
||||
|
||||
dotenv-expand@12.0.3:
|
||||
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -11778,6 +11818,8 @@ snapshots:
|
||||
|
||||
'@emotion/weak-memoize@0.4.0': {}
|
||||
|
||||
'@epic-web/invariant@1.0.0': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
@@ -12440,10 +12482,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
'@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 9.4.0(react@19.2.6)
|
||||
'@mantine/hooks': 9.3.2(react@19.2.6)
|
||||
clsx: 2.1.1
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
@@ -12453,19 +12495,19 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
'@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 9.3.0(react@19.2.6)
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
clsx: 2.1.1
|
||||
dayjs: 1.11.21
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
'@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.4.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
'@mantine/dates@9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 9.4.0(react@19.2.6)
|
||||
'@mantine/core': 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 9.3.2(react@19.2.6)
|
||||
clsx: 2.1.1
|
||||
dayjs: 1.11.21
|
||||
react: 19.2.6
|
||||
@@ -12483,7 +12525,7 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
'@mantine/hooks@9.4.0(react@19.2.6)':
|
||||
'@mantine/hooks@9.3.2(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
@@ -15191,6 +15233,50 @@ snapshots:
|
||||
|
||||
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
||||
|
||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
change-case: 5.4.4
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.4
|
||||
dotenv: 16.6.1
|
||||
ethiopian-calendar-date-converter: 2.1.6
|
||||
ethiopian-date: 0.0.6
|
||||
exceljs: 4.4.0
|
||||
file-type: 21.3.4
|
||||
handlebars: 4.7.9
|
||||
handlebars-helpers: 0.10.0
|
||||
jmespath: 0.16.0
|
||||
jose: 5.10.0
|
||||
jsonwebtoken: 9.0.3
|
||||
libphonenumber-js: 1.13.6
|
||||
libreoffice-convert: 1.8.1
|
||||
nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
passport-jwt: 4.0.1
|
||||
qrcode: 1.5.4
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
style-object-to-css-string: 1.1.3
|
||||
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
uuid: 11.1.1
|
||||
xlsx: 0.18.5
|
||||
transitivePeerDependencies:
|
||||
- '@faker-js/faker'
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
@@ -15235,85 +15321,6 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
change-case: 5.4.4
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.4
|
||||
dotenv: 16.6.1
|
||||
ethiopian-calendar-date-converter: 2.1.6
|
||||
ethiopian-date: 0.0.6
|
||||
exceljs: 4.4.0
|
||||
file-type: 21.3.4
|
||||
handlebars: 4.7.9
|
||||
handlebars-helpers: 0.10.0
|
||||
jmespath: 0.16.0
|
||||
jose: 5.10.0
|
||||
jsonwebtoken: 9.0.3
|
||||
libphonenumber-js: 1.13.6
|
||||
libreoffice-convert: 1.8.1
|
||||
nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
passport-jwt: 4.0.1
|
||||
qrcode: 1.5.4
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
style-object-to-css-string: 1.1.3
|
||||
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
uuid: 11.1.1
|
||||
xlsx: 0.18.5
|
||||
transitivePeerDependencies:
|
||||
- '@faker-js/faker'
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)
|
||||
api-common: 1.2.2
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.4
|
||||
dotenv: 17.4.2
|
||||
ethiopian-date: 0.0.6
|
||||
file-type: 21.3.4
|
||||
jose: 5.10.0
|
||||
jsonwebtoken: 9.0.3
|
||||
libphonenumber-js: 1.13.6
|
||||
nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
passport-jwt: 4.0.1
|
||||
qrcode: 1.5.4
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
uuid: 11.1.1
|
||||
transitivePeerDependencies:
|
||||
- '@faker-js/faker'
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
@@ -15349,7 +15356,42 @@ snapshots:
|
||||
- '@faker-js/faker'
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7)':
|
||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)':
|
||||
dependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)
|
||||
api-common: 1.2.2
|
||||
argon2: 0.43.1
|
||||
axios: 1.17.0
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.4
|
||||
dotenv: 17.4.2
|
||||
ethiopian-date: 0.0.6
|
||||
file-type: 21.3.4
|
||||
jose: 5.10.0
|
||||
jsonwebtoken: 9.0.3
|
||||
libphonenumber-js: 1.13.6
|
||||
nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
passport-jwt: 4.0.1
|
||||
qrcode: 1.5.4
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
uuid: 11.1.1
|
||||
transitivePeerDependencies:
|
||||
- '@faker-js/faker'
|
||||
- supports-color
|
||||
|
||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)':
|
||||
dependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
||||
@@ -15357,7 +15399,7 @@ snapshots:
|
||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -17284,6 +17326,11 @@ snapshots:
|
||||
'@types/luxon': 3.7.1
|
||||
luxon: 3.7.2
|
||||
|
||||
cross-env@10.1.0:
|
||||
dependencies:
|
||||
'@epic-web/invariant': 1.0.0
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -17575,6 +17622,13 @@ snapshots:
|
||||
dependencies:
|
||||
is-obj: 2.0.0
|
||||
|
||||
dotenv-cli@11.0.0:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
dotenv: 17.4.2
|
||||
dotenv-expand: 12.0.3
|
||||
minimist: 1.2.8
|
||||
|
||||
dotenv-expand@12.0.3:
|
||||
dependencies:
|
||||
dotenv: 16.6.1
|
||||
@@ -20179,7 +20233,7 @@ snapshots:
|
||||
mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||
'@tanstack/match-sorter-utils': 8.19.4
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user