fix issues

This commit is contained in:
Marshal
2026-08-12 08:42:42 +00:00
39 changed files with 1767 additions and 458 deletions

View File

@@ -112,7 +112,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
import { RequestLogMiddleware } from "@edr/api-common";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
@@ -376,7 +376,9 @@ export class AppModule implements OnApplicationBootstrap {
}
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
// FIRST: opens the request log context every later middleware/guard/service
// writes into via logCtx(). Anything applied above it logs into the void.
consumer.apply(RequestLogMiddleware).forRoutes("*");
consumer
.apply(LoginAudienceMiddleware)
.forRoutes(

View File

@@ -0,0 +1,102 @@
import { Logger } from "@nestjs/common";
import {
RequestLogMiddleware,
getLogContext,
logCtx,
runWithLogContext,
} from "@edr/api-common";
describe("logCtx", () => {
it("is a no-op outside a request", () => {
expect(() => logCtx({ bookingId: "b1" })).not.toThrow();
expect(getLogContext()).toBeUndefined();
});
it("collects data points across the request and isolates concurrent ones", async () => {
const collect = async (id: string) =>
runWithLogContext({ requestId: id }, async () => {
logCtx({ bookingId: id });
await Promise.resolve();
logCtx({ from: "DRAFT", to: "SUBMITTED" }, { path: "booking.status" });
logCtx({ wagonId: "w1" }, { path: "wagons", mode: "push" });
logCtx({ wagonId: "w2" }, { path: "wagons", mode: "push" });
logCtx(1, { path: "smsSent", mode: "count" });
logCtx(1, { path: "smsSent", mode: "count" });
logCtx("PAID", { path: "payment.state", mode: "set" });
logCtx({ ignored: true }, (ctx) => {
ctx.custom = "yes";
});
return getLogContext();
});
const [a, b] = await Promise.all([collect("r1"), collect("r2")]);
expect(a).toEqual({
requestId: "r1",
bookingId: "r1",
booking: { status: { from: "DRAFT", to: "SUBMITTED" } },
wagons: [{ wagonId: "w1" }, { wagonId: "w2" }],
smsSent: 2,
payment: { state: "PAID" },
custom: "yes",
});
expect(b?.requestId).toBe("r2");
expect(b?.bookingId).toBe("r2");
});
});
describe("RequestLogMiddleware", () => {
it("emits one canonical JSON line carrying the collected context", () => {
const lines: string[] = [];
jest
.spyOn(Logger.prototype, "warn")
.mockImplementation((m) => lines.push(String(m)));
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
const listeners: Record<string, () => void> = {};
const req = {
method: "POST",
url: "/api/bookings/1/submit",
originalUrl: "/api/bookings/1/submit?dry=1",
baseUrl: "/api/bookings",
route: { path: "/:id/submit" },
headers: { "user-agent": "jest", "x-request-id": "req-42" },
ip: "10.0.0.1",
query: { dry: "1" },
user: { id: "u-7" },
};
const res = {
statusCode: 409,
writableEnded: true,
setHeader: jest.fn(),
on: (event: string, fn: () => void) => {
listeners[event] = fn;
},
};
new RequestLogMiddleware().use(req, res, () => {
logCtx({ bookingId: "b-1" });
logCtx("REJECTED", { path: "booking.outcome", mode: "set" });
});
listeners.finish();
listeners.close(); // aborts/close after finish must not double-log
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0])).toMatchObject({
type: "http_request",
requestId: "req-42",
method: "POST",
route: "/api/bookings/:id/submit",
url: "/api/bookings/1/submit?dry=1",
status: 409,
userId: "u-7",
ip: "10.0.0.1",
userAgent: "jest",
query: { dry: "1" },
bookingId: "b-1",
booking: { outcome: "REJECTED" },
});
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
jest.restoreAllMocks();
});
});

View File

@@ -1,21 +0,0 @@
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
this.logger.log(
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
);
});
next();
}
}

View File

