mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
merge conflict
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import type { Repository } from "typeorm";
|
||||
import {
|
||||
BaseRepository,
|
||||
RequestLogMiddleware,
|
||||
getLogContext,
|
||||
logCtx,
|
||||
@@ -47,10 +49,13 @@ describe("logCtx", () => {
|
||||
|
||||
describe("RequestLogMiddleware", () => {
|
||||
it("emits one canonical JSON line carrying the collected context", () => {
|
||||
// Raw stdout, not the Nest logger — the line must be parsable JSON with no
|
||||
// "[Nest] … LOG [request]" prefix in front of it.
|
||||
const lines: string[] = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, "warn")
|
||||
.mockImplementation((m) => lines.push(String(m)));
|
||||
jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
lines.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
|
||||
|
||||
const listeners: Record<string, () => void> = {};
|
||||
@@ -82,7 +87,11 @@ describe("RequestLogMiddleware", () => {
|
||||
listeners.close(); // aborts/close after finish must not double-log
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].endsWith("\n")).toBe(true);
|
||||
expect(lines[0].startsWith("{")).toBe(true);
|
||||
expect(JSON.parse(lines[0])).toMatchObject({
|
||||
level: "warn",
|
||||
logger: "request",
|
||||
type: "http_request",
|
||||
requestId: "req-42",
|
||||
method: "POST",
|
||||
@@ -100,3 +109,38 @@ describe("RequestLogMiddleware", () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseRepository write trail", () => {
|
||||
class TestRepo extends BaseRepository<{ id: string; status?: string }> {
|
||||
constructor(repo: Repository<{ id: string; status?: string }>) {
|
||||
super(repo);
|
||||
}
|
||||
}
|
||||
|
||||
const typeormRepo = {
|
||||
metadata: { tableName: "booking" },
|
||||
create: (data: unknown) => data,
|
||||
save: async (data: unknown) => data,
|
||||
update: async () => undefined,
|
||||
findOne: async () => ({ id: "b-1", status: "SUBMITTED" }),
|
||||
softDelete: async () => undefined,
|
||||
delete: async () => undefined,
|
||||
} as unknown as Repository<{ id: string; status?: string }>;
|
||||
|
||||
it("records creates, status changes and deletes without any service opting in", async () => {
|
||||
const ctx = await runWithLogContext({}, async () => {
|
||||
const repo = new TestRepo(typeormRepo);
|
||||
await repo.create({ id: "b-1" });
|
||||
await repo.update("b-1", { status: "SUBMITTED" });
|
||||
await repo.update("b-1", { id: "b-1" }); // no status → no transition entry
|
||||
await repo.softDelete("b-1");
|
||||
return getLogContext();
|
||||
});
|
||||
|
||||
expect(ctx).toEqual({
|
||||
db: { created: { booking: 1 }, updated: { booking: 2 } },
|
||||
statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }],
|
||||
deleted: [{ entity: "booking", id: "b-1" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
@@ -951,6 +952,24 @@ export class BillingService {
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
|
||||
// Every invoice status move in the app funnels through here — money
|
||||
// changing state is the single most-asked question in support.
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
source: invoice.source,
|
||||
sourceId: invoice.sourceId,
|
||||
from: invoice.status,
|
||||
to: status,
|
||||
event,
|
||||
amount: Number(invoice.totalAmount),
|
||||
currency: invoice.currency,
|
||||
paymentId: extra.paymentId ?? invoice.paymentId ?? undefined,
|
||||
},
|
||||
{ path: "invoiceTransitions", mode: "push" },
|
||||
);
|
||||
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
@@ -1320,6 +1339,21 @@ export class BillingService {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
source: invoice.source,
|
||||
sourceId: invoice.sourceId,
|
||||
companyId: invoice.companyId,
|
||||
amountDue,
|
||||
currency: invoice.currency,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
},
|
||||
{ path: "payment.payInvoice" },
|
||||
);
|
||||
|
||||
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
|
||||
// required up front (the payment service rejects it otherwise, as a 502 here).
|
||||
if (
|
||||
@@ -1443,7 +1477,24 @@ export class BillingService {
|
||||
// first on DESC, which would hand back an unissued invoice.
|
||||
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
if (!invoice) {
|
||||
logCtx(
|
||||
{ paymentId, outcome: "no-invoice-for-payment" },
|
||||
{ path: "payment.settleInvoice" },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
logCtx(
|
||||
{
|
||||
paymentId,
|
||||
providerTxnId,
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoiceStatus: invoice.status,
|
||||
},
|
||||
{ path: "payment.settleInvoice" },
|
||||
);
|
||||
|
||||
const settleable: Freight.InvoiceStatus[] = [
|
||||
...OPEN_STATUSES,
|
||||
@@ -1453,6 +1504,12 @@ export class BillingService {
|
||||
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
|
||||
// inline by payInvoice). Anything else means money was captured with
|
||||
// nowhere to land — that needs a person, so say so loudly.
|
||||
logCtx(
|
||||
invoice.status === Freight.InvoiceStatus.Paid
|
||||
? "already-paid"
|
||||
: "captured-with-nowhere-to-land",
|
||||
{ path: "payment.settleInvoice.outcome", mode: "set" },
|
||||
);
|
||||
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||||
this.logger.error(
|
||||
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { BaseRepository, logCtx } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
@@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
authorId?: string,
|
||||
): Promise<BookingReviewNote> {
|
||||
const repo = this.dataSource.getRepository(BookingReviewNote);
|
||||
// Every rejection/cancellation/change-request reason in the booking flow is
|
||||
// written through here — the "why" behind the status change on the same log
|
||||
// line as the status change itself.
|
||||
logCtx(
|
||||
{ bookingId, type, note, authorId },
|
||||
{ path: 'reviewNotes', mode: 'push' },
|
||||
);
|
||||
return repo.save(
|
||||
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Freight, SchedulingStatus } from '@edr/types';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
@@ -2115,6 +2115,21 @@ export class BookingsService {
|
||||
throw new NotFoundException(`Booking ${id} not found`);
|
||||
}
|
||||
|
||||
// Nearly every booking flow loads the booking through here, so this one
|
||||
// call puts the human-searchable reference + entry state on the request log
|
||||
// line for all of them. Entry state only — the write trail (statusChanges)
|
||||
// shows where it ended up.
|
||||
logCtx(
|
||||
{
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
statusAtEntry: booking.status,
|
||||
companyId: booking.companyId,
|
||||
contractId: booking.contractId ?? undefined,
|
||||
},
|
||||
{ path: "booking" },
|
||||
);
|
||||
|
||||
if (booking.files && booking.files.length > 0) {
|
||||
booking.files = await Promise.all(
|
||||
booking.files.map(async (file: FileRecord) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||
@@ -1084,6 +1084,19 @@ export class ContractTransitionService {
|
||||
): Promise<void> {
|
||||
const role = dto.role as ContractSignerRole;
|
||||
|
||||
// Both sign() and counterSign() land here — who signed what, and whether the
|
||||
// ink came from the request or the signer's saved profile signature.
|
||||
logCtx(
|
||||
{
|
||||
contractId: contract.id,
|
||||
reference: contract.reference,
|
||||
role,
|
||||
signerUserId: options.signerUserId,
|
||||
usedDrawnImage: Boolean(dto.signatureImageBase64),
|
||||
},
|
||||
{ path: "contractSignatures", mode: "push" },
|
||||
);
|
||||
|
||||
// Resolve the signature image. The client may send a freshly-drawn image, or
|
||||
// omit it to reuse the signer's saved profile signature. Fall back to the
|
||||
// saved one whenever no image is supplied.
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||
|
||||
import { YardCountry } from '@edr/types';
|
||||
//
|
||||
@@ -820,6 +820,17 @@ export class ContractsService {
|
||||
contract.status = 'EXPIRED';
|
||||
}
|
||||
}
|
||||
// Entry state for every contract flow (submit, approve, sign, suspend…) —
|
||||
// see the equivalent in BookingsService.findById.
|
||||
logCtx(
|
||||
{
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
statusAtEntry: contract.status,
|
||||
companyId: contract.companyId,
|
||||
},
|
||||
{ path: "contract" },
|
||||
);
|
||||
|
||||
if (contract.files && contract.files.length > 0) {
|
||||
contract.files = await Promise.all(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// otp.service.ts
|
||||
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
import { OtpRepository } from "./otp.repository";
|
||||
@@ -317,6 +318,13 @@ export class OtpService {
|
||||
}`;
|
||||
if (result === "ok") this.logger.log(line);
|
||||
else this.logger.warn(line);
|
||||
|
||||
// Outcome only — the target is a phone number / email address and stays out
|
||||
// of the canonical line. Channels are safe and say which one was used.
|
||||
logCtx(
|
||||
{ mode, result, channels: channelsOf(target) },
|
||||
{ path: "otp.verify", mode: "push" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
@@ -111,6 +112,16 @@ export class PaymentClientService {
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
// Every hop to the payment service lands on the request log line: which
|
||||
// call, how slow, and what it answered. A settle that never happened is
|
||||
// almost always one of these coming back 4xx/5xx or timing out.
|
||||
const startedAt = Date.now();
|
||||
const trace = (extra: Record<string, unknown>) =>
|
||||
logCtx(
|
||||
{ method, path, ms: Date.now() - startedAt, ...extra },
|
||||
{ path: "outbound.payment", mode: "push" },
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
@@ -122,9 +133,11 @@ export class PaymentClientService {
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
trace({ status: response.status });
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
trace({ status: err.response.status });
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
@@ -136,6 +149,7 @@ export class PaymentClientService {
|
||||
}
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : String(err);
|
||||
trace({ unreachable: true, error: message });
|
||||
this.logger.error(
|
||||
`payment service unreachable (${method} ${path}): ${message}`,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
@@ -214,8 +215,16 @@ export class PaymentService {
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
referenceId,
|
||||
);
|
||||
logCtx(
|
||||
{ referenceId, paid: result.paid, unverifiable: result.unverifiable },
|
||||
{ path: "payment.reconcile" },
|
||||
);
|
||||
return { paid: result.paid, unverifiable: result.unverifiable };
|
||||
} catch (err) {
|
||||
logCtx(
|
||||
{ referenceId, unverifiable: true, error: (err as Error).message },
|
||||
{ path: "payment.reconcile" },
|
||||
);
|
||||
this.logger.warn(
|
||||
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
|
||||
);
|
||||
@@ -224,6 +233,18 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
logCtx(
|
||||
{
|
||||
referenceId: input.referenceId,
|
||||
source: input.source,
|
||||
orderRef: input.orderRef,
|
||||
method: input.method,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
platform: input.platform,
|
||||
},
|
||||
{ path: "payment.initiate" },
|
||||
);
|
||||
try {
|
||||
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
|
||||
@@ -268,6 +289,17 @@ export class PaymentService {
|
||||
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
intentId: intent.id,
|
||||
providerStatus: snapshot.status,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
merchantOrderId: snapshot.merchantOrderId,
|
||||
immediateSuccess,
|
||||
},
|
||||
{ path: "payment.initiate" },
|
||||
);
|
||||
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
@@ -428,6 +460,17 @@ export class PaymentService {
|
||||
otp,
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
intentId: local.id,
|
||||
refId: local.refId,
|
||||
gatewayIntentId: snapshot.intentId,
|
||||
providerStatus: confirmed.status,
|
||||
failureCode: confirmed.failureCode,
|
||||
},
|
||||
{ path: "payment.confirmOtp" },
|
||||
);
|
||||
|
||||
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.markIntentSucceeded(local.id, {
|
||||
providerTxnId: confirmed.providerTxnId,
|
||||
@@ -460,6 +503,16 @@ export class PaymentService {
|
||||
): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
logCtx(
|
||||
{
|
||||
intentId,
|
||||
refId: intent.refId,
|
||||
priorStatus: intent.status,
|
||||
providerTxnId: opts.providerTxnId,
|
||||
notifyBilling: opts.notify !== false,
|
||||
},
|
||||
{ path: "payment.settle" },
|
||||
);
|
||||
if (intent.status === "success") {
|
||||
// Still notify billing: a prior delivery may have flipped the intent to
|
||||
// success and then died before the invoice settled (the two steps are not
|
||||
@@ -471,6 +524,7 @@ export class PaymentService {
|
||||
opts.paidAt ?? intent.paidAt ?? undefined,
|
||||
);
|
||||
}
|
||||
logCtx(true, { path: "payment.settle.alreadyFinalized", mode: "set" });
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
|
||||
@@ -507,6 +561,15 @@ export class PaymentService {
|
||||
referenceId: string,
|
||||
): Promise<{ acknowledged: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
|
||||
logCtx(
|
||||
{
|
||||
referenceId,
|
||||
intentId: intent?.id,
|
||||
intentStatus: intent?.status ?? "none",
|
||||
method: intent?.method,
|
||||
},
|
||||
{ path: "payment.successRedirect" },
|
||||
);
|
||||
if (!intent || intent.method === "cbe-bill") {
|
||||
return { acknowledged: false };
|
||||
}
|
||||
@@ -533,6 +596,16 @@ export class PaymentService {
|
||||
}): Promise<void> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
logCtx(
|
||||
{
|
||||
intentId: intent.id,
|
||||
refId: intent.refId,
|
||||
priorStatus: intent.status,
|
||||
failureCode: input.failureCode,
|
||||
failureMessage: input.failureMessage,
|
||||
},
|
||||
{ path: "payment.failed" },
|
||||
);
|
||||
if (intent.status === "success" || intent.status === "canceled") return;
|
||||
|
||||
await this.paymentRepo.update(
|
||||
|
||||
@@ -15,21 +15,21 @@ export class StampSettingsController {
|
||||
constructor(private readonly service: StampSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin])
|
||||
@BookingStaff([FREIGHT_PERMS.settings.stamp.view, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
|
||||
get() {
|
||||
return this.service.getView();
|
||||
}
|
||||
|
||||
@Put()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Replace the company stamp" })
|
||||
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({
|
||||
summary: "Clear the company stamp (invoices fall back to the plain seal)",
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
|
||||
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
||||
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
|
||||
|
||||
@@ -165,10 +166,13 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
* actually produce is a backoffice decision, edited in the file-settings editor.
|
||||
*/
|
||||
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
// First on purpose: only the first field of a new set is seeded, and this is
|
||||
// the paper that distinguishes a co-operative from every other company.
|
||||
{
|
||||
fileKey: "tin_certificate",
|
||||
fileLabel: "TIN Certificate",
|
||||
helpText: "Verified against the TIN registry during registration.",
|
||||
fileKey: "cooperative_registration_certificate",
|
||||
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
||||
helpText:
|
||||
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
@@ -177,10 +181,9 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
fileKey: "cooperative_registration_certificate",
|
||||
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
||||
helpText:
|
||||
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
|
||||
fileKey: "tin_certificate",
|
||||
fileLabel: "TIN Certificate",
|
||||
helpText: "Verified against the TIN registry during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
@@ -662,16 +665,15 @@ export class FileUploadSettingsSeeder {
|
||||
async run() {
|
||||
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
|
||||
|
||||
// Seed only into an empty table: any existing rows (including
|
||||
// soft-deleted ones, which would still conflict on the unique `code`)
|
||||
// mean the data is admin-managed, so leave it untouched.
|
||||
const existing = await settingRepository.count({ withDeleted: true });
|
||||
if (existing > 0) {
|
||||
this.logger.log(
|
||||
`file_upload_settings already has ${existing} rows — skipping seed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Seed per CODE, not "only into an empty table". An existing row is
|
||||
// admin-managed and never touched — including a soft-deleted one, which
|
||||
// means the set was removed on purpose (and would still conflict on the
|
||||
// unique `code`). What the table-wide check got wrong is the other half: a
|
||||
// set added to this file after the first boot could never reach a database
|
||||
// that already held the others, so it existed in code and nowhere else.
|
||||
const existingCodes = new Set(
|
||||
(await settingRepository.find({ withDeleted: true })).map((s) => s.code),
|
||||
);
|
||||
|
||||
const allSettings: Array<
|
||||
OnboardingDocumentSetting & { description: string }
|
||||
@@ -701,20 +703,41 @@ export class FileUploadSettingsSeeder {
|
||||
})),
|
||||
];
|
||||
|
||||
// Insert setting rows only — no FileUploadField rows. Fields start empty
|
||||
// and are configured from the backoffice file-settings editor; the field
|
||||
// definitions above are kept as reference defaults.
|
||||
await settingRepository.insert(
|
||||
allSettings.map((documentSetting) => ({
|
||||
code: documentSetting.code,
|
||||
label: documentSetting.label,
|
||||
description: documentSetting.description,
|
||||
entity: documentSetting.entity,
|
||||
})),
|
||||
const missing = allSettings.filter((s) => !existingCodes.has(s.code));
|
||||
if (missing.length === 0) {
|
||||
this.logger.log("file upload settings up to date — nothing to seed");
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = await settingRepository.save(
|
||||
missing.map((documentSetting) =>
|
||||
settingRepository.create({
|
||||
code: documentSetting.code,
|
||||
label: documentSetting.label,
|
||||
description: documentSetting.description,
|
||||
entity: documentSetting.entity,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// A brand-new set gets exactly ONE field: its first reference default. The
|
||||
// rest of the list above stays documentation — what a set actually asks for
|
||||
// is a backoffice decision, edited in the file-settings editor. Seeding one
|
||||
// means a set is never born empty (an empty set silently requires nothing),
|
||||
// while leaving the admin a single row to extend rather than a list to prune.
|
||||
const fieldRepository = this.dataSource.getRepository(FileUploadField);
|
||||
const firstFields = inserted.flatMap((setting) => {
|
||||
const reference = missing.find((s) => s.code === setting.code)?.fields[0];
|
||||
return reference
|
||||
? [fieldRepository.create({ ...reference, settingId: setting.id })]
|
||||
: [];
|
||||
});
|
||||
if (firstFields.length > 0) await fieldRepository.save(firstFields);
|
||||
|
||||
this.logger.log(
|
||||
`Seeded ${allSettings.length} file upload settings with empty fields`,
|
||||
`Seeded ${missing.length} file upload settings (${firstFields.length} with a default field): ${missing
|
||||
.map((s) => s.code)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1190,22 +1190,28 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
"b4b00002-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:settings:stamp:view",
|
||||
"View stamp settings",
|
||||
"View the company stamp",
|
||||
),
|
||||
perm(
|
||||
"b4b00002-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:settings:stamp:manage",
|
||||
"Manage stamp settings",
|
||||
"Manage the company stamp",
|
||||
),
|
||||
// The per-officer approval teeter (ማህተም) — an individual's own stamp +
|
||||
// signature, not the company seal. It used to ride on settings:stamp:*, which
|
||||
// now gates the ONE company stamp; this key was split out when the two were
|
||||
// untangled. `settings:invoice_stamp:*` retired at the same time: it gated the
|
||||
// company stamp before the fold and is deliberately left orphaned in any DB
|
||||
// that already seeded it (the seeder upserts by key and never deletes).
|
||||
perm(
|
||||
"b4b00003-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:settings:invoice_stamp:view",
|
||||
"View invoice stamp settings",
|
||||
"edr_freight_app:settings:teeter:view",
|
||||
"View own approval teeter and signature",
|
||||
),
|
||||
perm(
|
||||
"b4b00003-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:settings:invoice_stamp:manage",
|
||||
"Manage invoice stamp settings",
|
||||
"edr_freight_app:settings:teeter:manage",
|
||||
"Manage own approval teeter and signature",
|
||||
),
|
||||
perm(
|
||||
"b4c00001-0001-4000-8000-000000000001",
|
||||
@@ -1907,15 +1913,18 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:dropdown:view",
|
||||
manage: "edr_freight_app:settings:dropdown:manage",
|
||||
},
|
||||
// The ONE company stamp/seal, applied to every generated document
|
||||
// (invoices, receipts, warehouse papers, the EDR side of contracts).
|
||||
stamp: {
|
||||
view: "edr_freight_app:settings:stamp:view",
|
||||
manage: "edr_freight_app:settings:stamp:manage",
|
||||
},
|
||||
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
|
||||
// from `stamp` above, which is the per-employee approval-record teeter.
|
||||
invoiceStamp: {
|
||||
view: "edr_freight_app:settings:invoice_stamp:view",
|
||||
manage: "edr_freight_app:settings:invoice_stamp:manage",
|
||||
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||
// gate the company stamp before the two were untangled.
|
||||
teeter: {
|
||||
view: "edr_freight_app:settings:teeter:view",
|
||||
manage: "edr_freight_app:settings:teeter:manage",
|
||||
},
|
||||
exchangeRate: {
|
||||
view: "edr_freight_app:settings:exchange_rate:view",
|
||||
|
||||
@@ -51,8 +51,7 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
|
||||
import NoAccessPage from "./pages/NoAccessPage";
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature";
|
||||
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
|
||||
import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage";
|
||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||
@@ -786,23 +785,27 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
The ONE company stamp, for every generated document. The per-officer
|
||||
teeter (ማህተም) that used to sit beside it at /dashboard/stamp-settings
|
||||
now lives at /user-management/teeter-and-signature — it is a different
|
||||
thing (an individual's approval stamp), and pairing the two here was
|
||||
the duplication.
|
||||
*/}
|
||||
<Route
|
||||
path="stamp-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
|
||||
<StampSettings />
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.settings.stamp.view}
|
||||
>
|
||||
<CompanyStampSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Old URL kept alive so existing links/bookmarks do not 404. */}
|
||||
<Route
|
||||
path="invoice-stamp-settings"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
|
||||
>
|
||||
<InvoiceStampSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
element={<Navigate to="/dashboard/stamp-settings" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="contract-templates"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Badge, Card, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface PersonField {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
export interface PersonCardProps {
|
||||
/** OWNER / POA / CONTACT PERSON — the person's role, not their name. */
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
/**
|
||||
* Identity state. `undefined` = this person has no identity check at all
|
||||
* (contact person), so no badge is rendered rather than a misleading "not
|
||||
* verified" one.
|
||||
*/
|
||||
verified?: boolean;
|
||||
/** Extra pills after the verification badge (e.g. "Verifies for this company"). */
|
||||
badges?: ReactNode;
|
||||
/** Rendered between the header and the fields — alerts, match warnings. */
|
||||
notice?: ReactNode;
|
||||
fields: PersonField[];
|
||||
/** Shown when the API returned nothing for every field. */
|
||||
emptyMessage: string;
|
||||
/** Attachments or anything else that belongs to this person. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* One person in the customer's people column: owner, power of attorney, contact
|
||||
* person. Empty fields are dropped rather than rendered as "—", so a field the
|
||||
* API stops sending simply disappears instead of leaving a dead row behind.
|
||||
*/
|
||||
export function PersonCard({
|
||||
title,
|
||||
icon,
|
||||
verified,
|
||||
badges,
|
||||
notice,
|
||||
fields,
|
||||
emptyMessage,
|
||||
children,
|
||||
}: PersonCardProps) {
|
||||
const filled = fields.filter(
|
||||
(f) => f.value != null && String(f.value).trim(),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap="sm">
|
||||
<Group gap={8} wrap="wrap">
|
||||
{icon}
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c="edr-muted"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{verified !== undefined &&
|
||||
(verified ? (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
Fayda verified
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
Not verified
|
||||
</Badge>
|
||||
))}
|
||||
{badges}
|
||||
</Group>
|
||||
|
||||
{notice}
|
||||
|
||||
{filled.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
{filled.map((f) => (
|
||||
<Stack key={f.label} gap={0}>
|
||||
<Text size="xs" c="edr-muted">
|
||||
{f.label}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="edr-text"
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{f.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{children && (
|
||||
<>
|
||||
<Divider />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PersonCard;
|
||||
@@ -24,4 +24,9 @@ export {
|
||||
type ResetPasswordActionProps,
|
||||
} from "./ResetPasswordAction";
|
||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||
export {
|
||||
PersonCard,
|
||||
type PersonCardProps,
|
||||
type PersonField,
|
||||
} from "./PersonCard";
|
||||
export { TableCard, type TableCardProps } from "./TableCard";
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
PackageOpen,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
Stamp,
|
||||
ScrollText,
|
||||
Send,
|
||||
Settings,
|
||||
@@ -486,17 +487,14 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
||||
permission: FREIGHT_PERMS.settings.dropdown.view,
|
||||
},
|
||||
{
|
||||
label: "Stamp settings",
|
||||
// One entry, one stamp. The former "Stamp settings" entry here pointed
|
||||
// at the per-officer teeter (ማህተም), not a company seal — it moved to
|
||||
// /user-management/teeter-and-signature.
|
||||
label: "Company stamp",
|
||||
href: "/dashboard/stamp-settings",
|
||||
icon: <FileSignature />,
|
||||
icon: <Stamp />,
|
||||
permission: FREIGHT_PERMS.settings.stamp.view,
|
||||
},
|
||||
{
|
||||
label: "Invoice stamp",
|
||||
href: "/dashboard/invoice-stamp-settings",
|
||||
icon: <Receipt />,
|
||||
permission: FREIGHT_PERMS.settings.invoiceStamp.view,
|
||||
},
|
||||
{
|
||||
label: "Contract templates",
|
||||
href: "/dashboard/contract-templates",
|
||||
|
||||
@@ -328,15 +328,18 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:dropdown:view",
|
||||
manage: "edr_freight_app:settings:dropdown:manage",
|
||||
},
|
||||
// The ONE company stamp/seal, applied to every generated document
|
||||
// (invoices, receipts, warehouse papers, the EDR side of contracts).
|
||||
stamp: {
|
||||
view: "edr_freight_app:settings:stamp:view",
|
||||
manage: "edr_freight_app:settings:stamp:manage",
|
||||
},
|
||||
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
|
||||
// from `stamp` above, which is the per-employee approval-record teeter.
|
||||
invoiceStamp: {
|
||||
view: "edr_freight_app:settings:invoice_stamp:view",
|
||||
manage: "edr_freight_app:settings:invoice_stamp:manage",
|
||||
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||
// gate the company stamp before the two were untangled.
|
||||
teeter: {
|
||||
view: "edr_freight_app:settings:teeter:view",
|
||||
manage: "edr_freight_app:settings:teeter:manage",
|
||||
},
|
||||
exchangeRate: {
|
||||
view: "edr_freight_app:settings:exchange_rate:view",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Banknote,
|
||||
Contact,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
FilePen,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
UserCheck,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -46,6 +49,7 @@ import {
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
PaymentStatusBadge,
|
||||
PersonCard,
|
||||
ProfileApprovalActions,
|
||||
ProfileChips,
|
||||
ProfileStatusBadge,
|
||||
@@ -259,7 +263,9 @@ export default function CustomerDetailPage() {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`View ${f.name}`}
|
||||
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</ActionIcon>
|
||||
@@ -268,7 +274,9 @@ export default function CustomerDetailPage() {
|
||||
type="button"
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
style={{
|
||||
maxWidth: 170,
|
||||
textAlign: "left",
|
||||
@@ -615,7 +623,10 @@ export default function CustomerDetailPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const licenseProfiles = (company?.companyProfiles ?? []).filter(
|
||||
/** Never read `company.companyProfiles` directly — an endpoint that stops
|
||||
* loading the relation would otherwise crash the whole page. */
|
||||
const profiles = company?.companyProfiles ?? [];
|
||||
const licenseProfiles = profiles.filter(
|
||||
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||
);
|
||||
|
||||
@@ -629,14 +640,13 @@ export default function CustomerDetailPage() {
|
||||
[documents],
|
||||
);
|
||||
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
||||
const poaFields = [
|
||||
{ label: "PoA name", value: company?.poaName },
|
||||
{ label: "PoA email", value: company?.poaEmail },
|
||||
{ label: "PoA phone", value: company?.poaPhone },
|
||||
{ label: "PoA location", value: company?.poaLocation },
|
||||
{ label: "PoA address", value: company?.poaAddress },
|
||||
];
|
||||
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
|
||||
const hasPoaDetails = [
|
||||
company?.poaName,
|
||||
company?.poaEmail,
|
||||
company?.poaPhone,
|
||||
company?.poaLocation,
|
||||
company?.poaAddress,
|
||||
].some((v) => v?.trim());
|
||||
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
|
||||
// this page can never disagree with the rule the API actually enforces.
|
||||
const identityState = company?.identity;
|
||||
@@ -645,9 +655,7 @@ export default function CustomerDetailPage() {
|
||||
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
|
||||
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
||||
// and DARS delegation paper both — is mandatory rather than optional.
|
||||
const poaMandatory = (company?.companyProfiles ?? []).some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
const poaMandatory = profiles.some((p) => p.type === "freight_forwarder");
|
||||
const delegationMissing =
|
||||
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
|
||||
|
||||
@@ -685,8 +693,9 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
@@ -749,7 +758,7 @@ export default function CustomerDetailPage() {
|
||||
items={[
|
||||
{
|
||||
label: "Profiles",
|
||||
value: company.companyProfiles.length,
|
||||
value: profiles.length,
|
||||
icon: IdCard,
|
||||
color: "edr-green",
|
||||
},
|
||||
@@ -761,9 +770,7 @@ export default function CustomerDetailPage() {
|
||||
: "Pending approval",
|
||||
value: stillOnboarding
|
||||
? "—"
|
||||
: company.companyProfiles.filter(
|
||||
(p) => p.status === "pending",
|
||||
).length,
|
||||
: profiles.filter((p) => p.status === "pending").length,
|
||||
icon: IdCard,
|
||||
color: "yellow",
|
||||
},
|
||||
@@ -782,407 +789,392 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} c="edr-text">
|
||||
Company information
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField label="VAT number" value={company.vatNumber} />
|
||||
<InfoField label="FAN number" value={company.fanNumber} />
|
||||
<InfoField
|
||||
label="Submitted on"
|
||||
value={formatDate(company.createdAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Approved on"
|
||||
value={
|
||||
company.approvedAt
|
||||
? formatDate(company.approvedAt)
|
||||
: "Not yet approved"
|
||||
}
|
||||
/>
|
||||
<InfoField
|
||||
label="Owner identity"
|
||||
value={
|
||||
ownerIdentity?.verified
|
||||
? "Fayda verified"
|
||||
: ownerIdentity?.passportNumber
|
||||
? `Passport ${ownerIdentity.passportNumber}`
|
||||
: "Not verified"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Country" value={company.country} />
|
||||
<InfoField
|
||||
label="Nationality"
|
||||
value={
|
||||
company.nationality
|
||||
? humanize(company.nationality)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* Why this company's registration was typed rather than
|
||||
fetched, and why it carries no business licence. */}
|
||||
<InfoField
|
||||
label="Registration"
|
||||
value={
|
||||
company.cooperative
|
||||
? "Co-operative union / farm (no trade licence)"
|
||||
: "eTrade trade licence"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Address" value={company.address} />
|
||||
<InfoField label="Website" value={company.website} />
|
||||
<InfoField label="Email" value={company.email} />
|
||||
<InfoField label="Phone" value={company.phone} />
|
||||
<Box />
|
||||
<InfoField
|
||||
label="Contact person"
|
||||
value={company.contactPersonName}
|
||||
/>
|
||||
<InfoField
|
||||
label="Contact phone"
|
||||
value={company.contactPersonPhone}
|
||||
/>
|
||||
<Box />
|
||||
<InfoField label="Owner" value={company.ownerName} />
|
||||
<InfoField label="Owner email" value={company.ownerEmail} />
|
||||
<InfoField label="Owner phone" value={company.ownerPhone} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord && (
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
aria-label="Download TIN record"
|
||||
onClick={() => downloadTinRecord(company)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField
|
||||
label="License number"
|
||||
value={company.licenceNumber}
|
||||
/>
|
||||
<InfoField label="Status" value={company.statusDescription} />
|
||||
<InfoField
|
||||
label="Date registered"
|
||||
value={company.dateRegistered}
|
||||
/>
|
||||
<InfoField label="Renewed from" value={company.renewedFrom} />
|
||||
<InfoField label="Renewal date" value={company.renewalDate} />
|
||||
<InfoField label="Renewed to" value={company.renewedTo} />
|
||||
<InfoField label="Region" value={company.region} />
|
||||
<InfoField label="Zone" value={company.zone} />
|
||||
<InfoField label="Woreda" value={company.woreda} />
|
||||
<InfoField label="Kebele" value={company.kebele} />
|
||||
<InfoField label="House No" value={company.houseNo} />
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No eTrade registration record on file for this customer's
|
||||
TIN.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
Owner identity
|
||||
</Text>
|
||||
{identityState?.subject === "owner" && (
|
||||
<Badge size="sm" color="blue" variant="light">
|
||||
Verifies for this company
|
||||
</Badge>
|
||||
)}
|
||||
{ownerIdentity?.verified ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Fayda verified
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
Not verified
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* THE check: is the owner the company put forward the person
|
||||
the eTrade licence actually names? Advisory — eTrade and
|
||||
Fayda transliterate Amharic names differently, so this is a
|
||||
prompt to look, not a verdict. */}
|
||||
{identityState?.ownerMatchesEtrade === false ? (
|
||||
<Alert
|
||||
color="amber"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Does not match the eTrade licence"
|
||||
>
|
||||
The licence names{" "}
|
||||
<strong>{identityState.etradeManagerName}</strong>, but this
|
||||
company recorded <strong>{company.ownerName}</strong>.
|
||||
</Alert>
|
||||
) : identityState?.ownerMatchesEtrade === true ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Matches the eTrade licence
|
||||
</Badge>
|
||||
) : company.cooperative ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
A co-operative union or farm holds no trade licence, so
|
||||
there is no eTrade record to check the owner against.
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
No eTrade manager name on file to compare against.
|
||||
</Text>
|
||||
)}
|
||||
{ownerIdentity?.verified ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField label="Name" value={ownerIdentity.name} />
|
||||
<InfoField label="Phone" value={ownerIdentity.phone} />
|
||||
<InfoField label="Email" value={ownerIdentity.email} />
|
||||
<InfoField label="Address" value={ownerIdentity.address} />
|
||||
<InfoField
|
||||
label="Verified at"
|
||||
value={formatDate(ownerIdentity.verifiedAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Birthdate"
|
||||
value={ownerIdentity.birthdate}
|
||||
/>
|
||||
<InfoField label="Gender" value={ownerIdentity.gender} />
|
||||
<InfoField
|
||||
label="Passport number"
|
||||
value={ownerIdentity.passportNumber}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{ownerIdentity?.passportNumber
|
||||
? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
|
||||
: "The company owner has not verified their identity with Fayda."}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
Power of Attorney
|
||||
</Text>
|
||||
{poaMandatory && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Required for freight forwarder
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{delegationMissing ? (
|
||||
<Badge size="sm" color="red" variant="light">
|
||||
DARS delegation paper missing
|
||||
</Badge>
|
||||
) : poaLive.length > 0 ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
DARS delegation paper on file
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
Not provided
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{hasPoaDetails ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{poaFields.map((f) => (
|
||||
<InfoField
|
||||
key={f.label}
|
||||
label={f.label}
|
||||
value={f.value}
|
||||
/>
|
||||
))}
|
||||
<InfoField
|
||||
label="PoA Fayda"
|
||||
value={
|
||||
poaIdentity?.verified ? "Verified" : "Not verified"
|
||||
}
|
||||
/>
|
||||
{poaIdentity?.verified && (
|
||||
<>
|
||||
<Grid gap="lg" align="flex-start">
|
||||
{/* Company facts — the wide column. People live in the narrow one
|
||||
beside it, so nothing about a person is stated twice. */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} c="edr-text">
|
||||
Company information
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField
|
||||
label="PoA verified at"
|
||||
value={formatDate(poaIdentity.verifiedAt)}
|
||||
label="VAT number"
|
||||
value={company.vatNumber}
|
||||
/>
|
||||
<InfoField label="Country" value={company.country} />
|
||||
<InfoField
|
||||
label="Nationality"
|
||||
value={
|
||||
company.nationality
|
||||
? humanize(company.nationality)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* Why this company's registration was typed rather
|
||||
than fetched, and why it carries no licence. */}
|
||||
<InfoField
|
||||
label="Registration"
|
||||
value={
|
||||
company.cooperative
|
||||
? "Co-operative union / farm (no trade licence)"
|
||||
: "eTrade trade licence"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Address" value={company.address} />
|
||||
<InfoField label="Email" value={company.email} />
|
||||
<InfoField label="Phone" value={company.phone} />
|
||||
<InfoField label="Website" value={company.website} />
|
||||
<InfoField
|
||||
label="Submitted on"
|
||||
value={formatDate(company.createdAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="PoA birthdate"
|
||||
value={poaIdentity.birthdate}
|
||||
label="Approved on"
|
||||
value={
|
||||
company.approvedAt
|
||||
? formatDate(company.approvedAt)
|
||||
: "Not yet approved"
|
||||
}
|
||||
/>
|
||||
<InfoField label="PoA gender" value={poaIdentity.gender} />
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No Power of Attorney representative recorded for this
|
||||
customer.
|
||||
</Text>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="edr-muted"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.04em" }}
|
||||
>
|
||||
DARS delegation paper
|
||||
</Text>
|
||||
|
||||
{documentsQuery.isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading documents…
|
||||
</Text>
|
||||
</Group>
|
||||
) : documentsQuery.isError ? (
|
||||
<Group gap="sm">
|
||||
<Text size="sm" c="red">
|
||||
Failed to load documents.
|
||||
</Text>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => void documentsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Anchor>
|
||||
</Group>
|
||||
) : poaDocuments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No DARS delegation paper uploaded.
|
||||
</Text>
|
||||
) : (
|
||||
poaDocuments.map((doc) => (
|
||||
<Group key={doc.id} justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Paperclip
|
||||
size={14}
|
||||
className="shrink-0 text-edr-muted"
|
||||
/>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(view)
|
||||
}
|
||||
>
|
||||
{doc.name}
|
||||
</Anchor>
|
||||
<Text size="xs" c="dimmed" className="shrink-0">
|
||||
{formatBytes(doc.size)} ·{" "}
|
||||
{formatDate(doc.uploadedAt)}
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color="yellow"
|
||||
variant="light"
|
||||
className="shrink-0"
|
||||
>
|
||||
Pending approval
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{hasEtradeRecord && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Preview ${doc.name}`}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(view)
|
||||
}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void downloadBookingFile(doc.id, doc.name)
|
||||
}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Download ${doc.name}`}
|
||||
variant="default"
|
||||
aria-label="Download TIN record"
|
||||
onClick={() => downloadTinRecord(company)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
{hasEtradeRecord ? (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="lg"
|
||||
>
|
||||
<InfoField
|
||||
label="License number"
|
||||
value={company.licenceNumber}
|
||||
/>
|
||||
<InfoField
|
||||
label="Status"
|
||||
value={company.statusDescription}
|
||||
/>
|
||||
<InfoField
|
||||
label="Date registered"
|
||||
value={company.dateRegistered}
|
||||
/>
|
||||
<InfoField
|
||||
label="Renewed from"
|
||||
value={company.renewedFrom}
|
||||
/>
|
||||
<InfoField
|
||||
label="Renewal date"
|
||||
value={company.renewalDate}
|
||||
/>
|
||||
<InfoField
|
||||
label="Renewed to"
|
||||
value={company.renewedTo}
|
||||
/>
|
||||
<InfoField label="Region" value={company.region} />
|
||||
<InfoField label="Zone" value={company.zone} />
|
||||
<InfoField label="Woreda" value={company.woreda} />
|
||||
<InfoField label="Kebele" value={company.kebele} />
|
||||
<InfoField label="House No" value={company.houseNo} />
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No eTrade registration record on file for this
|
||||
customer's TIN.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} c="edr-text">
|
||||
Role profiles
|
||||
</Text>
|
||||
<ProfileChips profiles={company.companyProfiles} />
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={company.companyProfiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} c="edr-text">
|
||||
Role profiles
|
||||
</Text>
|
||||
<ProfileChips profiles={profiles} />
|
||||
</Group>
|
||||
{/* Narrower than the old full-width layout — the table
|
||||
shares the row with the people column now. */}
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={760}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={profiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* People: owner, then power of attorney, then contact person —
|
||||
the order a reviewer checks them in. */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="md">
|
||||
<PersonCard
|
||||
title="Owner"
|
||||
icon={<UserRound size={15} className="text-edr-muted" />}
|
||||
verified={Boolean(ownerIdentity?.verified)}
|
||||
badges={
|
||||
<>
|
||||
{identityState?.subject === "owner" && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Verifies for this company
|
||||
</Badge>
|
||||
)}
|
||||
{identityState?.ownerMatchesEtrade === true && (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
Matches eTrade licence
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
notice={
|
||||
/* THE check: is the owner the company put forward the
|
||||
person the eTrade licence actually names? Advisory —
|
||||
eTrade and Fayda transliterate Amharic names
|
||||
differently, so this is a prompt to look, not a
|
||||
verdict. */
|
||||
identityState?.ownerMatchesEtrade === false ? (
|
||||
<Alert
|
||||
color="amber"
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Does not match the eTrade licence"
|
||||
>
|
||||
<Text size="xs">
|
||||
The licence names{" "}
|
||||
<strong>{identityState.etradeManagerName}</strong>,
|
||||
but this company recorded{" "}
|
||||
<strong>{company.ownerName ?? "nobody"}</strong>.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : !ownerIdentity?.verified &&
|
||||
ownerIdentity?.passportNumber ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Identified by passport rather than Fayda.
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
fields={[
|
||||
{
|
||||
label: "Name",
|
||||
value: company.ownerName ?? ownerIdentity?.name,
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: company.ownerEmail ?? ownerIdentity?.email,
|
||||
},
|
||||
{
|
||||
label: "Phone",
|
||||
value: company.ownerPhone ?? ownerIdentity?.phone,
|
||||
},
|
||||
{
|
||||
label: "Passport number",
|
||||
value: ownerIdentity?.passportNumber,
|
||||
},
|
||||
{ label: "Address", value: ownerIdentity?.address },
|
||||
{ label: "Birthdate", value: ownerIdentity?.birthdate },
|
||||
{ label: "Gender", value: ownerIdentity?.gender },
|
||||
{
|
||||
label: "Verified on",
|
||||
value: ownerIdentity?.verifiedAt
|
||||
? formatDate(ownerIdentity.verifiedAt)
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
emptyMessage="No owner recorded for this company."
|
||||
/>
|
||||
|
||||
<PersonCard
|
||||
title="Power of attorney"
|
||||
icon={<UserCheck size={15} className="text-edr-muted" />}
|
||||
verified={
|
||||
// No PoA at all → no badge, rather than a "not verified"
|
||||
// that reads as a problem where none exists.
|
||||
hasPoaDetails || identityState?.poaDeclared === "yes"
|
||||
? Boolean(poaIdentity?.verified)
|
||||
: undefined
|
||||
}
|
||||
badges={
|
||||
<>
|
||||
{identityState?.subject === "poa" && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Verifies for this company
|
||||
</Badge>
|
||||
)}
|
||||
{poaMandatory && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Required for freight forwarder
|
||||
</Badge>
|
||||
)}
|
||||
{delegationMissing && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
Delegation paper missing
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Name", value: company.poaName },
|
||||
{ label: "Email", value: company.poaEmail },
|
||||
{ label: "Phone", value: company.poaPhone },
|
||||
{ label: "Location", value: company.poaLocation },
|
||||
{ label: "Address", value: company.poaAddress },
|
||||
{ label: "Birthdate", value: poaIdentity?.birthdate },
|
||||
{ label: "Gender", value: poaIdentity?.gender },
|
||||
{
|
||||
label: "Verified on",
|
||||
value: poaIdentity?.verifiedAt
|
||||
? formatDate(poaIdentity.verifiedAt)
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
emptyMessage="No representative recorded for this customer."
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="edr-muted">
|
||||
DARS delegation paper
|
||||
</Text>
|
||||
|
||||
{documentsQuery.isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading…
|
||||
</Text>
|
||||
</Group>
|
||||
) : documentsQuery.isError ? (
|
||||
<Group gap="sm">
|
||||
<Text size="sm" c="red">
|
||||
Failed to load documents.
|
||||
</Text>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => void documentsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Anchor>
|
||||
</Group>
|
||||
) : poaDocuments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Not uploaded.
|
||||
</Text>
|
||||
) : (
|
||||
poaDocuments.map((doc) => (
|
||||
<Stack key={doc.id} gap={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Paperclip
|
||||
size={13}
|
||||
className="shrink-0 text-edr-muted"
|
||||
/>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
style={{ flex: 1, textAlign: "left" }}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(
|
||||
view,
|
||||
)
|
||||
}
|
||||
>
|
||||
{doc.name}
|
||||
</Anchor>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Preview ${doc.name}`}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(
|
||||
view,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
component="button"
|
||||
type="button"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Download ${doc.name}`}
|
||||
onClick={() =>
|
||||
void downloadBookingFile(doc.id, doc.name)
|
||||
}
|
||||
>
|
||||
<Download size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group gap={6} pl={19} wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatBytes(doc.size)} ·{" "}
|
||||
{formatDate(doc.uploadedAt)}
|
||||
</Text>
|
||||
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Pending approval
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</PersonCard>
|
||||
|
||||
<PersonCard
|
||||
title="Contact person"
|
||||
icon={<Contact size={15} className="text-edr-muted" />}
|
||||
fields={[
|
||||
{ label: "Name", value: company.contactPersonName },
|
||||
{ label: "Phone", value: company.contactPersonPhone },
|
||||
]}
|
||||
emptyMessage="No contact person recorded."
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
invoicesQuery.isError
|
||||
? {
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
|
||||
@@ -17,10 +17,16 @@ import {
|
||||
} from "@/hooks/useStampSettings";
|
||||
|
||||
/**
|
||||
* The one company stamp/seal stamped onto every generated invoice/receipt
|
||||
* PDF (InvoiceDocumentService). Single global image — no per-employee choice.
|
||||
* The ONE company stamp/seal, read by every document path server-side via
|
||||
* StampSettingsService: invoices and receipts (InvoiceDocumentService),
|
||||
* warehouse release + handover papers, and the EDR side of contract signature
|
||||
* blocks. Single global image — no per-employee choice, and staff never upload
|
||||
* one when signing.
|
||||
*
|
||||
* Not to be confused with the per-officer teeter (ማህተም) at
|
||||
* /user-management/teeter-and-signature, which is genuinely per-person.
|
||||
*/
|
||||
export default function InvoiceStampSettingsPage() {
|
||||
export default function CompanyStampSettingsPage() {
|
||||
const { data, isLoading } = useStampSettingsQuery();
|
||||
const setStamp = useSetStamp();
|
||||
const clearStamp = useClearStamp();
|
||||
@@ -47,11 +53,13 @@ export default function InvoiceStampSettingsPage() {
|
||||
<div className="p-4 w-full max-w-screen-sm mx-auto">
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Invoice stamp</CardTitle>
|
||||
<CardTitle>Company stamp</CardTitle>
|
||||
<CardDescription>
|
||||
Stamped onto every generated invoice and receipt PDF. Replacing it
|
||||
here changes it everywhere at once — there is no per-invoice or
|
||||
per-user choice.
|
||||
The single EDR seal, applied to every generated document — invoices
|
||||
and receipts, warehouse release and handover papers, and the EDR
|
||||
side of signed contracts. Replacing it here changes it everywhere at
|
||||
once; there is no per-document, per-invoice or per-employee choice.
|
||||
Staff do not upload their own.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -3,7 +3,7 @@ import { Navigate, Outlet, Route } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing";
|
||||
import { isSuperAdmin } from "@/lib/permissions";
|
||||
import { FREIGHT_PERMS, isSuperAdmin } from "@/lib/permissions";
|
||||
import { WithPermission } from "@/shared/hooks/useHas";
|
||||
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
|
||||
import TemplatePage from "@/super-admin/components/templates/components/templates";
|
||||
@@ -44,6 +44,8 @@ import { SidebarProvider } from "@/shared/common/ui/sidebar";
|
||||
import { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext";
|
||||
import { PermissionProvider } from "@/shared/context/PermissionContext";
|
||||
import UserManagementPage from "@/pages/UserManagementPage";
|
||||
import { RequirePermission } from "@/components/auth/RequirePermission";
|
||||
import UploadTeeterAndSignature from "@/record-management/components/Settings/uploadTeeterandSingature";
|
||||
|
||||
/**
|
||||
* Provider shell for the vendored IAM UI. Feeds its Auth + Permission contexts
|
||||
@@ -138,6 +140,29 @@ export function UserManagementRoutes(): ReactElement {
|
||||
path="user-management/position-management"
|
||||
element={<PositionManagementPage />}
|
||||
/>
|
||||
{/*
|
||||
The per-officer teeter (ማህተም) + signature upload. This
|
||||
is NOT the company stamp: it is the individual approval
|
||||
stamp a record officer applies to records, locale-aware
|
||||
(am/en) and genuinely per-person. It used to sit at
|
||||
/dashboard/stamp-settings under Settings, next to the
|
||||
single global company stamp, which read as duplication.
|
||||
|
||||
Gated by settings:teeter:*, split out of settings:stamp:*
|
||||
when the two were untangled — settings:stamp:* now means
|
||||
the company stamp, so anyone who held it for the teeter
|
||||
needs the new key granted.
|
||||
*/}
|
||||
<Route
|
||||
path="user-management/teeter-and-signature"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.settings.teeter.view}
|
||||
>
|
||||
<UploadTeeterAndSignature />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="user-management/migrated-records-management"
|
||||
element={<MigratedDataManagementPage />}
|
||||
|
||||
@@ -169,7 +169,8 @@ export default function OnboardingWizardDialog({
|
||||
setCooperative(checked);
|
||||
if (checked) {
|
||||
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
||||
setNationality((prev) => (prev === "foreign" ? "ethiopian" : prev));
|
||||
// Ethiopian is then the only answer left, so it is made rather than asked.
|
||||
setNationality("ethiopian");
|
||||
}
|
||||
}, []);
|
||||
const [documentFiles, setDocumentFiles] = useState<
|
||||
|
||||
@@ -50,7 +50,7 @@ function buildLicenseSetting(
|
||||
isMultiple: true,
|
||||
maxFiles: 10,
|
||||
allowedExtensions: ["pdf", "png", "jpg", "jpeg"],
|
||||
maxSizeMb: 10,
|
||||
maxSizeMb: 25,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -29,7 +29,11 @@ export default function NationalitySelect({
|
||||
<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."
|
||||
description={
|
||||
excludeForeign
|
||||
? "Registered in Ethiopia. You'll provide a TIN certificate, your co-operative registration certificate and national ID."
|
||||
: "Registered in Ethiopia. You'll provide a TIN certificate, commercial registration and national ID."
|
||||
}
|
||||
icon={<MapPin size={22} />}
|
||||
selected={value === "ethiopian"}
|
||||
onClick={() => onChange("ethiopian")}
|
||||
|
||||
@@ -88,20 +88,48 @@ export class FareEngineService {
|
||||
where: {
|
||||
routeId: route.id,
|
||||
seatClassId: dto.seatClassId,
|
||||
originStopSequence: originStop.sequence,
|
||||
destinationStopSequence: destStop.sequence,
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
originStopSequence: originStop.sequence,
|
||||
destinationStopSequence: destStop.sequence,
|
||||
},
|
||||
{
|
||||
originStopSequence: destStop.sequence,
|
||||
destinationStopSequence: originStop.sequence,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
],
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
nationality: dto.nationality ?? null,
|
||||
},
|
||||
}) ?? await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: route.id,
|
||||
seatClassId: dto.seatClassId,
|
||||
originStopSequence: originStop.sequence,
|
||||
destinationStopSequence: destStop.sequence,
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
originStopSequence: originStop.sequence,
|
||||
destinationStopSequence: destStop.sequence,
|
||||
},
|
||||
{
|
||||
originStopSequence: destStop.sequence,
|
||||
destinationStopSequence: originStop.sequence,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
],
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
nationality: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell,
|
||||
} from 'recharts';
|
||||
import { bookingsApi } from '@/lib/api';
|
||||
import { dashboardApi } from '@/lib/api/dashboard';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
@@ -62,12 +61,6 @@ export default function ReportsPage() {
|
||||
const dates = getDateRange();
|
||||
|
||||
// Confirmed-ticket revenue — same source as dashboard
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['backoffice-stats'],
|
||||
queryFn: dashboardApi.getBackofficeStats,
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
const { data: exchangeRates = [] } = useQuery<any[]>({
|
||||
queryKey: ['currencies'],
|
||||
queryFn: () => apiClient.get('/currencies'),
|
||||
@@ -92,19 +85,13 @@ export default function ReportsPage() {
|
||||
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
|
||||
}, 0);
|
||||
|
||||
const normalRows = stats?.revenueByCurrency ?? [];
|
||||
const packageRows = stats?.packageRevenueByCurrency ?? [];
|
||||
const normalGrand = calcGrand(normalRows);
|
||||
const packageGrand = calcGrand(packageRows);
|
||||
const overallGrand = normalGrand + packageGrand;
|
||||
|
||||
// Bookings for charts / status distribution
|
||||
const { data: bookingsData, isLoading: bookingsLoading } = useQuery({
|
||||
queryKey: ['all-bookings'],
|
||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||
queryKey: ['all-bookings', dates.startDate, dates.endDate],
|
||||
queryFn: () => bookingsApi.getAll({ pageSize: 5000, dateFrom: dates.startDate, dateTo: dates.endDate }),
|
||||
});
|
||||
|
||||
const isLoading = statsLoading || bookingsLoading;
|
||||
const isLoading = bookingsLoading;
|
||||
|
||||
const allBookings: any[] = Array.isArray(bookingsData?.items) ? bookingsData.items : [];
|
||||
|
||||
@@ -113,7 +100,7 @@ export default function ReportsPage() {
|
||||
return d >= dates.startDate && d <= dates.endDate;
|
||||
});
|
||||
|
||||
const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
|
||||
const confirmedBookings = bookings.filter((b: any) => b.status === 'CONFIRMED' || b.status === 'BOARDED');
|
||||
|
||||
const totalRevenueMinor = confirmedBookings.reduce((sum, b: any) => {
|
||||
const rate = toEtbRate(b.currency);
|
||||
@@ -123,6 +110,20 @@ export default function ReportsPage() {
|
||||
const totalBookingsCount = confirmedBookings.length;
|
||||
const totalTicketsCount = confirmedBookings.reduce((sum, b: any) => sum + getBookingTicketCount(b), 0);
|
||||
|
||||
const totalRegularTicketsCount = confirmedBookings
|
||||
.filter((b: any) => {
|
||||
const bookingType = String(b.bookingType || '').toUpperCase();
|
||||
return bookingType !== 'PACKAGE' && !b.packageId;
|
||||
})
|
||||
.reduce((sum: number, b: any) => sum + getBookingTicketCount(b), 0);
|
||||
|
||||
const totalPackageTicketsCount = confirmedBookings
|
||||
.filter((b: any) => {
|
||||
const bookingType = String(b.bookingType || '').toUpperCase();
|
||||
return bookingType === 'PACKAGE' || Boolean(b.packageId);
|
||||
})
|
||||
.reduce((sum: number, b: any) => sum + getBookingTicketCount(b), 0);
|
||||
|
||||
const byDate = confirmedBookings.reduce((acc: Record<string, any>, b: any) => {
|
||||
const date = new Date(b.createdAt).toISOString().split('T')[0];
|
||||
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
|
||||
@@ -151,6 +152,34 @@ export default function ReportsPage() {
|
||||
return bookingType === 'PACKAGE' || Boolean(b.packageId);
|
||||
}).length;
|
||||
|
||||
const normalRows = confirmedBookings
|
||||
.filter((b: any) => {
|
||||
const bookingType = String(b.bookingType || '').toUpperCase();
|
||||
return bookingType !== 'PACKAGE' && !b.packageId;
|
||||
})
|
||||
.reduce((acc: Record<string, number>, b: any) => {
|
||||
const currency = b.currency || 'ETB';
|
||||
acc[currency] = (acc[currency] || 0) + (b.totalMinor || 0);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const packageRows = confirmedBookings
|
||||
.filter((b: any) => {
|
||||
const bookingType = String(b.bookingType || '').toUpperCase();
|
||||
return bookingType === 'PACKAGE' || Boolean(b.packageId);
|
||||
})
|
||||
.reduce((acc: Record<string, number>, b: any) => {
|
||||
const currency = b.currency || 'ETB';
|
||||
acc[currency] = (acc[currency] || 0) + (b.totalMinor || 0);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const normalRowData = Object.entries(normalRows).map(([currency, totalMinor]) => ({ currency, totalMinor }));
|
||||
const packageRowData = Object.entries(packageRows).map(([currency, totalMinor]) => ({ currency, totalMinor }));
|
||||
const normalGrand = calcGrand(normalRowData);
|
||||
const packageGrand = calcGrand(packageRowData);
|
||||
const overallGrand = normalGrand + packageGrand;
|
||||
|
||||
const filteredRouteBookings = confirmedBookings.filter((b: any) => {
|
||||
const origin = b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown';
|
||||
const destination = b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown';
|
||||
@@ -201,8 +230,8 @@ export default function ReportsPage() {
|
||||
`th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>` +
|
||||
`<h2>Revenue Report — ${esc(dates.startDate)} to ${esc(dates.endDate)}</h2>` +
|
||||
`<p>Total Revenue: ${esc(formatCurrency(overallGrand, 'ETB'))} | ` +
|
||||
`Bookings: ${esc(String(stats?.totalBookings ?? 0))} | ` +
|
||||
`Tickets: ${esc(String(stats?.totalTickets ?? 0))}</p>` +
|
||||
`Bookings: ${esc(String(totalBookingsCount))} | ` +
|
||||
`Tickets: ${esc(String(totalTicketsCount))}</p>` +
|
||||
`<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></body></html>`
|
||||
);
|
||||
w.document.close(); w.print();
|
||||
@@ -309,11 +338,11 @@ export default function ReportsPage() {
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(normalGrand, 'ETB')}</span>
|
||||
<span className="font-semibold tabular-nums">{isLoading ? '—' : formatCurrency(normalGrand, 'ETB')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(packageGrand, 'ETB')}</span>
|
||||
<span className="font-semibold tabular-nums">{isLoading ? '—' : formatCurrency(packageGrand, 'ETB')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,9 +415,9 @@ export default function ReportsPage() {
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
||||
Revenue Breakdown — Confirmed Tickets
|
||||
</h2>
|
||||
{statsLoading ? (
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : !normalRows.length && !packageRows.length ? (
|
||||
) : !normalRowData.length && !packageRowData.length ? (
|
||||
<p className="text-sm text-muted-foreground">No revenue data yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
@@ -397,12 +426,12 @@ export default function ReportsPage() {
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Regular</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{(stats?.totalNormalBookings ?? 0).toLocaleString()} bookings · {(stats?.totalNormalTickets ?? 0).toLocaleString()} tickets
|
||||
{totalRegularBookingsCount.toLocaleString()} bookings · {totalRegularTicketsCount.toLocaleString()} tickets
|
||||
</span>
|
||||
</div>
|
||||
{normalRows.length === 0
|
||||
{normalRowData.length === 0
|
||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
: normalRows.map(renderCurrencyRow)}
|
||||
: normalRowData.map(renderCurrencyRow)}
|
||||
{normalRows.length > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
|
||||
@@ -416,13 +445,13 @@ export default function ReportsPage() {
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Package</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{(stats?.totalPackageBookings ?? 0).toLocaleString()} bookings · {(stats?.totalPackageTickets ?? 0).toLocaleString()} tickets
|
||||
{totalPackageBookingsCount.toLocaleString()} bookings · {totalPackageTicketsCount.toLocaleString()} tickets
|
||||
</span>
|
||||
</div>
|
||||
{packageRows.length === 0
|
||||
{packageRowData.length === 0
|
||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
: packageRows.map(renderCurrencyRow)}
|
||||
{packageRows.length > 0 && (
|
||||
: packageRowData.map(renderCurrencyRow)}
|
||||
{packageRowData.length > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
|
||||
<span className="text-sm font-bold tabular-nums">{formatCurrency(packageGrand, 'ETB')}</span>
|
||||
@@ -431,7 +460,7 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!statsLoading && (normalRows.length > 0 || packageRows.length > 0) && (
|
||||
{(normalRowData.length > 0 || packageRowData.length > 0) && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-4 py-3 mt-4">
|
||||
<span className="text-sm font-semibold text-muted-foreground">Grand Total (ETB equivalent)</span>
|
||||
<span className="text-lg font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
|
||||
@@ -637,17 +666,17 @@ export default function ReportsPage() {
|
||||
<h3 className="text-base font-semibold mb-4">Summary</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{[
|
||||
{ label: 'Active Days', value: chartData.length, fromStats: false },
|
||||
{ label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false },
|
||||
{ label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false },
|
||||
{ label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false },
|
||||
{ label: 'Regular Bookings', value: totalRegularBookingsCount, fromStats: false },
|
||||
{ label: 'Package Bookings', value: totalPackageBookingsCount, fromStats: false },
|
||||
].map(({ label, value, fromStats }) => (
|
||||
{ label: 'Active Days', value: chartData.length },
|
||||
{ label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
|
||||
{ label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length },
|
||||
{ label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length },
|
||||
{ label: 'Regular Bookings', value: totalRegularBookingsCount },
|
||||
{ label: 'Package Bookings', value: totalPackageBookingsCount },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="border border-border rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-xl font-bold mt-1 tabular-nums">
|
||||
{fromStats && statsLoading ? '—' : value.toLocaleString()}
|
||||
{value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
RequestLogContext,
|
||||
@@ -58,9 +58,6 @@ const userId = (req: LoggedRequest): string | undefined => {
|
||||
*/
|
||||
@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();
|
||||
@@ -86,10 +83,14 @@ export class RequestLogMiddleware implements NestMiddleware {
|
||||
const status = res.statusCode;
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
this.logger.log(`${req.method} ${url} ${status} ${durationMs}ms`);
|
||||
|
||||
const line = {
|
||||
...ctx,
|
||||
// Fields the Nest console prefix used to carry. They are IN the JSON
|
||||
// now because this line is written raw (see below) — a log shipper
|
||||
// needs level and time as parsable fields, not as console decoration.
|
||||
time: new Date().toISOString(),
|
||||
level: status >= 500 ? "error" : status >= 400 ? "warn" : "info",
|
||||
logger: "request",
|
||||
type: "http_request",
|
||||
requestId,
|
||||
method: req.method,
|
||||
@@ -113,6 +114,9 @@ export class RequestLogMiddleware implements NestMiddleware {
|
||||
json = JSON.stringify(line);
|
||||
} catch {
|
||||
json = JSON.stringify({
|
||||
time: line.time,
|
||||
level: line.level,
|
||||
logger: "request",
|
||||
type: "http_request",
|
||||
requestId,
|
||||
method: req.method,
|
||||
@@ -123,9 +127,11 @@ export class RequestLogMiddleware implements NestMiddleware {
|
||||
});
|
||||
}
|
||||
|
||||
if (status >= 500) this.canonical.error(json);
|
||||
else if (status >= 400) this.canonical.warn(json);
|
||||
else this.canonical.log(json);
|
||||
// Written raw, NOT through Nest's Logger: the console logger wraps every
|
||||
// message in "[Nest] pid - date LEVEL [ctx] …", which makes the line
|
||||
// un-parsable as JSON. Same destination the Nest logger writes to
|
||||
// (stdout, stderr for errors) — only the decoration is dropped.
|
||||
(status >= 500 ? process.stderr : process.stdout).write(`${json}\n`);
|
||||
};
|
||||
|
||||
res.on("finish", emit);
|
||||
|
||||
@@ -7,9 +7,16 @@ import {
|
||||
Repository,
|
||||
} from "typeorm";
|
||||
|
||||
import { logCtx } from "../logging/request-context";
|
||||
|
||||
export abstract class BaseRepository<T extends ObjectLiteral> {
|
||||
protected constructor(protected readonly repository: Repository<T>) {}
|
||||
|
||||
/** Table name, for the write trail on the canonical request log line. */
|
||||
private get table(): string {
|
||||
return this.repository.metadata.tableName;
|
||||
}
|
||||
|
||||
/** Find a single entity by its primary key. */
|
||||
async findById(
|
||||
id: string,
|
||||
@@ -34,22 +41,43 @@ export abstract class BaseRepository<T extends ObjectLiteral> {
|
||||
/** Create and persist a new entity. */
|
||||
async create(data: DeepPartial<T>): Promise<T> {
|
||||
const entity = this.repository.create(data);
|
||||
return this.repository.save(entity);
|
||||
const saved = await this.repository.save(entity);
|
||||
logCtx(1, { path: `db.created.${this.table}`, mode: "count" });
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Patch an entity in place and return the reloaded row. */
|
||||
/**
|
||||
* Patch an entity in place and return the reloaded row.
|
||||
*
|
||||
* Every domain status machine in this app (booking, contract, wagon,
|
||||
* warehouse, transfer request…) lands here, so this is the one place that can
|
||||
* record "what state did this request actually move" without every service
|
||||
* remembering to log it.
|
||||
*/
|
||||
async update(id: string, data: DeepPartial<T>): Promise<T | null> {
|
||||
await this.repository.update(id, data as never);
|
||||
logCtx(1, { path: `db.updated.${this.table}`, mode: "count" });
|
||||
if (data && typeof data === "object" && "status" in data) {
|
||||
logCtx(
|
||||
{ entity: this.table, id, status: (data as { status: unknown }).status },
|
||||
{ path: "statusChanges", mode: "push" },
|
||||
);
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Soft-delete an entity by primary key (sets deleted_at). */
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repository.softDelete(id);
|
||||
logCtx({ entity: this.table, id }, { path: "deleted", mode: "push" });
|
||||
}
|
||||
|
||||
/** Permanently delete an entity. Avoid in domain code; prefer softDelete. */
|
||||
async hardDelete(id: string): Promise<void> {
|
||||
await this.repository.delete(id);
|
||||
logCtx(
|
||||
{ entity: this.table, id, hard: true },
|
||||
{ path: "deleted", mode: "push" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user