mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issues
This commit is contained in:
@@ -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(
|
||||
|
||||
102
apps/edr-freight-api/src/common/request-log-context.spec.ts
Normal file
102
apps/edr-freight-api/src/common/request-log-context.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,6 +34,7 @@ export default function NationalitySelect({
|
||||
selected={value === "ethiopian"}
|
||||
onClick={() => onChange("ethiopian")}
|
||||
/>
|
||||
{!excludeForeign && (
|
||||
<RoleCard
|
||||
label="Foreign Company"
|
||||
description="Registered abroad. You'll provide a passport and investment license."
|
||||
@@ -38,6 +42,7 @@ export default function NationalitySelect({
|
||||
selected={value === "foreign"}
|
||||
onClick={() => onChange("foreign")}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
18
apps/edr-freight-web/portal/src/utils/documentSettingCode.ts
Normal file
18
apps/edr-freight-web/portal/src/utils/documentSettingCode.ts
Normal 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";
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
65
apps/edr-passenger-web/backoffice/src/lib/api/finance.ts
Normal file
65
apps/edr-passenger-web/backoffice/src/lib/api/finance.ts
Normal 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) }),
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
109
packages/api-common/src/logging/request-context.ts
Normal file
109
packages/api-common/src/logging/request-context.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
137
packages/api-common/src/logging/request-log.middleware.ts
Normal file
137
packages/api-common/src/logging/request-log.middleware.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
316
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user