@@ -256,11 +256,15 @@ export class CompaniesService {
},
];
/** The nationality-based document setting code for a company. */
private documentSettingCodeFor(
nationality: CompanyNationality | null | undefined,
): string {
return nationality === CompanyNationality.Foreign
/**
* The document setting code for a company: one of three mutually exclusive
* sets. A co-operative union or farm resolves to its own set regardless of
* nationality — it holds no business licence, so it owes a different list of
* papers rather than the nationality list plus extras.
*/
private documentSettingCodeFor(company: Company): string {
if (isCooperative(company)) return COOPERATIVE_ONBOARDING_CODE;
return company.nationality === CompanyNationality.Foreign
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
@@ -387,13 +391,16 @@ export class CompaniesService {
const current = needsCompany
? await this.companiesRepo.findById(companyId)
: null;
this.assertRolesAllowedForCooperative(
cooperative ?? isCooperative(current),
roles,
);
const isCoop = cooperative ?? isCooperative(current);
this.assertRolesAllowedForCooperative(isCoop, roles);
this.assertNationalityAllowedForCooperative(isCoop, nationality);
await this.syncCompanyProfiles(companyId, companyType, roles);
const updates: Partial<Company> = {};
if (nationality) updates.nationality = nationality;
// Ticking the box on a draft that was saved as foreign has to correct the
// stored nationality too, or the company keeps resolving to the foreign
// document set.
if (isCoop) updates.nationality = CompanyNationality.Ethiopian;
if (cooperative !== undefined) {
updates.attributes = {
...(current?.attributes ?? {}),
@@ -407,6 +414,7 @@ export class CompaniesService {
}
this.assertRolesAllowedForCooperative(cooperative === true, roles);
this.assertNationalityAllowedForCooperative(cooperative === true, nationality);
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
@@ -458,6 +466,24 @@ export class CompaniesService {
}
}
/**
* A co-operative union or farm is registered in Ethiopia by the co-operative
* promotion agency, so it is always an Ethiopian company — "foreign" is not a
* combination that exists, and allowing it would resolve the company to a
* document set built around an investment licence it cannot hold.
*/
private assertNationalityAllowedForCooperative(
cooperative: boolean,
nationality: CompanyNationality | undefined,
): void {
if (!cooperative) return;
if (nationality === CompanyNationality.Foreign) {
throw new BadRequestException(
"A co-operative union or farm is registered in Ethiopia — it cannot onboard as a foreign company.",
);
}
}
/**
* Reconcile the company's operational profiles with the roles the user has
* selected: create the missing ones, drop the ones they deselected.
@@ -1252,7 +1278,7 @@ export class CompaniesService {
uploaded: FileRecord[],
): Promise<CompanyRevisionChange[]> {
const setting = await this.fileUploadSettingsService
.getByCode(this.documentSettingCodeFor(company.nationality))
.getByCode(this.documentSettingCodeFor(company))
.catch(() => null);
const fields = setting?.fields ?? [];
const singleFileCodes = new Set(
@@ -2036,35 +2062,20 @@ export class CompaniesService {
.filter((f) => !f.get(company))
.map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded. A
// co-operative adds its own set on top: it provides everything its
// nationality demands, plus the papers standing in for the business licence
// it does not hold.
// 2. Company documents + which are already uploaded. One set applies: the
// company's nationality set, or the co-operative set in its place — a union
// or farm holds no business licence, so it owes its own list rather than the
// nationality list plus extras.
const cooperative = isCooperative(company);
const documentSettingCode = this.documentSettingCodeFor(
company.nationality,
);
const [setting, coopSetting, uploadedFiles] = await Promise.all([
const documentSettingCode = this.documentSettingCodeFor(company);
const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
.getByCode(documentSettingCode)
.catch(() => null),
cooperative
? this.fileUploadSettingsService
.getByCode(COOPERATIVE_ONBOARDING_CODE)
.catch(() => null)
: Promise.resolve(null),
this.filesService.findByResource(company.id, "companies"),
]);
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
// The co-op set is admin-managed and could name a fileKey the nationality
// set already carries; the nationality field wins so the same slot is never
// rendered (or required) twice.
const baseFields = setting?.fields ?? [];
const baseKeys = new Set(baseFields.map((f) => f.fileKey));
const documents = [
...baseFields,
...(coopSetting?.fields ?? []).filter((f) => !baseKeys.has(f.fileKey)),
]
const documents = (setting?.fields ?? [])
.slice()
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((f) => ({
@@ -2203,9 +2214,6 @@ export class CompaniesService {
return new OnboardingRequirementsResponseDto({
documentSettingCode,
cooperativeDocumentSettingCode: cooperative
? COOPERATIVE_ONBOARDING_CODE
: null,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
cooperative,
companyInfo: {

View File

@@ -66,15 +66,11 @@ export interface OnboardingPoaState {
}
export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */
documentSettingCode: string;
/**
* The co-operative document set, merged on top of the nationality one — null
* for every other company. `documents` below already carries the merged
* result; this is only so the portal can fetch the same extra fields when it
* renders the pickers from the file-settings endpoint.
* Resolved document setting code the docs were drawn from: the company's
* nationality set, or the co-operative set in its place.
*/
cooperativeDocumentSettingCode: string | null;
documentSettingCode: string;
nationality: string;
/**
* The company trades as a co-operative: no business licence, so no eTrade
@@ -118,7 +114,6 @@ export class OnboardingRequirementsResponseDto {
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
this.documentSettingCode = init.documentSettingCode;
this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode;
this.nationality = init.nationality;
this.cooperative = init.cooperative;
this.companyInfo = init.companyInfo;

View File

@@ -63,6 +63,21 @@ describe('ETradeService business selection', () => {
expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']);
});
it('survives the null entries eTrade puts in SubGroups', () => {
const { service } = build();
const info = companyInfo();
(info.Businesses[0] as any).SubGroups = [
null,
{ Code: 66331, Description: 'Export trade in minerals' },
{ Code: 1, Description: null },
];
const data = service.extractRegistrationData(
{ LicenceNumber: 'x' } as ETradeBusinessInfo,
info,
);
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
});
it('lists every licence for the picker, code prefixes stripped', () => {
const { service } = build();
const data = service.extractRegistrationData(

View File

@@ -142,7 +142,8 @@ export class ETradeService {
tradeName: b.TradesName?.trim() || "",
activity: (b.SubGroups ?? [])
// Some descriptions repeat the code inline ("(65611)Import trade …").
.map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim())
// eTrade also puts null entries in this array, so every hop is optional.
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
.filter(Boolean)
.join(", "),
renewedTo: b.RenewedTo || "",

View File

@@ -17,7 +17,6 @@ import {
} from "./interfaces/file-upload-settings.repository.interface";
import {
COMPANY_ONBOARDING_CODE_PREFIX,
COOPERATIVE_ONBOARDING_CODE,
POA_DELEGATION_FILE_KEY,
poaDelegationField,
} from "./poa-delegation.constants";
@@ -57,10 +56,6 @@ export class FileUploadSettingsService {
*/
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
// The co-operative set is merged ON TOP of a nationality set that already
// carries the paper; injecting it here too would hand the portal the same
// slot twice.
if (setting.code === COOPERATIVE_ONBOARDING_CODE) return setting;
const fields = setting.fields ?? [];
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;

View File

@@ -27,10 +27,11 @@ export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
/**
* The co-operative onboarding set. Unlike the nationality sets it is ADDITIVE —
* merged on top of the company's `_ethiopian`/`_foreign` set rather than
* replacing it — which is why the delegation paper is not injected into it: the
* set it is merged onto already carries one.
* The co-operative onboarding set — the third alternative to `_ethiopian` and
* `_foreign`, not an addition to them: a union or farm resolves to this set
* INSTEAD of its nationality's, because it holds no business licence and so
* owes a different list of papers. The delegation paper is injected into it
* like any other company onboarding set.
*/
export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`;

View File

@@ -155,16 +155,27 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
// ];
/**
* Extra documents a co-operative union or farm provides, merged on top of its
* nationality set. It has a TIN but no business licence, so the papers that
* evidence the co-operative itself stand in for the trade licence every other
* company uploads.
* Documents required from a co-operative union or farm — the third alternative
* to the two nationality sets, not an addition to them. A co-op is always
* registered in Ethiopia and has a TIN but no business licence, so its
* registration certificate stands in for the commercial registration every
* other Ethiopian company uploads.
*
* Only the registration certificate is seeded, and the set is admin-managed
* like every other onboarding set — what these members must actually produce
* is a backoffice decision, edited in the file-settings editor.
* Admin-managed like every other onboarding set: what these members must
* actually produce is a backoffice decision, edited in the file-settings editor.
*/
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 50,
displayOrder: 1,
},
{
fileKey: "cooperative_registration_certificate",
fileLabel: "Co-operative Union / Farm Registration Certificate",
@@ -175,8 +186,20 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 50,
displayOrder: 1,
displayOrder: 2,
},
{
fileKey: "national_id",
fileLabel: "National ID",
helpText: "Verified against the National ID API during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 50,
displayOrder: 3,
},
poaDelegationDefault(4),
];
interface OnboardingDocumentSetting {
@@ -201,11 +224,11 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
entity: "customer",
fields: FOREIGN_ONBOARDING_FIELDS,
},
// Additive, not a nationality of its own: a union or farm still uploads
// everything its nationality set demands, and these on top.
// The third set: a union or farm resolves here INSTEAD of a nationality set
// (it is always Ethiopian, and holds no business licence).
{
code: "company_onboarding_documents_cooperative",
label: "Co-operative union / farm onboarding documents (additional)",
label: "Co-operative union / farm onboarding documents",
entity: "customer",
fields: COOPERATIVE_ONBOARDING_FIELDS,
},

View File

@@ -101,7 +101,8 @@ export function mapEtradeBusinessLicenses(
const tradeName = String(business.TradesName ?? "").trim();
const tradeNameAmh = String(business.TradeNameAmh ?? "").trim();
const activities = (business.SubGroups ?? [])
.map((group) => String(group.Description ?? "").trim())
// eTrade returns null entries in this array, not just a null array.
.map((group) => String(group?.Description ?? "").trim())
.filter(Boolean);
const displayTradeName =

View File

@@ -39,6 +39,7 @@ import type {
} from "@/services/companies.service";
import { companiesService } from "@/services/companies.service";
import type { UpdateProfilePayload } from "@/types/profile";
import { documentSettingCode } from "@/utils/documentSettingCode";
import { extractApiError } from "@/utils/result";
/** Form steps rendered by CompanyProfileForm. */
@@ -116,13 +117,6 @@ function companyTypeForRoles(_roles: string[]): string {
return "customer";
}
/** Document upload setting code per company nationality. */
function documentSettingCode(nationality: CompanyNationality): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
/**
* First-run onboarding wizard with a "draft-first" flow: picking the role(s)
* immediately creates a draft company + profile on the backend, so every
@@ -168,11 +162,15 @@ export default function OnboardingWizardDialog({
const [cooperative, setCooperative] = useState<boolean>(
company?.company?.attributes?.cooperative === true,
);
// Ticking the box drops a role the company can no longer hold, rather than
// letting Continue fail on a selection the API refuses.
// Ticking the box drops the selections the company can no longer hold, rather
// than letting Continue fail on ones the API refuses: a co-op cannot forward
// freight, and is registered in Ethiopia so it is never foreign.
const handleCooperativeChange = useCallback((checked: boolean) => {
setCooperative(checked);
if (checked) setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
if (checked) {
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
setNationality((prev) => (prev === "foreign" ? "ethiopian" : prev));
}
}, []);
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
@@ -419,7 +417,7 @@ export default function OnboardingWizardDialog({
// after the draft — and thus the requirements — exist).
const resolvedDocumentSettingCode =
requirementsQuery.data?.documentSettingCode ??
documentSettingCode(effectiveNationality);
documentSettingCode(effectiveNationality, cooperative);
// Server-confirmed document state, used both to badge already-uploaded fields
// and to keep a refreshed resume from over-shooting the documents step.
@@ -480,8 +478,6 @@ export default function OnboardingWizardDialog({
// startOnboarding has persisted it, and the form's whole company step
// branches on it.
cooperative: requirementsQuery.data?.cooperative ?? cooperative,
extraDocumentSettingCode:
requirementsQuery.data?.cooperativeDocumentSettingCode ?? null,
// A freight forwarder cannot answer the power-of-attorney question — the
// API forces "yes" — so the step offers no way to change it.
declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
@@ -551,6 +547,10 @@ export default function OnboardingWizardDialog({
value={nationality}
onChange={setNationality}
embedded
// A co-op is registered in Ethiopia by the co-operative
// promotion agency — foreign is not on offer rather than
// refused later.
excludeForeign={cooperative}
/>
{/* A co-operative union or farm registers on a TIN alone. It
changes what the next step asks for (typed registration, no

View File

@@ -66,7 +66,6 @@ export default function CompanyProfileForm({
onIdentityChange,
cooperative = false,
declarationLocked = false,
extraDocumentSettingCode,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
@@ -115,8 +114,8 @@ export default function CompanyProfileForm({
/**
* The company trades as a co-operative: a TIN but no business licence, so the
* eTrade lookup is replaced by typed registration details, the per-role
* licence upload is not owed, and its own document set applies on top of the
* nationality one.
* licence upload is not owed, and its own document set applies instead of the
* nationality one (resolved by the caller into `documentSettingCode`).
*/
cooperative?: boolean;
/**
@@ -124,8 +123,6 @@ export default function CompanyProfileForm({
* answer is forced to "yes" and cannot be changed here.
*/
declarationLocked?: boolean;
/** Additional document set merged in (the co-operative one), if any. */
extraDocumentSettingCode?: string | null;
}) {
// A Fayda claim carries the phone as the national registry holds it, which is
// often a local number the form's E.164 validation (and the API's
@@ -206,36 +203,15 @@ export default function CompanyProfileForm({
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: nationalitySetting, isLoading: loadingDocuments } = useQuery(
// One set applies: the company's nationality set, or the co-operative one in
// its place — the caller resolves which (the API resolves the same way when
// it decides what is outstanding).
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
// A co-operative's own documents come as a second, additive set — it uploads
// everything its nationality demands, plus the papers standing in for the
// business licence it does not hold. The API merges the same two sets when it
// decides what is outstanding.
const { data: extraSetting } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: extraDocumentSettingCode ?? "" },
enabled: Boolean(extraDocumentSettingCode),
refetchOnMount: false,
}),
);
const uploadSetting = useMemo(() => {
if (!nationalitySetting) return nationalitySetting;
if (!extraSetting?.fields?.length) return nationalitySetting;
// Nationality wins a fileKey collision, so a slot is never rendered twice.
const seen = new Set(nationalitySetting.fields.map((f) => f.fileKey));
return {
...nationalitySetting,
fields: [
...nationalitySetting.fields,
...extraSetting.fields.filter((f) => !seen.has(f.fileKey)),
],
};
}, [nationalitySetting, extraSetting]);
// Which fields the current step renders an input for and therefore requires.
// Filled in further down (it depends on values this form owns), and read at

View File

@@ -6,6 +6,7 @@ import { type UseFormReturn } from "react-hook-form";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { documentSettingCode } from "@/utils/documentSettingCode";
import {
operationToProfileType,
type BookingDocuments,
@@ -20,13 +21,6 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
/** Onboarding document setting code for the company's nationality. */
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
function formatSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
@@ -48,14 +42,17 @@ function formatSize(bytes?: number): string {
export function StepDocuments({ form }: { form: BookingForm }) {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
const company = auth.company?.company;
const nationality = company?.nationality as string | null | undefined;
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
input: {
code: documentSettingCode(
nationality,
company?.attributes?.cooperative === true,
),
},
}),
);

View File

@@ -2,15 +2,9 @@ import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { documentSettingCode } from "@/utils/documentSettingCode";
/** Onboarding document setting code for the company's nationality. */
export function documentSettingCode(
nationality: string | null | undefined,
): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
export { documentSettingCode };
/**
* Fetches the FileUploadSetting that describes the documents a booking requires
@@ -22,14 +16,17 @@ export function documentSettingCode(
*/
export function useBookingDocumentSetting() {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
const company = auth.company?.company;
const nationality = company?.nationality as string | null | undefined;
return useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
input: {
code: documentSettingCode(
nationality,
company?.attributes?.cooperative === true,
),
},
}),
);
}

View File

@@ -7,6 +7,7 @@ import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { documentSettingCode } from "@/utils/documentSettingCode";
import type { CompanyDocument } from "@/services/companies.service";
import { downloadStoredFile } from "@/services/files.service";
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
@@ -15,13 +16,6 @@ import { BORDER, GREEN, INK } from "../contract-ui";
type DocumentsValue = Record<string, File | File[] | null>;
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
/** Onboarding document setting code for the company's nationality. */
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
function hasFile(value: File | File[] | null | undefined): boolean {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : true;
@@ -95,13 +89,16 @@ export function ContractDocsEditor({
}) {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
const company = auth.company?.company;
const nationality = company?.nationality as string | null | undefined;
const settingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
input: {
code: documentSettingCode(
nationality,
company?.attributes?.cooperative === true,
),
},
}),
);

View File

@@ -7,6 +7,7 @@ import { type UseFormReturn } from "react-hook-form";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { documentSettingCode } from "@/utils/documentSettingCode";
import {
type ContractDocuments,
type ContractFormInputValues,
@@ -21,12 +22,6 @@ type ContractForm = UseFormReturn<
ContractFormValues
>;
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
function formatSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
@@ -61,14 +56,17 @@ export function StepDocuments({
const auth = useAuth();
const [errors, setErrors] = useState<Record<string, string>>({});
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
const company = auth.company?.company;
const nationality = company?.nationality as string | null | undefined;
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
input: {
code: documentSettingCode(
nationality,
company?.attributes?.cooperative === true,
),
},
}),
);

View File

@@ -9,6 +9,8 @@ interface NationalitySelectProps {
onChange: (next: CompanyNationality) => void;
/** Render only the option grid — the wizard supplies its own header/card. */
embedded?: boolean;
/** Hide the foreign option (a co-operative union or farm is always Ethiopian). */
excludeForeign?: boolean;
}
/**
@@ -21,9 +23,10 @@ export default function NationalitySelect({
value,
onChange,
embedded = false,
excludeForeign = false,
}: NationalitySelectProps) {
const grid = (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<SimpleGrid cols={{ base: 1, sm: excludeForeign ? 1 : 2 }} spacing="md">
<RoleCard
label="Ethiopian Company"
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial registration and national ID."
@@ -31,13 +34,15 @@ export default function NationalitySelect({
selected={value === "ethiopian"}
onClick={() => onChange("ethiopian")}
/>
<RoleCard
label="Foreign Company"
description="Registered abroad. You'll provide a passport and investment license."
icon={<Globe2 size={22} />}
selected={value === "foreign"}
onClick={() => onChange("foreign")}
/>
{!excludeForeign && (
<RoleCard
label="Foreign Company"
description="Registered abroad. You'll provide a passport and investment license."
icon={<Globe2 size={22} />}
selected={value === "foreign"}
onClick={() => onChange("foreign")}
/>
)}
</SimpleGrid>
);

View File

@@ -6,6 +6,7 @@ import {
type LicenseFileStatus,
} from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings";
import { documentSettingCode } from "@/utils/documentSettingCode";
import type { ProfileResponse } from "@/types/profile";
import {
SmartFileInput,
@@ -57,14 +58,8 @@ interface TabDocumentsProps {
onContinue?: () => void;
}
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
/**
* The DARS delegation paper ships in the same nationality document set, but it is
* The DARS delegation paper ships in the same document set, but it is
* edited on the Power of Attorney tab (where it is staged for review alongside
* the PoA details), so it is excluded from this tab's uploader.
*/
@@ -83,7 +78,9 @@ export default function TabDocuments({
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(profile.nationality) },
input: {
code: documentSettingCode(profile.nationality, profile.cooperative),
},
}),
);

View File

@@ -183,14 +183,8 @@ export interface OnboardingPoaState {
* outstanding, so the client never hardcodes required fields or document sets.
*/
export interface OnboardingRequirements {
/** The set the docs came from: the nationality one, or the co-operative one. */
documentSettingCode: string;
/**
* Extra document set merged on top of the nationality one for a co-operative,
* null otherwise. `documents` already carries the merged list; this is only
* so the pickers, which render from the file-settings endpoint, can fetch the
* same extra fields.
*/
cooperativeDocumentSettingCode: string | null;
nationality: string;
/** No business licence: registration typed by hand, no eTrade lookup. */
cooperative: boolean;

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { documentSettingCode } from "./documentSettingCode";
describe("documentSettingCode", () => {
it("resolves the nationality set", () => {
expect(documentSettingCode("ethiopian")).toBe(
"company_onboarding_documents_ethiopian",
);
expect(documentSettingCode("foreign")).toBe(
"company_onboarding_documents_foreign",
);
expect(documentSettingCode(null)).toBe(
"company_onboarding_documents_ethiopian",
);
});
it("replaces the nationality set for a co-operative", () => {
expect(documentSettingCode("ethiopian", true)).toBe(
"company_onboarding_documents_cooperative",
);
// A co-op is never foreign, but a stale flag must not fall back to the
// foreign set — the co-op answer wins.
expect(documentSettingCode("foreign", true)).toBe(
"company_onboarding_documents_cooperative",
);
});
});

View File

@@ -0,0 +1,18 @@
/**
* The company document set a company resolves to — one of three, never a
* combination: a co-operative union or farm uploads its own papers INSTEAD of
* its nationality's (it holds no business licence), and is always Ethiopian.
*
* Mirrors `CompaniesService.documentSettingCodeFor` on the API; prefer the
* server-resolved `documentSettingCode` from onboarding requirements where one
* is available, and use this where only the company is at hand.
*/
export function documentSettingCode(
nationality: string | null | undefined,
cooperative?: boolean | null,
): string {
if (cooperative) return "company_onboarding_documents_cooperative";
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}

View File

@@ -8,7 +8,7 @@ import {
ApiProduces,
} from "@nestjs/swagger";
import { ReportsService } from "./reports.service";
import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto";
import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReportDto } from "./reports.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -83,6 +83,35 @@ export class ReportsController {
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
}
// ── Finance Summary ──────────────────────────────────────────────────────
@Get("finance")
@ApiOperation({
summary: "Finance summary — revenue by period, origin/destination segment, and payment method",
description:
"Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " +
"destination station pair, and payment method. Filter by originStationId and/or destinationStationId " +
"independently to query any station-pair segment (A→B, A→D, B→C), not just a whole predefined route. " +
"Returns per-bucket rows plus roll-ups by period, segment, and method for charting.",
})
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
return this.service.getFinanceSummary(query);
}
@Get("finance/export")
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
@ApiProduces("text/csv")
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
async exportFinanceSummary(@Query() query: FinanceSummaryQueryDto, @Res() res: Response): Promise<void> {
const csv = await this.service.exportFinanceSummaryCsv(query);
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="finance-summary-${new Date().toISOString().split("T")[0]}.csv"`,
);
res.send(csv);
}
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
@Get("blocked-seats-revenue-loss")

View File

@@ -1,6 +1,7 @@
import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentMethodType } from '@prisma/client';
import { SeatBlockReasonCategory } from '../seats/seats.dto';
export enum ReportType {
@@ -103,3 +104,31 @@ export class BlockedSeatsRevenueLossQueryDto {
})
@IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy;
}
// ── Finance Summary ──────────────────────────────────────────────────────────
export enum FinanceGranularity {
DAILY = 'daily',
WEEKLY = 'weekly',
MONTHLY = 'monthly',
}
export class FinanceSummaryQueryDto {
@ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' })
@IsDateString() dateFrom: string;
@ApiProperty({ example: '2026-07-31', description: 'End of the window, inclusive, matched on PaymentIntent.paidAt.' })
@IsDateString() dateTo: string;
@ApiPropertyOptional({ enum: FinanceGranularity, default: FinanceGranularity.DAILY })
@IsOptional() @IsEnum(FinanceGranularity) granularity?: FinanceGranularity;
@ApiPropertyOptional({ description: 'Restrict to bookings departing from this station.' })
@IsOptional() @IsString() originStationId?: string;
@ApiPropertyOptional({ description: 'Restrict to bookings arriving at this station.' })
@IsOptional() @IsString() destinationStationId?: string;
@ApiPropertyOptional({ enum: PaymentMethodType, description: 'Restrict to payments made with this method.' })
@IsOptional() @IsEnum(PaymentMethodType) method?: PaymentMethodType;
}

