Merge pull request #1255 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-12 13:23:29 +03:00
committed by GitHub
21 changed files with 916 additions and 484 deletions

View File

@@ -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" }],
});
});
});

View File

@@ -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} ` +

View File

@@ -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 }),
);

View File

@@ -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) => {

View File

@@ -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.

View File

@@ -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';
//
@@ -811,6 +811,18 @@ export class ContractsService {
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) {
contract.files = await Promise.all(
contract.files.map(async (file: FileRecord) => {

View File

@@ -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" },
);
}
/**

View File

@@ -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}`,
);

View File

@@ -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(

View File

@@ -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)",
})

View File

@@ -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",

View File

@@ -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"

View File

@@ -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;

View File

@@ -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";

View File

@@ -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",

View File

@@ -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",

View File

@@ -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={{

View File

@@ -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">

View File

@@ -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 />}

View File

@@ -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);

View File

@@ -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" },
);
}
}