mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -1,5 +1,7 @@
|
|||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
|
import type { Repository } from "typeorm";
|
||||||
import {
|
import {
|
||||||
|
BaseRepository,
|
||||||
RequestLogMiddleware,
|
RequestLogMiddleware,
|
||||||
getLogContext,
|
getLogContext,
|
||||||
logCtx,
|
logCtx,
|
||||||
@@ -47,10 +49,13 @@ describe("logCtx", () => {
|
|||||||
|
|
||||||
describe("RequestLogMiddleware", () => {
|
describe("RequestLogMiddleware", () => {
|
||||||
it("emits one canonical JSON line carrying the collected context", () => {
|
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[] = [];
|
const lines: string[] = [];
|
||||||
jest
|
jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||||
.spyOn(Logger.prototype, "warn")
|
lines.push(String(chunk));
|
||||||
.mockImplementation((m) => lines.push(String(m)));
|
return true;
|
||||||
|
});
|
||||||
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
|
||||||
|
|
||||||
const listeners: Record<string, () => void> = {};
|
const listeners: Record<string, () => void> = {};
|
||||||
@@ -82,7 +87,11 @@ describe("RequestLogMiddleware", () => {
|
|||||||
listeners.close(); // aborts/close after finish must not double-log
|
listeners.close(); // aborts/close after finish must not double-log
|
||||||
|
|
||||||
expect(lines).toHaveLength(1);
|
expect(lines).toHaveLength(1);
|
||||||
|
expect(lines[0].endsWith("\n")).toBe(true);
|
||||||
|
expect(lines[0].startsWith("{")).toBe(true);
|
||||||
expect(JSON.parse(lines[0])).toMatchObject({
|
expect(JSON.parse(lines[0])).toMatchObject({
|
||||||
|
level: "warn",
|
||||||
|
logger: "request",
|
||||||
type: "http_request",
|
type: "http_request",
|
||||||
requestId: "req-42",
|
requestId: "req-42",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -100,3 +109,38 @@ describe("RequestLogMiddleware", () => {
|
|||||||
jest.restoreAllMocks();
|
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,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||||
|
import { logCtx } from "@edr/api-common";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
@@ -951,6 +952,24 @@ export class BillingService {
|
|||||||
|
|
||||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
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;
|
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||||
return {
|
return {
|
||||||
result: updated,
|
result: updated,
|
||||||
@@ -1320,6 +1339,21 @@ export class BillingService {
|
|||||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
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
|
// 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).
|
// required up front (the payment service rejects it otherwise, as a 502 here).
|
||||||
if (
|
if (
|
||||||
@@ -1443,7 +1477,24 @@ export class BillingService {
|
|||||||
// first on DESC, which would hand back an unissued invoice.
|
// first on DESC, which would hand back an unissued invoice.
|
||||||
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
|
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[] = [
|
const settleable: Freight.InvoiceStatus[] = [
|
||||||
...OPEN_STATUSES,
|
...OPEN_STATUSES,
|
||||||
@@ -1453,6 +1504,12 @@ export class BillingService {
|
|||||||
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
|
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
|
||||||
// inline by payInvoice). Anything else means money was captured with
|
// inline by payInvoice). Anything else means money was captured with
|
||||||
// nowhere to land — that needs a person, so say so loudly.
|
// 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) {
|
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
|
`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 { SchedulingStatus } from '@edr/types';
|
||||||
import { ConflictException, Injectable } from '@nestjs/common';
|
import { ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
authorId?: string,
|
authorId?: string,
|
||||||
): Promise<BookingReviewNote> {
|
): Promise<BookingReviewNote> {
|
||||||
const repo = this.dataSource.getRepository(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(
|
return repo.save(
|
||||||
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Freight, SchedulingStatus } from '@edr/types';
|
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 { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||||
@@ -2115,6 +2115,21 @@ export class BookingsService {
|
|||||||
throw new NotFoundException(`Booking ${id} not found`);
|
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) {
|
if (booking.files && booking.files.length > 0) {
|
||||||
booking.files = await Promise.all(
|
booking.files = await Promise.all(
|
||||||
booking.files.map(async (file: FileRecord) => {
|
booking.files.map(async (file: FileRecord) => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Readable } from 'stream';
|
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 type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||||
@@ -1084,6 +1084,19 @@ export class ContractTransitionService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const role = dto.role as ContractSignerRole;
|
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
|
// 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
|
// omit it to reuse the signer's saved profile signature. Fall back to the
|
||||||
// saved one whenever no image is supplied.
|
// saved one whenever no image is supplied.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||||
|
|
||||||
import { YardCountry } from '@edr/types';
|
import { YardCountry } from '@edr/types';
|
||||||
//
|
//
|
||||||
@@ -811,6 +811,18 @@ export class ContractsService {
|
|||||||
throw new NotFoundException(`Contract ${id} not found`);
|
throw new NotFoundException(`Contract ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (contract.files && contract.files.length > 0) {
|
||||||
contract.files = await Promise.all(
|
contract.files = await Promise.all(
|
||||||
contract.files.map(async (file: FileRecord) => {
|
contract.files.map(async (file: FileRecord) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// otp.service.ts
|
// otp.service.ts
|
||||||
|
|
||||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { logCtx } from "@edr/api-common";
|
||||||
import { randomInt } from "node:crypto";
|
import { randomInt } from "node:crypto";
|
||||||
|
|
||||||
import { OtpRepository } from "./otp.repository";
|
import { OtpRepository } from "./otp.repository";
|
||||||
@@ -317,6 +318,13 @@ export class OtpService {
|
|||||||
}`;
|
}`;
|
||||||
if (result === "ok") this.logger.log(line);
|
if (result === "ok") this.logger.log(line);
|
||||||
else this.logger.warn(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 { HttpService } from "@nestjs/axios";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
import { logCtx } from "@edr/api-common";
|
||||||
import {
|
import {
|
||||||
InitiatePaymentRequest,
|
InitiatePaymentRequest,
|
||||||
PaymentIntentSnapshot,
|
PaymentIntentSnapshot,
|
||||||
@@ -111,6 +112,16 @@ export class PaymentClientService {
|
|||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = `${this.baseUrl}${path}`;
|
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 {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.request<T>({
|
this.http.request<T>({
|
||||||
@@ -122,9 +133,11 @@ export class PaymentClientService {
|
|||||||
: {},
|
: {},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
trace({ status: response.status });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError && err.response) {
|
if (err instanceof AxiosError && err.response) {
|
||||||
|
trace({ status: err.response.status });
|
||||||
if (err.response.status === 404) throw err;
|
if (err.response.status === 404) throw err;
|
||||||
const detail =
|
const detail =
|
||||||
(err.response.data as { message?: string | string[] })?.message ??
|
(err.response.data as { message?: string | string[] })?.message ??
|
||||||
@@ -136,6 +149,7 @@ export class PaymentClientService {
|
|||||||
}
|
}
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error && err.message ? err.message : String(err);
|
err instanceof Error && err.message ? err.message : String(err);
|
||||||
|
trace({ unreachable: true, error: message });
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`payment service unreachable (${method} ${path}): ${message}`,
|
`payment service unreachable (${method} ${path}): ${message}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { logCtx } from "@edr/api-common";
|
||||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
import { PaymentEntity } from "./entities/payment.entity";
|
import { PaymentEntity } from "./entities/payment.entity";
|
||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
@@ -214,8 +215,16 @@ export class PaymentService {
|
|||||||
PaymentReferenceType.SHIPMENT,
|
PaymentReferenceType.SHIPMENT,
|
||||||
referenceId,
|
referenceId,
|
||||||
);
|
);
|
||||||
|
logCtx(
|
||||||
|
{ referenceId, paid: result.paid, unverifiable: result.unverifiable },
|
||||||
|
{ path: "payment.reconcile" },
|
||||||
|
);
|
||||||
return { paid: result.paid, unverifiable: result.unverifiable };
|
return { paid: result.paid, unverifiable: result.unverifiable };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logCtx(
|
||||||
|
{ referenceId, unverifiable: true, error: (err as Error).message },
|
||||||
|
{ path: "payment.reconcile" },
|
||||||
|
);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
|
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
@@ -224,6 +233,18 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
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 {
|
try {
|
||||||
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
||||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
|
// 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);
|
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) {
|
if (immediateSuccess) {
|
||||||
// Settle the projection but DO NOT notify billing — billing settles
|
// Settle the projection but DO NOT notify billing — billing settles
|
||||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||||
@@ -428,6 +460,17 @@ export class PaymentService {
|
|||||||
otp,
|
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) {
|
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
await this.markIntentSucceeded(local.id, {
|
await this.markIntentSucceeded(local.id, {
|
||||||
providerTxnId: confirmed.providerTxnId,
|
providerTxnId: confirmed.providerTxnId,
|
||||||
@@ -460,6 +503,16 @@ export class PaymentService {
|
|||||||
): Promise<{ alreadyFinalized: boolean }> {
|
): Promise<{ alreadyFinalized: boolean }> {
|
||||||
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
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") {
|
if (intent.status === "success") {
|
||||||
// Still notify billing: a prior delivery may have flipped the intent to
|
// 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
|
// 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,
|
opts.paidAt ?? intent.paidAt ?? undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
logCtx(true, { path: "payment.settle.alreadyFinalized", mode: "set" });
|
||||||
return { alreadyFinalized: true };
|
return { alreadyFinalized: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +561,15 @@ export class PaymentService {
|
|||||||
referenceId: string,
|
referenceId: string,
|
||||||
): Promise<{ acknowledged: boolean }> {
|
): Promise<{ acknowledged: boolean }> {
|
||||||
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
|
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") {
|
if (!intent || intent.method === "cbe-bill") {
|
||||||
return { acknowledged: false };
|
return { acknowledged: false };
|
||||||
}
|
}
|
||||||
@@ -533,6 +596,16 @@ export class PaymentService {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
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;
|
if (intent.status === "success" || intent.status === "canceled") return;
|
||||||
|
|
||||||
await this.paymentRepo.update(
|
await this.paymentRepo.update(
|
||||||
|
|||||||
@@ -15,21 +15,21 @@ export class StampSettingsController {
|
|||||||
constructor(private readonly service: StampSettingsService) {}
|
constructor(private readonly service: StampSettingsService) {}
|
||||||
|
|
||||||
@Get()
|
@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" })
|
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
|
||||||
get() {
|
get() {
|
||||||
return this.service.getView();
|
return this.service.getView();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put()
|
@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" })
|
@ApiOperation({ summary: "Replace the company stamp" })
|
||||||
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
|
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
|
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete()
|
@Delete()
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
|
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Clear the company stamp (invoices fall back to the plain seal)",
|
summary: "Clear the company stamp (invoices fall back to the plain seal)",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1190,22 +1190,28 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm(
|
perm(
|
||||||
"b4b00002-0001-4000-8000-000000000001",
|
"b4b00002-0001-4000-8000-000000000001",
|
||||||
"edr_freight_app:settings:stamp:view",
|
"edr_freight_app:settings:stamp:view",
|
||||||
"View stamp settings",
|
"View the company stamp",
|
||||||
),
|
),
|
||||||
perm(
|
perm(
|
||||||
"b4b00002-0001-4000-8000-000000000002",
|
"b4b00002-0001-4000-8000-000000000002",
|
||||||
"edr_freight_app:settings:stamp:manage",
|
"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(
|
perm(
|
||||||
"b4b00003-0001-4000-8000-000000000001",
|
"b4b00003-0001-4000-8000-000000000001",
|
||||||
"edr_freight_app:settings:invoice_stamp:view",
|
"edr_freight_app:settings:teeter:view",
|
||||||
"View invoice stamp settings",
|
"View own approval teeter and signature",
|
||||||
),
|
),
|
||||||
perm(
|
perm(
|
||||||
"b4b00003-0001-4000-8000-000000000002",
|
"b4b00003-0001-4000-8000-000000000002",
|
||||||
"edr_freight_app:settings:invoice_stamp:manage",
|
"edr_freight_app:settings:teeter:manage",
|
||||||
"Manage invoice stamp settings",
|
"Manage own approval teeter and signature",
|
||||||
),
|
),
|
||||||
perm(
|
perm(
|
||||||
"b4c00001-0001-4000-8000-000000000001",
|
"b4c00001-0001-4000-8000-000000000001",
|
||||||
@@ -1907,15 +1913,18 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:settings:dropdown:view",
|
view: "edr_freight_app:settings:dropdown:view",
|
||||||
manage: "edr_freight_app:settings:dropdown:manage",
|
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: {
|
stamp: {
|
||||||
view: "edr_freight_app:settings:stamp:view",
|
view: "edr_freight_app:settings:stamp:view",
|
||||||
manage: "edr_freight_app:settings:stamp:manage",
|
manage: "edr_freight_app:settings:stamp:manage",
|
||||||
},
|
},
|
||||||
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
|
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||||
// from `stamp` above, which is the per-employee approval-record teeter.
|
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||||
invoiceStamp: {
|
// gate the company stamp before the two were untangled.
|
||||||
view: "edr_freight_app:settings:invoice_stamp:view",
|
teeter: {
|
||||||
manage: "edr_freight_app:settings:invoice_stamp:manage",
|
view: "edr_freight_app:settings:teeter:view",
|
||||||
|
manage: "edr_freight_app:settings:teeter:manage",
|
||||||
},
|
},
|
||||||
exchangeRate: {
|
exchangeRate: {
|
||||||
view: "edr_freight_app:settings:exchange_rate:view",
|
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 NoAccessPage from "./pages/NoAccessPage";
|
||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||||
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature";
|
import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage";
|
||||||
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
|
|
||||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||||
@@ -786,23 +785,27 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</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
|
<Route
|
||||||
path="stamp-settings"
|
path="stamp-settings"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
|
<RequirePermission
|
||||||
<StampSettings />
|
permission={FREIGHT_PERMS.settings.stamp.view}
|
||||||
|
>
|
||||||
|
<CompanyStampSettingsPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Old URL kept alive so existing links/bookmarks do not 404. */}
|
||||||
<Route
|
<Route
|
||||||
path="invoice-stamp-settings"
|
path="invoice-stamp-settings"
|
||||||
element={
|
element={<Navigate to="/dashboard/stamp-settings" replace />}
|
||||||
<RequirePermission
|
|
||||||
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
|
|
||||||
>
|
|
||||||
<InvoiceStampSettingsPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contract-templates"
|
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,
|
type ResetPasswordActionProps,
|
||||||
} from "./ResetPasswordAction";
|
} from "./ResetPasswordAction";
|
||||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||||
|
export {
|
||||||
|
PersonCard,
|
||||||
|
type PersonCardProps,
|
||||||
|
type PersonField,
|
||||||
|
} from "./PersonCard";
|
||||||
export { TableCard, type TableCardProps } from "./TableCard";
|
export { TableCard, type TableCardProps } from "./TableCard";
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
PackageOpen,
|
PackageOpen,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Stamp,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -486,17 +487,14 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
|||||||
permission: FREIGHT_PERMS.settings.dropdown.view,
|
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",
|
href: "/dashboard/stamp-settings",
|
||||||
icon: <FileSignature />,
|
icon: <Stamp />,
|
||||||
permission: FREIGHT_PERMS.settings.stamp.view,
|
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",
|
label: "Contract templates",
|
||||||
href: "/dashboard/contract-templates",
|
href: "/dashboard/contract-templates",
|
||||||
|
|||||||
@@ -328,15 +328,18 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:settings:dropdown:view",
|
view: "edr_freight_app:settings:dropdown:view",
|
||||||
manage: "edr_freight_app:settings:dropdown:manage",
|
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: {
|
stamp: {
|
||||||
view: "edr_freight_app:settings:stamp:view",
|
view: "edr_freight_app:settings:stamp:view",
|
||||||
manage: "edr_freight_app:settings:stamp:manage",
|
manage: "edr_freight_app:settings:stamp:manage",
|
||||||
},
|
},
|
||||||
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
|
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||||
// from `stamp` above, which is the per-employee approval-record teeter.
|
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||||
invoiceStamp: {
|
// gate the company stamp before the two were untangled.
|
||||||
view: "edr_freight_app:settings:invoice_stamp:view",
|
teeter: {
|
||||||
manage: "edr_freight_app:settings:invoice_stamp:manage",
|
view: "edr_freight_app:settings:teeter:view",
|
||||||
|
manage: "edr_freight_app:settings:teeter:manage",
|
||||||
},
|
},
|
||||||
exchangeRate: {
|
exchangeRate: {
|
||||||
view: "edr_freight_app:settings:exchange_rate:view",
|
view: "edr_freight_app:settings:exchange_rate:view",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
Divider,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Banknote,
|
Banknote,
|
||||||
|
Contact,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -32,6 +33,8 @@ import {
|
|||||||
FilePen,
|
FilePen,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
UserCheck,
|
||||||
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -46,6 +49,7 @@ import {
|
|||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
InvoiceStatusBadge,
|
InvoiceStatusBadge,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
|
PersonCard,
|
||||||
ProfileApprovalActions,
|
ProfileApprovalActions,
|
||||||
ProfileChips,
|
ProfileChips,
|
||||||
ProfileStatusBadge,
|
ProfileStatusBadge,
|
||||||
@@ -259,7 +263,9 @@ export default function CustomerDetailPage() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
aria-label={`View ${f.name}`}
|
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} />
|
<Eye size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -268,7 +274,9 @@ export default function CustomerDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="xs"
|
size="xs"
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
onClick={() =>
|
||||||
|
void fetchViewableFile(f.id, f.name).then(view)
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: 170,
|
maxWidth: 170,
|
||||||
textAlign: "left",
|
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,
|
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -629,14 +640,13 @@ export default function CustomerDetailPage() {
|
|||||||
[documents],
|
[documents],
|
||||||
);
|
);
|
||||||
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
||||||
const poaFields = [
|
const hasPoaDetails = [
|
||||||
{ label: "PoA name", value: company?.poaName },
|
company?.poaName,
|
||||||
{ label: "PoA email", value: company?.poaEmail },
|
company?.poaEmail,
|
||||||
{ label: "PoA phone", value: company?.poaPhone },
|
company?.poaPhone,
|
||||||
{ label: "PoA location", value: company?.poaLocation },
|
company?.poaLocation,
|
||||||
{ label: "PoA address", value: company?.poaAddress },
|
company?.poaAddress,
|
||||||
];
|
].some((v) => v?.trim());
|
||||||
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
|
|
||||||
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
|
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
|
||||||
// this page can never disagree with the rule the API actually enforces.
|
// this page can never disagree with the rule the API actually enforces.
|
||||||
const identityState = company?.identity;
|
const identityState = company?.identity;
|
||||||
@@ -645,9 +655,7 @@ export default function CustomerDetailPage() {
|
|||||||
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
|
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
|
||||||
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
||||||
// and DARS delegation paper both — is mandatory rather than optional.
|
// and DARS delegation paper both — is mandatory rather than optional.
|
||||||
const poaMandatory = (company?.companyProfiles ?? []).some(
|
const poaMandatory = profiles.some((p) => p.type === "freight_forwarder");
|
||||||
(p) => p.type === "freight_forwarder",
|
|
||||||
);
|
|
||||||
const delegationMissing =
|
const delegationMissing =
|
||||||
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
|
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
|
||||||
|
|
||||||
@@ -685,8 +693,9 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
backTo="/dashboard/customers"
|
backTo="/dashboard/customers"
|
||||||
title={company.name}
|
title={company.name}
|
||||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
subtitle={`TIN ${company.tin}${
|
||||||
}`}
|
company.country ? ` · ${company.country}` : ""
|
||||||
|
}`}
|
||||||
meta={
|
meta={
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<CompanyTypeBadge type={company.type} />
|
<CompanyTypeBadge type={company.type} />
|
||||||
@@ -749,7 +758,7 @@ export default function CustomerDetailPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "Profiles",
|
label: "Profiles",
|
||||||
value: company.companyProfiles.length,
|
value: profiles.length,
|
||||||
icon: IdCard,
|
icon: IdCard,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
},
|
},
|
||||||
@@ -761,9 +770,7 @@ export default function CustomerDetailPage() {
|
|||||||
: "Pending approval",
|
: "Pending approval",
|
||||||
value: stillOnboarding
|
value: stillOnboarding
|
||||||
? "—"
|
? "—"
|
||||||
: company.companyProfiles.filter(
|
: profiles.filter((p) => p.status === "pending").length,
|
||||||
(p) => p.status === "pending",
|
|
||||||
).length,
|
|
||||||
icon: IdCard,
|
icon: IdCard,
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
@@ -782,407 +789,392 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Grid gap="lg" align="flex-start">
|
||||||
<Stack gap="lg">
|
{/* Company facts — the wide column. People live in the narrow one
|
||||||
<Text fw={600} c="edr-text">
|
beside it, so nothing about a person is stated twice. */}
|
||||||
Company information
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
</Text>
|
<Stack gap="lg">
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
<Card>
|
||||||
<InfoField label="TIN" value={company.tin} />
|
<Stack gap="lg">
|
||||||
<InfoField label="VAT number" value={company.vatNumber} />
|
<Text fw={600} c="edr-text">
|
||||||
<InfoField label="FAN number" value={company.fanNumber} />
|
Company information
|
||||||
<InfoField
|
</Text>
|
||||||
label="Submitted on"
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
value={formatDate(company.createdAt)}
|
<InfoField label="TIN" value={company.tin} />
|
||||||
/>
|
|
||||||
<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 && (
|
|
||||||
<>
|
|
||||||
<InfoField
|
<InfoField
|
||||||
label="PoA verified at"
|
label="VAT number"
|
||||||
value={formatDate(poaIdentity.verifiedAt)}
|
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
|
<InfoField
|
||||||
label="PoA birthdate"
|
label="Approved on"
|
||||||
value={poaIdentity.birthdate}
|
value={
|
||||||
|
company.approvedAt
|
||||||
|
? formatDate(company.approvedAt)
|
||||||
|
: "Not yet approved"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InfoField label="PoA gender" value={poaIdentity.gender} />
|
</SimpleGrid>
|
||||||
</>
|
</Stack>
|
||||||
)}
|
</Card>
|
||||||
</SimpleGrid>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No Power of Attorney representative recorded for this
|
|
||||||
customer.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
<Card>
|
||||||
|
<Stack gap="lg">
|
||||||
<Stack gap="sm">
|
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||||
<Text
|
<Group gap="xs" wrap="nowrap">
|
||||||
size="xs"
|
<Text fw={600} c="edr-text">
|
||||||
fw={600}
|
eTrade registration
|
||||||
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)}
|
|
||||||
</Text>
|
</Text>
|
||||||
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
{hasEtradeRecord ? (
|
||||||
<Badge
|
<Badge size="sm" color="edr-green" variant="light">
|
||||||
size="xs"
|
Verified with eTrade
|
||||||
color="yellow"
|
</Badge>
|
||||||
variant="light"
|
) : (
|
||||||
className="shrink-0"
|
<Badge size="sm" color="gray" variant="light">
|
||||||
>
|
No eTrade record
|
||||||
Pending approval
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={4} wrap="nowrap">
|
{hasEtradeRecord && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="default"
|
||||||
color="gray"
|
aria-label="Download TIN record"
|
||||||
aria-label={`Preview ${doc.name}`}
|
onClick={() => downloadTinRecord(company)}
|
||||||
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}`}
|
|
||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
))
|
{hasEtradeRecord ? (
|
||||||
)}
|
<SimpleGrid
|
||||||
</Stack>
|
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||||
</Stack>
|
spacing="lg"
|
||||||
</Card>
|
>
|
||||||
|
<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>
|
<Card>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={600} c="edr-text">
|
<Text fw={600} c="edr-text">
|
||||||
Role profiles
|
Role profiles
|
||||||
</Text>
|
</Text>
|
||||||
<ProfileChips profiles={company.companyProfiles} />
|
<ProfileChips profiles={profiles} />
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
{/* Narrower than the old full-width layout — the table
|
||||||
<Box miw={1040}>
|
shares the row with the people column now. */}
|
||||||
<DataTable
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
columns={profileColumns}
|
<Box miw={760}>
|
||||||
data={company.companyProfiles}
|
<DataTable
|
||||||
status="success"
|
columns={profileColumns}
|
||||||
emptyMessage="No profiles registered."
|
data={profiles}
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
status="success"
|
||||||
/>
|
emptyMessage="No profiles registered."
|
||||||
</Box>
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
</Box>
|
/>
|
||||||
</Stack>
|
</Box>
|
||||||
</Card>
|
</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>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
@@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
bookingsQuery.isError
|
bookingsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load bookings.",
|
message: "Failed to load bookings.",
|
||||||
onRetry: () => void bookingsQuery.refetch(),
|
onRetry: () => void bookingsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
documentsQuery.isError
|
documentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load documents.",
|
message: "Failed to load documents.",
|
||||||
onRetry: () => void documentsQuery.refetch(),
|
onRetry: () => void documentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
paymentsQuery.isError
|
paymentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load payments.",
|
message: "Failed to load payments.",
|
||||||
onRetry: () => void paymentsQuery.refetch(),
|
onRetry: () => void paymentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
invoicesQuery.isError
|
invoicesQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load invoices.",
|
message: "Failed to load invoices.",
|
||||||
onRetry: () => void invoicesQuery.refetch(),
|
onRetry: () => void invoicesQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
pagination={{
|
pagination={{
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ import {
|
|||||||
} from "@/hooks/useStampSettings";
|
} from "@/hooks/useStampSettings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one company stamp/seal stamped onto every generated invoice/receipt
|
* The ONE company stamp/seal, read by every document path server-side via
|
||||||
* PDF (InvoiceDocumentService). Single global image — no per-employee choice.
|
* 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 { data, isLoading } = useStampSettingsQuery();
|
||||||
const setStamp = useSetStamp();
|
const setStamp = useSetStamp();
|
||||||
const clearStamp = useClearStamp();
|
const clearStamp = useClearStamp();
|
||||||
@@ -47,11 +53,13 @@ export default function InvoiceStampSettingsPage() {
|
|||||||
<div className="p-4 w-full max-w-screen-sm mx-auto">
|
<div className="p-4 w-full max-w-screen-sm mx-auto">
|
||||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Invoice stamp</CardTitle>
|
<CardTitle>Company stamp</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Stamped onto every generated invoice and receipt PDF. Replacing it
|
The single EDR seal, applied to every generated document — invoices
|
||||||
here changes it everywhere at once — there is no per-invoice or
|
and receipts, warehouse release and handover papers, and the EDR
|
||||||
per-user choice.
|
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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -3,7 +3,7 @@ import { Navigate, Outlet, Route } from "react-router-dom";
|
|||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing";
|
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 { WithPermission } from "@/shared/hooks/useHas";
|
||||||
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
|
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
|
||||||
import TemplatePage from "@/super-admin/components/templates/components/templates";
|
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 { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext";
|
||||||
import { PermissionProvider } from "@/shared/context/PermissionContext";
|
import { PermissionProvider } from "@/shared/context/PermissionContext";
|
||||||
import UserManagementPage from "@/pages/UserManagementPage";
|
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
|
* 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"
|
path="user-management/position-management"
|
||||||
element={<PositionManagementPage />}
|
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
|
<Route
|
||||||
path="user-management/migrated-records-management"
|
path="user-management/migrated-records-management"
|
||||||
element={<MigratedDataManagementPage />}
|
element={<MigratedDataManagementPage />}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RequestLogContext,
|
RequestLogContext,
|
||||||
@@ -58,9 +58,6 @@ const userId = (req: LoggedRequest): string | undefined => {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RequestLogMiddleware implements NestMiddleware {
|
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 {
|
use(req: LoggedRequest, res: LoggedResponse, next: () => void): void {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const requestId = header(req, "x-request-id") ?? randomUUID();
|
const requestId = header(req, "x-request-id") ?? randomUUID();
|
||||||
@@ -86,10 +83,14 @@ export class RequestLogMiddleware implements NestMiddleware {
|
|||||||
const status = res.statusCode;
|
const status = res.statusCode;
|
||||||
const durationMs = Date.now() - start;
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
this.logger.log(`${req.method} ${url} ${status} ${durationMs}ms`);
|
|
||||||
|
|
||||||
const line = {
|
const line = {
|
||||||
...ctx,
|
...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",
|
type: "http_request",
|
||||||
requestId,
|
requestId,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
@@ -113,6 +114,9 @@ export class RequestLogMiddleware implements NestMiddleware {
|
|||||||
json = JSON.stringify(line);
|
json = JSON.stringify(line);
|
||||||
} catch {
|
} catch {
|
||||||
json = JSON.stringify({
|
json = JSON.stringify({
|
||||||
|
time: line.time,
|
||||||
|
level: line.level,
|
||||||
|
logger: "request",
|
||||||
type: "http_request",
|
type: "http_request",
|
||||||
requestId,
|
requestId,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
@@ -123,9 +127,11 @@ export class RequestLogMiddleware implements NestMiddleware {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status >= 500) this.canonical.error(json);
|
// Written raw, NOT through Nest's Logger: the console logger wraps every
|
||||||
else if (status >= 400) this.canonical.warn(json);
|
// message in "[Nest] pid - date LEVEL [ctx] …", which makes the line
|
||||||
else this.canonical.log(json);
|
// 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);
|
res.on("finish", emit);
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ import {
|
|||||||
Repository,
|
Repository,
|
||||||
} from "typeorm";
|
} from "typeorm";
|
||||||
|
|
||||||
|
import { logCtx } from "../logging/request-context";
|
||||||
|
|
||||||
export abstract class BaseRepository<T extends ObjectLiteral> {
|
export abstract class BaseRepository<T extends ObjectLiteral> {
|
||||||
protected constructor(protected readonly repository: Repository<T>) {}
|
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. */
|
/** Find a single entity by its primary key. */
|
||||||
async findById(
|
async findById(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -34,22 +41,43 @@ export abstract class BaseRepository<T extends ObjectLiteral> {
|
|||||||
/** Create and persist a new entity. */
|
/** Create and persist a new entity. */
|
||||||
async create(data: DeepPartial<T>): Promise<T> {
|
async create(data: DeepPartial<T>): Promise<T> {
|
||||||
const entity = this.repository.create(data);
|
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> {
|
async update(id: string, data: DeepPartial<T>): Promise<T | null> {
|
||||||
await this.repository.update(id, data as never);
|
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);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft-delete an entity by primary key (sets deleted_at). */
|
/** Soft-delete an entity by primary key (sets deleted_at). */
|
||||||
async softDelete(id: string): Promise<void> {
|
async softDelete(id: string): Promise<void> {
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
|
logCtx({ entity: this.table, id }, { path: "deleted", mode: "push" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Permanently delete an entity. Avoid in domain code; prefer softDelete. */
|
/** Permanently delete an entity. Avoid in domain code; prefer softDelete. */
|
||||||
async hardDelete(id: string): Promise<void> {
|
async hardDelete(id: string): Promise<void> {
|
||||||
await this.repository.delete(id);
|
await this.repository.delete(id);
|
||||||
|
logCtx(
|
||||||
|
{ entity: this.table, id, hard: true },
|
||||||
|
{ path: "deleted", mode: "push" },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user