View File

@@ -10,6 +10,8 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
import {
BlockedSeatsLossSortBy,
BlockedSeatsRevenueLossQueryDto,
FinanceGranularity,
FinanceSummaryQueryDto,
GenerateReportDto,
ReportType,
} from "./reports.dto";
@@ -84,6 +86,33 @@ function toCsvCell(value: string | number): string {
return `"${String(value).replace(/"/g, '""')}"`;
}
/**
* Buckets a paid-at timestamp into the requested reporting period, keyed so buckets sort
* chronologically as plain strings. Weekly buckets are labelled by their Monday (UTC).
*/
function periodKeyFor(date: Date, granularity: FinanceGranularity): string {
if (granularity === FinanceGranularity.MONTHLY) {
return date.toISOString().slice(0, 7);
}
if (granularity === FinanceGranularity.WEEKLY) {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const isoDay = d.getUTCDay() || 7; // Monday=1 .. Sunday=7
d.setUTCDate(d.getUTCDate() - (isoDay - 1));
return d.toISOString().split("T")[0];
}
return date.toISOString().split("T")[0];
}
export interface FinanceBucket {
period: string;
originStationId: string;
destinationStationId: string;
segmentLabel: string;
method: string;
bookingCount: number;
revenueEtbMinor: number;
}
@Injectable()
export class ReportsService {
private readonly logger = new Logger(ReportsService.name);
@@ -1051,6 +1080,161 @@ export class ReportsService {
return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows };
}
// ── Finance Summary ────────────────────────────────────────────────────────
/**
* Revenue collected in the window, grouped by reporting period (day/week/month), origin →
* destination station pair, and payment method — the shape finance reconciles against
* provider settlement statements.
*
* Grouped by the booking's own origin/destination, not the parent Route — a route like
* "Sebeta - Dire Dawa" has intermediate stops, and a passenger may have booked any
* sub-segment of it (e.g. Lebu → Adama). Filtering by station lets finance ask about any
* A→B pair, not just whole routes.
*
* Bucketed on `PaymentIntent.paidAt` (cash actually received), not `Booking.createdAt`,
* so a booking made in one period but paid in another lands in the period it was paid.
*/
async getFinanceSummary(query: FinanceSummaryQueryDto) {
const dateFrom = new Date(query.dateFrom + "T00:00:00.000Z");
const dateTo = new Date(query.dateTo + "T23:59:59.999Z");
const granularity = query.granularity ?? FinanceGranularity.DAILY;
const rateRows = await this.prisma.currencyExchangeRate.findMany({
where: { toCurrency: "ETB" as any },
orderBy: { effectiveDate: "desc" },
});
const rateToEtb = new Map<string, number>();
for (const r of rateRows) {
if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate));
}
const toEtbMinor = (minor: number, currency: string): number => {
if (currency === "ETB") return minor;
const rate = rateToEtb.get(currency);
return rate ? Math.round(minor * rate) : minor;
};
const bookings = await this.prisma.booking.findMany({
where: {
paymentIntent: {
paidAt: { gte: dateFrom, lte: dateTo },
...(query.method ? { method: query.method } : {}),
},
...(query.originStationId ? { originStationId: query.originStationId } : {}),
...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}),
},
select: {
totalMinor: true,
currency: true,
originStationId: true,
destinationStationId: true,
schedule: { select: { originStationId: true, destinationStationId: true } },
paymentIntent: { select: { paidAt: true, method: true } },
},
});
// Booking.originStationId/destinationStationId are set on every create path (guest and
// authenticated booking both pass them from the DTO); the schedule's own endpoints are
// only a fallback for the rare legacy row that predates those columns.
const stationIds = new Set<string>();
for (const b of bookings) {
const origin = b.originStationId ?? b.schedule.originStationId;
const destination = b.destinationStationId ?? b.schedule.destinationStationId;
if (origin) stationIds.add(origin);
if (destination) stationIds.add(destination);
}
const stations = stationIds.size > 0
? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, select: { id: true, name: true } })
: [];
const stationName = new Map(stations.map((s) => [s.id, s.name]));
const buckets = new Map<string, FinanceBucket>();
const bucketFor = (
period: string,
originStationId: string,
destinationStationId: string,
segmentLabel: string,
method: string,
): FinanceBucket => {
const key = `${period}|${originStationId}|${destinationStationId}|${method}`;
let bucket = buckets.get(key);
if (!bucket) {
bucket = { period, originStationId, destinationStationId, segmentLabel, method, bookingCount: 0, revenueEtbMinor: 0 };
buckets.set(key, bucket);
}
return bucket;
};
for (const b of bookings) {
const pi = b.paymentIntent!;
const period = periodKeyFor(pi.paidAt!, granularity);
const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN";
const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN";
const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"}${stationName.get(destinationStationId) ?? "Unknown"}`;
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method);
bucket.bookingCount += 1;
bucket.revenueEtbMinor += toEtbMinor(b.totalMinor, b.currency);
}
const rows = [...buckets.values()].sort((a, b) =>
a.period === b.period
? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method)
: a.period.localeCompare(b.period),
);
const totals = rows.reduce(
(acc, r) => {
acc.bookingCount += r.bookingCount;
acc.revenueEtbMinor += r.revenueEtbMinor;
return acc;
},
{ bookingCount: 0, revenueEtbMinor: 0 },
);
const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string) => {
const map = new Map<string, { key: string; label: string; revenueEtbMinor: number; bookingCount: number }>();
for (const r of rows) {
const key = keyOf(r);
let entry = map.get(key);
if (!entry) {
entry = { key, label: labelOf(r), revenueEtbMinor: 0, bookingCount: 0 };
map.set(key, entry);
}
entry.revenueEtbMinor += r.revenueEtbMinor;
entry.bookingCount += r.bookingCount;
}
return [...map.values()].sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor);
};
return {
granularity,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
currency: "ETB",
totals,
byPeriod: rollUp((r) => r.period, (r) => r.period),
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}`, (r) => r.segmentLabel),
byMethod: rollUp((r) => r.method, (r) => r.method),
rows,
};
}
/** CSV of the finance summary, one row per period + origin/destination segment + payment method. */
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
const report = await this.getFinanceSummary(query);
const headers = ["Period", "Origin → Destination", "Payment Method", "Bookings", "Revenue (ETB)"];
const rows = report.rows.map((r) => [
r.period,
r.segmentLabel,
r.method,
r.bookingCount,
(r.revenueEtbMinor / 100).toFixed(2),
]);
return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n");
}
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
search?: string;
seatClass?: string;

View File

@@ -16,6 +16,8 @@
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^3.0.0",
"exceljs": "^4.4.0",
"html-to-image": "^1.11.11",
"lucide-react": "^0.446.0",
"next": "^14.2.0",
"react": "^18.3.1",

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,521 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Banknote, BookOpen, FileSpreadsheet, Receipt } from 'lucide-react';
import {
Bar, BarChart, CartesianGrid, Line, LineChart,
ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis,
} from 'recharts';
import { toPng } from 'html-to-image';
import { apiClient } from '@/lib/api-client';
import { financeApi, type FinanceGranularity, type FinanceSummaryFilters } from '@/lib/api/finance';
import { buildFinanceWorkbook, type ChartImage } from '@/lib/export/finance-workbook';
import ActionButton from '@/components/ui/ActionButton';
import Pagination from '@/components/ui/Pagination';
import Skeleton from '@/components/ui/Skeleton';
import { usePagination } from '@/lib/use-pagination';
import { formatCurrency } from '@/lib/utils';
import { categoricalColor, getChartPalette } from '@/lib/chart-palette';
import { useTheme } from '@/lib/theme-store';
interface StationOption {
id: string;
name: string;
code: string;
}
// Fixed order so a method keeps its colour/slot when the method filter narrows the set.
const PAYMENT_METHOD_ORDER = ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'CARD', 'WALLET', 'DMONEY', 'CAC_BANK', 'CBE_BILL'] as const;
const PAYMENT_METHOD_LABELS: Record<string, string> = {
TELEBIRR: 'Telebirr',
CBE_BIRR: 'CBE Birr',
EBIRR: 'eBirr',
WAAFI: 'Waafi',
CARD: 'Card',
WALLET: 'Wallet',
DMONEY: 'DMoney',
CAC_BANK: 'CAC Bank',
CBE_BILL: 'CBE Bill',
};
function methodLabel(method: string): string {
return PAYMENT_METHOD_LABELS[method] ?? method;
}
function periodLabel(period: string, granularity: FinanceGranularity): string {
if (granularity === 'monthly') {
return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
}
return new Date(`${period}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
const TABLE_PAGE_SIZE = 25;
/** Mirrors the loaded layout's shape (KPI tiles, two charts, method breakdown, detail table) so nothing jumps when data arrives. */
function FinanceReportSkeleton() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="card flex flex-col gap-3">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-7 w-7 rounded-lg" />
</div>
<Skeleton className="h-7 w-32" />
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="card">
<Skeleton className="h-4 w-40 mb-4" />
<Skeleton className="h-[280px] w-full" />
</div>
))}
</div>
<div className="card">
<Skeleton className="h-4 w-56 mb-2" />
<Skeleton className="h-3 w-32 mb-4" />
<Skeleton className="h-7 w-full mb-4" />
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</div>
</div>
<div className="card p-0">
<div className="px-4 pt-4 pb-3">
<Skeleton className="h-4 w-52" />
</div>
<div className="px-4 pb-4 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
</div>
</div>
);
}
export default function FinanceReportPage() {
const isDark = useTheme((s) => s.isDark);
const palette = getChartPalette(isDark);
const [dateRangePreset, setDateRangePreset] = useState('90');
const [customFrom, setCustomFrom] = useState('');
const [customTo, setCustomTo] = useState('');
const [granularity, setGranularity] = useState<FinanceGranularity>('daily');
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [method, setMethod] = useState('');
const [exporting, setExporting] = useState(false);
const trendCardRef = useRef<HTMLDivElement>(null);
const segmentCardRef = useRef<HTMLDivElement>(null);
const methodCardRef = useRef<HTMLDivElement>(null);
const { dateFrom, dateTo } = useMemo(() => {
const end = new Date();
end.setHours(23, 59, 59, 999);
if (dateRangePreset === 'custom') {
if (customFrom && customTo) {
return customFrom <= customTo
? { dateFrom: customFrom, dateTo: customTo }
: { dateFrom: customTo, dateTo: customFrom };
}
const fallbackStart = new Date(end);
fallbackStart.setDate(end.getDate() - 90);
return {
dateFrom: fallbackStart.toISOString().split('T')[0],
dateTo: end.toISOString().split('T')[0],
};
}
const start = new Date(end);
start.setDate(end.getDate() - Number(dateRangePreset));
return {
dateFrom: start.toISOString().split('T')[0],
dateTo: end.toISOString().split('T')[0],
};
}, [dateRangePreset, customFrom, customTo]);
const filters: FinanceSummaryFilters = useMemo(
() => ({
dateFrom,
dateTo,
granularity,
originStationId: originStationId || undefined,
destinationStationId: destinationStationId || undefined,
method: method || undefined,
}),
[dateFrom, dateTo, granularity, originStationId, destinationStationId, method],
);
const { data: stations = [] } = useQuery<StationOption[]>({
queryKey: ['stations'],
queryFn: () => apiClient.get<StationOption[]>('/stations'),
});
const { data, isLoading, isFetching, isError } = useQuery({
queryKey: ['reports-finance', filters],
placeholderData: (previous) => previous,
queryFn: () => financeApi.getSummary(filters),
});
const rows = data?.rows ?? [];
const pg = usePagination(rows, TABLE_PAGE_SIZE);
const resetFilters = () => {
setDateRangePreset('90');
setCustomFrom('');
setCustomTo('');
setGranularity('daily');
setOriginStationId('');
setDestinationStationId('');
setMethod('');
};
/** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */
const captureCard = async (node: HTMLDivElement | null): Promise<ChartImage | undefined> => {
if (!node) return undefined;
const rect = node.getBoundingClientRect();
const dataUrl = await toPng(node, { pixelRatio: 2, cacheBust: true, backgroundColor: palette.surface });
return { dataUrl, width: Math.round(rect.width), height: Math.round(rect.height) };
};
const doExport = async () => {
if (!data) return;
setExporting(true);
try {
const [trend, segment, method_] = await Promise.all([
captureCard(trendCardRef.current),
captureCard(segmentCardRef.current),
captureCard(methodCardRef.current),
]);
const stationLabel = (id: string) => stations.find((s) => s.id === id)?.name ?? 'Any';
const blob = await buildFinanceWorkbook({
report: data,
filters: {
dateFrom,
dateTo,
granularity,
originLabel: originStationId ? stationLabel(originStationId) : 'Any',
destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any',
methodLabel: method ? methodLabel(method) : 'All',
},
methodLabel,
periodLabel,
images: { trend, segment, method: method_ },
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `finance-summary-${dateFrom}-to-${dateTo}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
};
const trendData = useMemo(
() =>
(data?.byPeriod ?? [])
.slice()
.sort((a, b) => a.key.localeCompare(b.key))
.map((p) => ({
label: periodLabel(p.key, data!.granularity),
revenue: p.revenueEtbMinor / 100,
})),
[data],
);
const segmentData = useMemo(
() =>
(data?.bySegment ?? [])
.slice()
.sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor)
.map((r) => ({ label: r.label, revenue: r.revenueEtbMinor })),
[data],
);
const methodBreakdown = useMemo(() => {
const rowsByMethod = data?.byMethod ?? [];
const total = rowsByMethod.reduce((sum, r) => sum + r.revenueEtbMinor, 0);
return rowsByMethod
.slice()
.sort((a, b) => PAYMENT_METHOD_ORDER.indexOf(a.key as any) - PAYMENT_METHOD_ORDER.indexOf(b.key as any))
.map((r) => ({
...r,
color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.key as any)),
sharePercent: total > 0 ? (r.revenueEtbMinor / total) * 100 : 0,
}));
}, [data, palette]);
const totals = data?.totals;
const hasData = (totals?.bookingCount ?? 0) > 0;
const avgPerBookingMinor = hasData ? Math.round(totals!.revenueEtbMinor / totals!.bookingCount) : 0;
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-3xl font-bold text-foreground">Finance Summary</h1>
<p className="text-muted-foreground mt-1">
Revenue collected by period, origin/destination, and payment method for daily, weekly, or monthly finance reporting.
</p>
</div>
<ActionButton icon={FileSpreadsheet} variant="secondary" onClick={doExport} loading={exporting} disabled={!hasData}>
Export
</ActionButton>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div>
<label className="label">Date Range</label>
<select className="input" value={dateRangePreset} onChange={(e) => setDateRangePreset(e.target.value)}>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="90">Last 90 Days</option>
<option value="custom">Custom Range</option>
</select>
</div>
{dateRangePreset === 'custom' && (
<>
<div>
<label className="label">Start Date</label>
<input type="date" className="input" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} />
</div>
<div>
<label className="label">End Date</label>
<input type="date" className="input" value={customTo} onChange={(e) => setCustomTo(e.target.value)} />
</div>
</>
)}
<div>
<label className="label">Granularity</label>
<select className="input" value={granularity} onChange={(e) => setGranularity(e.target.value as FinanceGranularity)}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div>
<label className="label">Origin</label>
<select className="input" value={originStationId} onChange={(e) => setOriginStationId(e.target.value)}>
<option value="">Any origin</option>
{stations.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div>
<label className="label">Destination</label>
<select className="input" value={destinationStationId} onChange={(e) => setDestinationStationId(e.target.value)}>
<option value="">Any destination</option>
{stations.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div>
<label className="label">Payment Method</label>
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
<option value="">All methods</option>
{PAYMENT_METHOD_ORDER.map((m) => (
<option key={m} value={m}>{methodLabel(m)}</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<button type="button" onClick={resetFilters} className="text-xs text-primary hover:underline">
Reset filters
</button>
{isFetching && <span className="text-xs text-muted-foreground">Refreshing</span>}
</div>
{isError && <p className="text-xs text-red-500 mt-3">Failed to load the finance summary. Check the filters and try again.</p>}
</div>
{isLoading && !data ? (
<FinanceReportSkeleton />
) : !hasData ? (
<div className="card py-16 text-center text-muted-foreground">
<Banknote className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No paid bookings in this window.</p>
<p className="text-xs mt-1">Widen the date range, or clear the origin/destination/method filters.</p>
</div>
) : (
<div className={isFetching ? 'opacity-60 transition-opacity space-y-6' : 'space-y-6'}>
{/* KPI tiles */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</p>
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
<Banknote className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
{formatCurrency(totals!.revenueEtbMinor, 'ETB')}
</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bookings</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{totals!.bookingCount.toLocaleString()}</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Avg. per Booking</p>
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
<Receipt className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{formatCurrency(avgPerBookingMinor, 'ETB')}</p>
</div>
</div>
{/* Trend + route charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card" ref={trendCardRef}>
<h3 className="text-base font-semibold mb-4">
Revenue Trend <span className="text-xs font-normal text-muted-foreground">(ETB, {granularity})</span>
</h3>
{trendData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke={palette.grid} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: palette.textMuted }} />
<YAxis tick={{ fontSize: 11, fill: palette.textMuted }} />
<RechartsTooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
<Line type="monotone" dataKey="revenue" name="Revenue" stroke={palette.sequential} dot={{ r: 3 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
)}
</div>
<div className="card" ref={segmentCardRef}>
<h3 className="text-base font-semibold mb-4">Revenue by Segment</h3>
{segmentData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={segmentData}>
<CartesianGrid strokeDasharray="3 3" stroke={palette.grid} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: palette.textMuted }} />
<YAxis tick={{ fontSize: 11, fill: palette.textMuted }} />
<RechartsTooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
<Bar dataKey="revenue" fill={palette.sequential} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
)}
</div>
</div>
{/* Payment method breakdown — part-to-whole stacked bar + legend table */}
<div className="card" ref={methodCardRef}>
<h3 className="text-base font-semibold text-foreground">Revenue by Payment Method</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">Share of revenue, in ETB</p>
<div
className="flex w-full h-7 rounded-md overflow-hidden"
role="img"
aria-label={`Revenue by payment method: ${methodBreakdown
.map((m) => `${methodLabel(m.key)} ${m.sharePercent.toFixed(0)}%`)
.join(', ')}`}
>
{methodBreakdown.map((m, i) => (
<div
key={m.key}
className="h-full"
style={{ width: `${m.sharePercent}%`, background: m.color, marginRight: i < methodBreakdown.length - 1 ? 2 : 0 }}
title={`${methodLabel(m.key)}${formatCurrency(m.revenueEtbMinor, 'ETB')}`}
/>
))}
</div>
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium py-2">Method</th>
<th className="text-right font-medium py-2">Bookings</th>
<th className="text-right font-medium py-2">Share</th>
<th className="text-right font-medium py-2">Revenue</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{methodBreakdown.map((m) => (
<tr key={m.key}>
<td className="py-2">
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ background: m.color }} aria-hidden="true" />
<span className="text-foreground">{methodLabel(m.key)}</span>
</span>
</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{m.bookingCount.toLocaleString()}</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{m.sharePercent.toFixed(1)}%</td>
<td className="py-2 text-right tabular-nums text-foreground font-medium">{formatCurrency(m.revenueEtbMinor, 'ETB')}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Detail table */}
<div className="card p-0">
<div className="px-4 pt-4 pb-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Period × Segment × Method detail
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{['Period', 'Segment', 'Method', 'Bookings', 'Revenue'].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{pg.paged.map((r, i) => (
<tr key={`${r.period}-${r.originStationId}-${r.destinationStationId}-${r.method}-${i}`} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td className="px-4 py-3 whitespace-nowrap text-foreground">{periodLabel(r.period, granularity)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.segmentLabel}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{methodLabel(r.method)}</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{r.bookingCount.toLocaleString()}</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap font-medium">{formatCurrency(r.revenueEtbMinor, 'ETB')}</td>
</tr>
))}
{pg.paged.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination currentPage={pg.page} totalPages={pg.totalPages} onPageChange={pg.setPage} />
</div>
</div>
)}
</div>
);
}

View File

@@ -123,6 +123,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Analytics & Reports',
items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },

View File

@@ -0,0 +1,10 @@
import { cn } from '@/lib/utils';
interface SkeletonProps {
className?: string;
}
/** A shimmering placeholder block. Give it the size/shape of the content it stands in for. */
export default function Skeleton({ className }: SkeletonProps) {
return <div className={cn('skeleton', className)} aria-hidden="true" />;
}

View File

@@ -0,0 +1,65 @@
import { apiClient } from '@/lib/api-client';
export type FinanceGranularity = 'daily' | 'weekly' | 'monthly';
export interface FinanceSummaryFilters {
dateFrom: string;
dateTo: string;
granularity?: FinanceGranularity;
originStationId?: string;
destinationStationId?: string;
method?: string;
}
export interface FinanceBucketRow {
period: string;
originStationId: string;
destinationStationId: string;
segmentLabel: string;
method: string;
bookingCount: number;
revenueEtbMinor: number;
}
export interface FinanceRollupRow {
key: string;
label: string;
revenueEtbMinor: number;
bookingCount: number;
}
export interface FinanceTotals {
bookingCount: number;
revenueEtbMinor: number;
}
export interface FinanceSummaryReport {
granularity: FinanceGranularity;
dateFrom: string;
dateTo: string;
currency: string;
totals: FinanceTotals;
byPeriod: FinanceRollupRow[];
bySegment: FinanceRollupRow[];
byMethod: FinanceRollupRow[];
rows: FinanceBucketRow[];
}
/** Drops blanks so the API applies its own defaults (granularity=daily, no station/method filter). */
function toParams(filters: FinanceSummaryFilters): Record<string, string> {
const params: Record<string, string> = { dateFrom: filters.dateFrom, dateTo: filters.dateTo };
if (filters.granularity) params.granularity = filters.granularity;
if (filters.originStationId) params.originStationId = filters.originStationId;
if (filters.destinationStationId) params.destinationStationId = filters.destinationStationId;
if (filters.method) params.method = filters.method;
return params;
}
export const financeApi = {
getSummary: (filters: FinanceSummaryFilters) =>
apiClient.get<FinanceSummaryReport>('/reports/finance', { params: toParams(filters) }),
/** CSV export. `getRaw` because the endpoint streams a bare CSV body, not the `{success,data}` envelope. */
exportCsv: (filters: FinanceSummaryFilters) =>
apiClient.getRaw<string>('/reports/finance/export', { params: toParams(filters) }),
};

View File

@@ -0,0 +1,245 @@
import ExcelJS from 'exceljs';
import type { FinanceGranularity, FinanceSummaryReport } from '@/lib/api/finance';
// Brand palette — rgb(20,113,76), the same green used by ActionButton's primary variant,
// so the exported file reads as the same product as the on-screen report.
const BRAND = 'FF14714C';
const BRAND_DARK = 'FF0E5A3D';
const BRAND_TINT = 'FFEAF5EF';
const INK = 'FF1F2937';
const MUTED = 'FF6B7280';
const ROW_ALT = 'FFF7F8F7';
const BORDER = 'FFE2E5E1';
const WHITE = 'FFFFFFFF';
const CURRENCY_FMT = '"ETB" #,##0.00';
const THIN_BORDER: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: BORDER } },
left: { style: 'thin', color: { argb: BORDER } },
bottom: { style: 'thin', color: { argb: BORDER } },
right: { style: 'thin', color: { argb: BORDER } },
};
export interface ChartImage {
dataUrl: string;
width: number;
height: number;
}
export interface FinanceWorkbookInput {
report: FinanceSummaryReport;
filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string };
methodLabel: (method: string) => string;
periodLabel: (period: string, granularity: FinanceGranularity) => string;
images: { trend?: ChartImage; segment?: ChartImage; method?: ChartImage };
}
function styleHeaderCell(cell: ExcelJS.Cell) {
cell.font = { bold: true, color: { argb: WHITE }, size: 11 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
cell.alignment = { vertical: 'middle', horizontal: 'left' };
cell.border = THIN_BORDER;
}
function addTableHeader(ws: ExcelJS.Worksheet, rowIndex: number, headers: string[], alignRight: Set<number> = new Set()) {
const row = ws.getRow(rowIndex);
headers.forEach((h, i) => {
const cell = row.getCell(i + 1);
cell.value = h;
styleHeaderCell(cell);
if (alignRight.has(i)) cell.alignment = { vertical: 'middle', horizontal: 'right' };
});
row.height = 20;
row.commit();
}
function bandRow(ws: ExcelJS.Worksheet, rowIndex: number, colCount: number, isAlt: boolean) {
const row = ws.getRow(rowIndex);
for (let c = 1; c <= colCount; c++) {
const cell = row.getCell(c);
cell.border = THIN_BORDER;
if (isAlt) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: ROW_ALT } };
}
}
function titleBanner(ws: ExcelJS.Worksheet, title: string, subtitle: string, colSpan: number) {
ws.mergeCells(1, 1, 1, colSpan);
const titleCell = ws.getCell(1, 1);
titleCell.value = title;
titleCell.font = { bold: true, size: 18, color: { argb: WHITE } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
titleCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(1).height = 34;
for (let c = 1; c <= colSpan; c++) ws.getCell(1, c).fill = titleCell.fill;
ws.mergeCells(2, 1, 2, colSpan);
const subCell = ws.getCell(2, 1);
subCell.value = subtitle;
subCell.font = { italic: true, size: 10, color: { argb: MUTED } };
subCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(2).height = 18;
}
function kpiCard(ws: ExcelJS.Worksheet, startRow: number, startCol: number, span: number, label: string, value: string, accent: string) {
ws.mergeCells(startRow, startCol, startRow, startCol + span - 1);
ws.mergeCells(startRow + 1, startCol, startRow + 1, startCol + span - 1);
const labelCell = ws.getCell(startRow, startCol);
labelCell.value = label.toUpperCase();
labelCell.font = { bold: true, size: 9, color: { argb: MUTED } };
labelCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
const valueCell = ws.getCell(startRow + 1, startCol);
valueCell.value = value;
valueCell.font = { bold: true, size: 16, color: { argb: accent } };
valueCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
for (let r = startRow; r <= startRow + 1; r++) {
for (let c = startCol; c < startCol + span; c++) {
const cell = ws.getCell(r, c);
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } };
cell.border = {
top: r === startRow ? { style: 'thin', color: { argb: BORDER } } : undefined,
bottom: r === startRow + 1 ? { style: 'thin', color: { argb: BORDER } } : undefined,
left: c === startCol ? { style: 'thin', color: { argb: BORDER } } : undefined,
right: c === startCol + span - 1 ? { style: 'thin', color: { argb: BORDER } } : undefined,
};
}
}
ws.getRow(startRow).height = 16;
ws.getRow(startRow + 1).height = 26;
}
function addImage(wb: ExcelJS.Workbook, ws: ExcelJS.Worksheet, image: ChartImage | undefined, anchorRow: number, heading: string) {
const headingCell = ws.getCell(anchorRow, 1);
headingCell.value = heading;
headingCell.font = { bold: true, size: 12, color: { argb: INK } };
ws.getRow(anchorRow).height = 20;
if (!image) {
const emptyCell = ws.getCell(anchorRow + 1, 1);
emptyCell.value = 'No chart available for the current filters.';
emptyCell.font = { italic: true, size: 10, color: { argb: MUTED } };
return anchorRow + 3;
}
const maxWidth = 640;
const scale = image.width > maxWidth ? maxWidth / image.width : 1;
const width = Math.round(image.width * scale);
const height = Math.round(image.height * scale);
const imageId = wb.addImage({ base64: image.dataUrl, extension: 'png' });
ws.addImage(imageId, {
tl: { col: 0.15, row: anchorRow + 0.15 },
ext: { width, height },
});
// Advance past the image height (≈20px per row) plus a spacer row.
const rowsUsed = Math.ceil(height / 20) + 2;
return anchorRow + rowsUsed;
}
export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise<Blob> {
const { report, filters, images } = input;
const methodLabel = input.methodLabel;
const periodLabel = input.periodLabel;
const wb = new ExcelJS.Workbook();
wb.creator = 'EDR Passenger Backoffice';
wb.created = new Date();
// ── Summary sheet ─────────────────────────────────────────────────────────
const summary = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] });
summary.columns = [{ width: 16 }, { width: 16 }, { width: 16 }, { width: 16 }, { width: 16 }, { width: 16 }];
titleBanner(
summary,
'EDR Passenger — Finance Summary',
`${filters.dateFrom} to ${filters.dateTo} · ${filters.granularity} · Origin: ${filters.originLabel} · Destination: ${filters.destinationLabel} · Method: ${filters.methodLabel} · Generated ${new Date().toLocaleString('en-US')}`,
6,
);
const avgPerBooking = report.totals.bookingCount > 0 ? report.totals.revenueEtbMinor / report.totals.bookingCount : 0;
kpiCard(summary, 4, 1, 2, 'Total Revenue', `ETB ${(report.totals.revenueEtbMinor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, BRAND_DARK);
kpiCard(summary, 4, 3, 2, 'Bookings', report.totals.bookingCount.toLocaleString('en-US'), INK);
kpiCard(summary, 4, 5, 2, 'Avg. per Booking', `ETB ${(avgPerBooking / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, INK);
let cursor = 7;
cursor = addImage(wb, summary, images.trend, cursor, 'Revenue Trend') + 1;
cursor = addImage(wb, summary, images.segment, cursor, 'Revenue by Segment') + 1;
addImage(wb, summary, images.method, cursor, 'Revenue by Payment Method');
// ── By Period sheet ──────────────────────────────────────────────────────
const byPeriod = wb.addWorksheet('By Period', { views: [{ state: 'frozen', ySplit: 1 }] });
byPeriod.columns = [{ width: 18 }, { width: 14 }, { width: 20 }];
addTableHeader(byPeriod, 1, ['Period', 'Bookings', 'Revenue (ETB)'], new Set([1, 2]));
const periodRows = [...report.byPeriod].sort((a, b) => a.key.localeCompare(b.key));
periodRows.forEach((p, i) => {
const r = byPeriod.getRow(i + 2);
r.getCell(1).value = periodLabel(p.key, report.granularity);
r.getCell(2).value = p.bookingCount;
r.getCell(2).alignment = { horizontal: 'right' };
r.getCell(3).value = p.revenueEtbMinor / 100;
r.getCell(3).numFmt = CURRENCY_FMT;
r.getCell(3).alignment = { horizontal: 'right' };
bandRow(byPeriod, i + 2, 3, i % 2 === 1);
});
byPeriod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 3 } };
// ── By Segment sheet ─────────────────────────────────────────────────────
const bySegment = wb.addWorksheet('By Segment', { views: [{ state: 'frozen', ySplit: 1 }] });
bySegment.columns = [{ width: 34 }, { width: 14 }, { width: 20 }];
addTableHeader(bySegment, 1, ['Origin → Destination', 'Bookings', 'Revenue (ETB)'], new Set([1, 2]));
report.bySegment.forEach((s, i) => {
const r = bySegment.getRow(i + 2);
r.getCell(1).value = s.label;
r.getCell(2).value = s.bookingCount;
r.getCell(2).alignment = { horizontal: 'right' };
r.getCell(3).value = s.revenueEtbMinor / 100;
r.getCell(3).numFmt = CURRENCY_FMT;
r.getCell(3).alignment = { horizontal: 'right' };
bandRow(bySegment, i + 2, 3, i % 2 === 1);
});
bySegment.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 3 } };
// ── By Method sheet ──────────────────────────────────────────────────────
const byMethod = wb.addWorksheet('By Method', { views: [{ state: 'frozen', ySplit: 1 }] });
byMethod.columns = [{ width: 20 }, { width: 14 }, { width: 20 }, { width: 12 }];
addTableHeader(byMethod, 1, ['Payment Method', 'Bookings', 'Revenue (ETB)', 'Share'], new Set([1, 2, 3]));
const methodTotal = report.byMethod.reduce((sum, m) => sum + m.revenueEtbMinor, 0);
report.byMethod.forEach((m, i) => {
const r = byMethod.getRow(i + 2);
r.getCell(1).value = methodLabel(m.key);
r.getCell(2).value = m.bookingCount;
r.getCell(2).alignment = { horizontal: 'right' };
r.getCell(3).value = m.revenueEtbMinor / 100;
r.getCell(3).numFmt = CURRENCY_FMT;
r.getCell(3).alignment = { horizontal: 'right' };
r.getCell(4).value = methodTotal > 0 ? m.revenueEtbMinor / methodTotal : 0;
r.getCell(4).numFmt = '0.0%';
r.getCell(4).alignment = { horizontal: 'right' };
bandRow(byMethod, i + 2, 4, i % 2 === 1);
});
byMethod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 4 } };
// ── Detail sheet — every row, unpaginated ───────────────────────────────
const detail = wb.addWorksheet('Detail', { views: [{ state: 'frozen', ySplit: 1 }] });
detail.columns = [{ width: 18 }, { width: 34 }, { width: 18 }, { width: 14 }, { width: 20 }];
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Payment Method', 'Bookings', 'Revenue (ETB)'], new Set([2, 3]));
report.rows.forEach((row, i) => {
const r = detail.getRow(i + 2);
r.getCell(1).value = periodLabel(row.period, report.granularity);
r.getCell(2).value = row.segmentLabel;
r.getCell(3).value = methodLabel(row.method);
r.getCell(4).value = row.bookingCount;
r.getCell(4).alignment = { horizontal: 'right' };
r.getCell(5).value = row.revenueEtbMinor / 100;
r.getCell(5).numFmt = CURRENCY_FMT;
r.getCell(5).alignment = { horizontal: 'right' };
bandRow(detail, i + 2, 5, i % 2 === 1);
});
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
const buffer = await wb.xlsx.writeBuffer();
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}

View File

@@ -68,6 +68,24 @@
.animate-fade-up {
animation: fade-up 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes shimmer {
from { background-position: -300px 0; }
to { background-position: 300px 0; }
}
.skeleton {
border-radius: 0.5rem;
background-color: hsl(var(--muted));
background-image: linear-gradient(
90deg,
hsl(var(--muted)) 0%,
hsl(var(--muted-foreground) / 0.18) 50%,
hsl(var(--muted)) 100%
);
background-size: 600px 100%;
background-repeat: no-repeat;
animation: shimmer 1.5s ease-in-out infinite;
}
}
@layer components {

View File

@@ -7,6 +7,8 @@ import {
Logger,
} from "@nestjs/common";
import { logCtx } from "../logging/request-context";
interface ErrorResponseBody {
success: false;
statusCode: number;
@@ -120,6 +122,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
path: request.url,
};
// Land the failure on the request's canonical log line too — the middleware
// only sees a status code, not what threw. No-op outside a request.
logCtx(
{
name: exception instanceof Error ? exception.name : "Error",
message,
status,
stack: status >= 500 ? (exception as Error)?.stack : undefined,
},
{ path: "error", mode: "set" },
);
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} -> ${status}`,

View File

@@ -6,6 +6,10 @@ export * from "./decorators/public.decorator";
// Filters
export * from "./filters/http-exception.filter";
// Logging
export * from "./logging/request-context";
export * from "./logging/request-log.middleware";
// Interceptors
export * from "./interceptors/response-transform.interceptor";

View File

@@ -0,0 +1,109 @@
import { AsyncLocalStorage } from "node:async_hooks";
/**
* Per-request bag of data points that end up on the canonical request log line
* (see request-log.middleware.ts). Anything written here is flattened into one
* JSON object at the end of the request, so keep it to values that are safe to
* ship to a log aggregator: ids, states, counts, outcomes — never secrets,
* tokens, or raw uploads.
*/
export type RequestLogContext = Record<string, unknown>;
const storage = new AsyncLocalStorage<RequestLogContext>();
export const getLogContext = (): RequestLogContext | undefined =>
storage.getStore();
export const runWithLogContext = <T>(
seed: RequestLogContext,
fn: () => T,
): T => storage.run(seed, fn);
export interface LogCtxOptions {
/**
* Dot path to write under, e.g. "booking.transition". Namespacing is just a
* path with one segment ("payment"). Root object when omitted.
*/
path?: string;
/**
* How the value lands:
* - `merge` (default) — shallow-merge objects into the target
* - `set` — overwrite the target
* - `push` — append to an array at the target
* - `count` — add the value (default 1) to a numeric counter at the target
*/
mode?: "merge" | "set" | "push" | "count";
}
/** Escape hatch when the four modes don't fit: mutate the context yourself. */
export type LogCtxMutator = (ctx: RequestLogContext, value: unknown) => void;
// ponytail: fixed cap so a loop calling logCtx in `push` mode can't grow an
// unbounded array in memory / a megabyte log line. Per-path caps if one ever
// legitimately needs more.
const MAX_PUSHED = 100;
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
/**
* Append a data point to the current request's canonical log line.
*
* No-op outside an HTTP request (bootstrap, cron, queue consumers) — callers
* never need to guard.
*
* logCtx({ bookingId, status }); // root merge
* logCtx(intentId, { path: "payment.intentId", mode: "set" });
* logCtx({ from, to }, { path: "booking.transition" }); // namespaced
* logCtx({ wagonId }, { path: "wagons", mode: "push" }); // array
* logCtx(1, { path: "smsSent", mode: "count" }); // counter
* logCtx(err, (ctx, e) => { ctx.lastError = String(e); }); // custom
*/
export function logCtx(
value: unknown,
opts: LogCtxOptions | LogCtxMutator = {},
): void {
const ctx = storage.getStore();
if (!ctx) return;
if (typeof opts === "function") {
opts(ctx, value);
return;
}
const { path, mode = "merge" } = opts;
if (!path) {
if (isPlainObject(value)) Object.assign(ctx, value);
return;
}
const keys = path.split(".");
const leaf = keys.pop() as string;
let node: Record<string, unknown> = ctx;
for (const key of keys) {
if (!isPlainObject(node[key])) node[key] = {};
node = node[key] as Record<string, unknown>;
}
switch (mode) {
case "set":
node[leaf] = value;
break;
case "push": {
const arr = Array.isArray(node[leaf]) ? (node[leaf] as unknown[]) : [];
if (arr.length < MAX_PUSHED) arr.push(value);
node[leaf] = arr;
break;
}
case "count":
node[leaf] =
((node[leaf] as number) ?? 0) + (typeof value === "number" ? value : 1);
break;
default:
node[leaf] =
isPlainObject(node[leaf]) && isPlainObject(value)
? Object.assign(node[leaf] as Record<string, unknown>, value)
: value;
}
}

View File

@@ -0,0 +1,137 @@
import { randomUUID } from "node:crypto";
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import {
RequestLogContext,
logCtx,
runWithLogContext,
} from "./request-context";
/**
* Structural request/response shapes — express types are not a dependency of
* this package (they arrive under @nestjs/platform-express in the apps).
*/
interface LoggedRequest {
method: string;
url: string;
originalUrl?: string;
baseUrl?: string;
route?: { path?: string };
headers: Record<string, string | string[] | undefined>;
ip?: string;
query?: Record<string, unknown>;
user?: Record<string, unknown> | null;
}
interface LoggedResponse {
statusCode: number;
writableEnded?: boolean;
setHeader(name: string, value: string): void;
on(event: string, listener: () => void): void;
}
const header = (req: LoggedRequest, name: string): string | undefined => {
const value = req.headers[name];
return Array.isArray(value) ? value[0] : value;
};
const userId = (req: LoggedRequest): string | undefined => {
const user = req.user;
if (!user) return undefined;
const id = user.id ?? user.sub ?? user.userId;
return typeof id === "string" || typeof id === "number"
? String(id)
: undefined;
};
/**
* Opens an AsyncLocalStorage context for the request (so anything downstream
* can `logCtx(...)` into it) and emits ONE canonical JSON line per request when
* the response ends — request metadata plus every data point collected during
* the flow, on the same Nest logger transport as the rest of the app.
*
* Base fields win over collected context on key collisions, so a stray
* `logCtx({ status })` can never corrupt the fields dashboards filter on.
*
* Apply FIRST in `configure()`: middleware registered before this one runs
* outside the context and its `logCtx` calls are silently dropped.
*/
@Injectable()
export class RequestLogMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
private readonly canonical = new Logger("request");
use(req: LoggedRequest, res: LoggedResponse, next: () => void): void {
const start = Date.now();
const requestId = header(req, "x-request-id") ?? randomUUID();
res.setHeader("x-request-id", requestId);
// Held by reference, NOT read back through the ALS at emit time: response
// "finish"/"close" listeners are plain EventEmitter callbacks, which Node
// does not bind to the async context they were registered in — getStore()
// there returns whatever context happened to call res.end().
const ctx: RequestLogContext = {};
runWithLogContext(ctx, () => {
logCtx({ requestId });
let emitted = false;
// "finish" covers normal responses; "close" catches client aborts, where
// "finish" never fires and the request would vanish from the logs.
const emit = () => {
if (emitted) return;
emitted = true;
const url = req.originalUrl ?? req.url;
const status = res.statusCode;
const durationMs = Date.now() - start;
this.logger.log(`${req.method} ${url} ${status} ${durationMs}ms`);
const line = {
...ctx,
type: "http_request",
requestId,
method: req.method,
route: req.route?.path
? `${req.baseUrl ?? ""}${req.route.path}`
: undefined,
url,
status,
durationMs,
userId: userId(req),
ip: req.ip,
userAgent: header(req, "user-agent"),
query:
req.query && Object.keys(req.query).length ? req.query : undefined,
aborted: res.writableEnded === false ? true : undefined,
};
// A circular value pushed into the context must not throw inside a
// response listener — that would take the process down, not the log.
let json: string;
try {
json = JSON.stringify(line);
} catch {
json = JSON.stringify({
type: "http_request",
requestId,
method: req.method,
url,
status,
durationMs,
contextSerializationFailed: true,
});
}
if (status >= 500) this.canonical.error(json);
else if (status >= 400) this.canonical.warn(json);
else this.canonical.log(json);
};
res.on("finish", emit);
res.on("close", emit);
next();
});
}
}

View File

@@ -55,7 +55,8 @@ export interface ETradeCompanyInfo {
RenewedFrom: string;
RenewedTo: string;
BusinessLicensingGroupMain: string | null;
SubGroups: Array<{ Code: number; Description: string }> | null;
/** eTrade returns null entries in this array as well as a null array. */
SubGroups: Array<{ Code: number; Description: string | null } | null> | null;
}>;
}

316
pnpm-lock.yaml generated
View File

@@ -601,7 +601,7 @@ importers:
version: 5.101.0(react@19.2.6)
'@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(ea394f4c73a62db876565d0b255299aa)
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
'@vis.gl/react-google-maps':
specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -980,6 +980,12 @@ importers:
date-fns:
specifier: ^3.0.0
version: 3.6.0
exceljs:
specifier: ^4.4.0
version: 4.4.0
html-to-image:
specifier: ^1.11.11
version: 1.11.13
lucide-react:
specifier: ^0.446.0
version: 0.446.0(react@18.3.1)
@@ -8302,6 +8308,9 @@ packages:
resolution: {integrity: sha512-XxzooSo6oBoxBEUazgjdXj7VwTn/iSTSZzTYKzYY6I916tkaYzypHxy+pbVU1h+0UQ9JlVf5XkNQyxOAiiQO1g==}
engines: {node: '>=0.10.0'}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -13023,11 +13032,11 @@ snapshots:
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -13062,7 +13071,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -13071,14 +13080,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -13093,9 +13095,9 @@ snapshots:
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-module-imports': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -13110,13 +13112,13 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -13269,18 +13271,6 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.7(supports-color@5.5.0)':
dependencies:
'@babel/code-frame': 7.29.7
@@ -13765,7 +13755,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/runtime': 7.29.7
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
@@ -13931,7 +13921,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.2
@@ -14100,7 +14090,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -15673,7 +15663,7 @@ snapshots:
'@puppeteer/browsers@2.13.2':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
@@ -17747,7 +17737,7 @@ snapshots:
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
@@ -18057,153 +18047,6 @@ snapshots:
- vite
- yup
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(ea394f4c73a62db876565d0b255299aa)':
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)
'@hookform/resolvers': 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76)
'@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@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)
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tooltip': 1.2.9(@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)
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf/renderer': 4.5.1(react@19.2.6)
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6)
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
'@tanstack/react-query': 5.101.0(react@19.2.6)
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
'@types/dompurify': 3.2.0
'@types/node': 24.13.1
'@types/tinymce': 4.6.9
axios: 1.17.0
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1(@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)
date-fns: 3.6.0
dayjs: 1.11.21
dompurify: 3.4.8
ethiopian-calendar-date-converter: 2.1.6
ethiopian-calendar-new: 1.1.0
file-type: 18.7.0
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
html2canvas: 1.4.1
i18next: 25.10.10(typescript@5.9.3)
i18next-browser-languagedetector: 8.2.1
jquery: 3.7.1
js-cookie: 3.0.8
jspdf: 3.0.4
lodash: 4.18.1
lucide-react: 0.513.0(react@19.2.6)
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
path: 0.12.7
pdf-lib: 1.17.1
qs: 6.15.2
react: 19.2.6
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-dropzone: 14.4.1(react@19.2.6)
react-hook-form: 7.77.0(react@19.2.6)
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-icons: 5.6.0(react@19.2.6)
react-image-crop: 11.0.10(react@19.2.6)
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(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)
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
socket.io-client: 4.8.3
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge: 3.6.0
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
tailwindcss: 4.3.0
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
tinymce: 7.9.3
url: 0.11.4
vaul: 1.1.2(@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)
xlsx: 0.18.5
zod: 3.25.76
transitivePeerDependencies:
- '@babel/core'
- '@emotion/is-prop-valid'
- '@mui/icons-material'
- '@mui/material'
- '@mui/x-date-pickers'
- '@sinclair/typebox'
- '@standard-schema/spec'
- '@types/prop-types'
- '@types/react'
- '@types/react-dom'
- '@typeschema/main'
- '@vinejs/vine'
- ajv
- ajv-errors
- ajv-formats
- arktype
- ata-validator
- bufferutil
- class-transformer
- class-validator
- computed-types
- debug
- effect
- fluentvalidation-ts
- fp-ts
- io-ts
- joi
- nope-validator
- pdfjs-dist
- prop-types
- react-is
- react-native
- redux
- rolldown
- rollup
- superstruct
- supports-color
- typanion
- typescript
- utf-8-validate
- valibot
- vest
- vite
- yup
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
@@ -18599,7 +18442,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
typescript: 5.9.3
transitivePeerDependencies:
@@ -18609,7 +18452,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -18628,7 +18471,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
@@ -18643,7 +18486,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
minimatch: 10.2.5
semver: 7.8.2
tinyglobby: 0.2.17
@@ -18932,7 +18775,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -19441,16 +19284,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
dependencies:
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
picomatch: 4.0.4
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- supports-color
babel-polyfill@6.26.0:
dependencies:
babel-runtime: 6.26.0
@@ -19606,7 +19439,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
@@ -20655,7 +20488,7 @@ snapshots:
engine.io-client@6.6.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io-parser: 5.2.3
ws: 8.20.1
xmlhttprequest-ssl: 2.1.2
@@ -20675,7 +20508,7 @@ snapshots:
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.6
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io-parser: 5.2.3
ws: 8.21.0
transitivePeerDependencies:
@@ -20906,7 +20739,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
get-tsconfig: 4.14.0
is-bun-module: 2.0.0
@@ -21034,7 +20867,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -21271,7 +21104,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
@@ -21324,7 +21157,7 @@ snapshots:
extract-zip@2.0.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
@@ -21479,7 +21312,7 @@ snapshots:
finalhandler@2.1.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -21727,7 +21560,7 @@ snapshots:
dependencies:
basic-ftp: 5.3.1
data-uri-to-buffer: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -22016,6 +21849,8 @@ snapshots:
is-self-closing: 1.0.1
kind-of: 6.0.3
html-to-image@1.11.13: {}
html-url-attributes@3.0.1: {}
html2canvas@1.4.1:
@@ -22034,7 +21869,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -22047,14 +21882,14 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -22494,7 +22329,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
istanbul-lib-coverage: 3.2.2
source-map: 0.6.1
transitivePeerDependencies:
@@ -23150,7 +22985,7 @@ snapshots:
dependencies:
chalk: 5.6.2
commander: 13.1.0
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
execa: 8.0.1
lilconfig: 3.1.3
listr2: 8.3.3
@@ -23837,7 +23672,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.13
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
decode-named-character-reference: 1.3.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@@ -24363,7 +24198,7 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
get-uri: 6.0.5
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
@@ -24713,7 +24548,7 @@ snapshots:
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
lru-cache: 7.18.3
@@ -24742,7 +24577,7 @@ snapshots:
dependencies:
'@puppeteer/browsers': 2.13.2
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
devtools-protocol: 0.0.1608973
typed-query-selector: 2.12.2
webdriver-bidi-protocol: 0.4.1
@@ -24995,15 +24830,6 @@ snapshots:
- '@babel/core'
- react-is
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
- react-is
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
dependencies:
date-fns: 3.6.0
@@ -25614,7 +25440,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -25736,7 +25562,7 @@ snapshots:
send@1.2.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -25952,7 +25778,7 @@ snapshots:
socket.io-adapter@2.5.8:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
@@ -25962,7 +25788,7 @@ snapshots:
socket.io-client@4.8.3:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io-client: 6.6.5
socket.io-parser: 4.2.6
transitivePeerDependencies:
@@ -25973,7 +25799,7 @@ snapshots:
socket.io-parser@4.2.6:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -25982,7 +25808,7 @@ snapshots:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.6
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io: 6.6.9
socket.io-adapter: 2.5.8
socket.io-parser: 4.2.6
@@ -25994,7 +25820,7 @@ snapshots:
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
socks: 2.8.9
transitivePeerDependencies:
- supports-color
@@ -26289,24 +26115,6 @@ snapshots:
transitivePeerDependencies:
- '@babel/core'
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@emotion/is-prop-valid': 1.4.0
'@emotion/stylis': 0.8.5
'@emotion/unitless': 0.7.5
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
css-to-react-native: 3.2.0
hoist-non-react-statics: 3.3.2
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 19.2.7
shallowequal: 1.1.0
supports-color: 5.5.0
transitivePeerDependencies:
- '@babel/core'
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
@@ -26332,7 +26140,7 @@ snapshots:
dependencies:
component-emitter: 1.3.1
cookiejar: 2.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
fast-safe-stringify: 2.1.1
form-data: 4.0.5
formidable: 3.5.4
@@ -26845,7 +26653,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.21
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
glob: 10.5.0
@@ -26869,7 +26677,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.21
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
glob: 10.5.0
@@ -27230,7 +27038,7 @@ snapshots:
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -27248,7 +27056,7 @@ snapshots:
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -27295,7 +27103,7 @@ snapshots:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 1.1.2
@@ -27331,7 +27139,7 @@ snapshots:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 1.1.2