mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -58,6 +58,13 @@ async function bootstrap() {
|
||||
"x-delegator-position-id",
|
||||
"x-current-project-id",
|
||||
"x-current-position-id",
|
||||
// Headers sent by the freight-backoffice OKR/objective-service client
|
||||
// (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts)
|
||||
// under yet another naming convention — unprefixed "tenant-key"/"unit-id",
|
||||
// and "x-delegated-position-id" (delegated, not delegator).
|
||||
"tenant-key",
|
||||
"unit-id",
|
||||
"x-delegated-position-id",
|
||||
],
|
||||
exposedHeaders: ["Content-Disposition"],
|
||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Transit-assignee handshake on the SHIPMENT, not the contract.
|
||||
*
|
||||
* Clearance runs per booking now, so the ask GL Ethiopia raises before filing a
|
||||
* customs declaration ("who handles this shipment in Djibouti?") and Djibouti's
|
||||
* answer belong on the booking. The contract-cycle columns added by
|
||||
* 2950000000000 stay for the legacy contract-level cycles.
|
||||
*/
|
||||
export class AddBookingTransitAssignee3010000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS transit_assignee_requested_at,
|
||||
DROP COLUMN IF EXISTS transit_assignee_request_note,
|
||||
DROP COLUMN IF EXISTS transit_assignee_name,
|
||||
DROP COLUMN IF EXISTS transit_assignee_assigned_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* A contract's `created_at` is the DRAFT row's insert time, not when the
|
||||
* customer actually submitted it for review — a DRAFT can sit edited for days
|
||||
* first. `submitted_at` is stamped by ContractTransitionService.submit /
|
||||
* confirmSubmit so the history UI can show a real submission time.
|
||||
*/
|
||||
export class AddContractSubmittedAt3020000000000 implements MigrationInterface {
|
||||
name = 'AddContractSubmittedAt3020000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS submitted_at timestamptz;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS submitted_at;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -263,6 +263,36 @@ export class BookingLifecycleNotifierService {
|
||||
this.inApp(b, 'Booking cancelled', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
|
||||
* deep-linked to the Djibouti clearance page where the name is entered — the
|
||||
* customs declaration is blocked until they answer.
|
||||
*/
|
||||
transitAssigneeRequested(b: Booking, note: string | null): void {
|
||||
const msg =
|
||||
`GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` +
|
||||
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
|
||||
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
|
||||
transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void {
|
||||
const msg = previous
|
||||
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
|
||||
`"${previous}" to "${assignee}".`
|
||||
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
|
||||
`The customs declaration can now be filed.`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
|
||||
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Clearance milestones needing customer action ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax — the customer must pay and upload the slip. */
|
||||
|
||||
@@ -823,6 +823,34 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-assignee/request')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration',
|
||||
})
|
||||
async requestBookingTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('note') note: string | undefined,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-assignee/assign')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
|
||||
})
|
||||
async assignBookingTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('assignee') assignee: string,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -557,6 +557,23 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Pre-declaration handshake: GL Ethiopia asks GL Djibouti who will handle this
|
||||
* shipment in transit, Djibouti answers with a name (free text — the officer is
|
||||
* not a platform user). The import declaration is blocked until `name` is set.
|
||||
*/
|
||||
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
|
||||
transitAssigneeRequestedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
|
||||
transitAssigneeRequestNote?: string | null;
|
||||
|
||||
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
|
||||
transitAssigneeName?: string | null;
|
||||
|
||||
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
|
||||
transitAssigneeAssignedAt?: Date | null;
|
||||
|
||||
/** GL staff user bound to this shipment by the station manager. */
|
||||
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
||||
glAssignedStaffId?: string | null;
|
||||
|
||||
@@ -15,6 +15,9 @@ const generalImportBooking = {
|
||||
dutyRequired: true,
|
||||
roHoldReason: null,
|
||||
vesselDepartureDate: null,
|
||||
// Djibouti already named the transit officer — the declaration gate is open.
|
||||
transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
transitAssigneeName: 'Ahmed Bourhan',
|
||||
} as Booking;
|
||||
|
||||
const generalExportBooking = {
|
||||
@@ -27,6 +30,8 @@ const generalExportBooking = {
|
||||
function makeService(overrides?: {
|
||||
booking?: Booking;
|
||||
workflowThrows?: boolean;
|
||||
/** Resolve the input doc set with no required fields → every doc counts approved. */
|
||||
docsApproved?: boolean;
|
||||
}) {
|
||||
const booking = overrides?.booking ?? generalImportBooking;
|
||||
const bookingsRepository = {
|
||||
@@ -38,10 +43,14 @@ function makeService(overrides?: {
|
||||
};
|
||||
const filesService = {
|
||||
upsertByCode: jest.fn().mockResolvedValue({}),
|
||||
upload: jest.fn().mockResolvedValue({}),
|
||||
deleteByCode: jest.fn().mockResolvedValue(undefined),
|
||||
findByResource: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const fileUploadSettingsService = {
|
||||
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
|
||||
getByCode: overrides?.docsApproved
|
||||
? jest.fn().mockResolvedValue({ fields: [] })
|
||||
: jest.fn().mockRejectedValue(new Error('no setting')),
|
||||
};
|
||||
const workflowService = {
|
||||
assertPriorCompleteForBooking: overrides?.workflowThrows
|
||||
@@ -49,6 +58,7 @@ function makeService(overrides?: {
|
||||
: jest.fn().mockResolvedValue(undefined),
|
||||
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
|
||||
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
|
||||
@@ -91,6 +101,8 @@ function makeService(overrides?: {
|
||||
documentQueried: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
transitAssigneeRequested: jest.fn(),
|
||||
transitAssigneeAssigned: jest.fn(),
|
||||
} as never, // notifier
|
||||
);
|
||||
|
||||
@@ -186,6 +198,82 @@ describe('BookingClearanceService', () => {
|
||||
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects an import declaration before Djibouti names the transit officer', async () => {
|
||||
const { service, workflowService } = makeService({
|
||||
docsApproved: true,
|
||||
booking: {
|
||||
...generalImportBooking,
|
||||
transitAssigneeRequestedAt: null,
|
||||
transitAssigneeName: null,
|
||||
} as Booking,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
|
||||
).rejects.toThrow(/Request a transit assignee/i);
|
||||
expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the export declaration through without a transit assignee', async () => {
|
||||
const { service, workflowService } = makeService({
|
||||
docsApproved: true,
|
||||
booking: {
|
||||
...generalExportBooking,
|
||||
transitAssigneeRequestedAt: null,
|
||||
transitAssigneeName: null,
|
||||
} as Booking,
|
||||
});
|
||||
|
||||
await service.uploadDeclaration('b-export', [
|
||||
{ fieldname: 'decl' } as Express.Multer.File,
|
||||
]);
|
||||
|
||||
expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transit assignee handshake', () => {
|
||||
it('refuses an assignment GL Ethiopia never asked for', async () => {
|
||||
const { service } = makeService({
|
||||
booking: {
|
||||
...generalImportBooking,
|
||||
transitAssigneeRequestedAt: null,
|
||||
transitAssigneeName: null,
|
||||
} as Booking,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.assignTransitAssignee('b-general', 'Ahmed Bourhan'),
|
||||
).rejects.toThrow(/has not requested a transit assignee/i);
|
||||
});
|
||||
|
||||
it('stamps the ask and then the name', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
booking: {
|
||||
...generalImportBooking,
|
||||
transitAssigneeName: null,
|
||||
} as Booking,
|
||||
});
|
||||
|
||||
await service.requestTransitAssignee('b-general', ' night shift ');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({
|
||||
transitAssigneeRequestedAt: expect.any(Date),
|
||||
transitAssigneeRequestNote: 'night shift',
|
||||
}),
|
||||
);
|
||||
|
||||
await service.assignTransitAssignee('b-general', ' Ahmed Bourhan ');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({
|
||||
transitAssigneeName: 'Ahmed Bourhan',
|
||||
transitAssigneeAssignedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadReleaseOrder', () => {
|
||||
|
||||
@@ -71,6 +71,17 @@ export interface BookingClearanceView {
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
operationReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
/**
|
||||
* Pre-declaration handshake with GL Djibouti: who handles this shipment in
|
||||
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
|
||||
* file the import customs declaration before it is set.
|
||||
*/
|
||||
transitAssignee?: {
|
||||
requestedAt: string | null;
|
||||
requestNote: string | null;
|
||||
name: string | null;
|
||||
assignedAt: string | null;
|
||||
} | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -269,6 +280,16 @@ export class BookingClearanceService {
|
||||
: null,
|
||||
operationReady: boundary,
|
||||
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
|
||||
transitAssignee: {
|
||||
requestedAt: booking.transitAssigneeRequestedAt
|
||||
? booking.transitAssigneeRequestedAt.toISOString()
|
||||
: null,
|
||||
requestNote: booking.transitAssigneeRequestNote ?? null,
|
||||
name: booking.transitAssigneeName ?? null,
|
||||
assignedAt: booking.transitAssigneeAssignedAt
|
||||
? booking.transitAssigneeAssignedAt.toISOString()
|
||||
: null,
|
||||
},
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
@@ -356,6 +377,53 @@ export class BookingClearanceService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia asks Djibouti to name the officer who will handle this shipment
|
||||
* in transit. The import declaration is gated on the answer, so this is the
|
||||
* first thing ET does once the customer documents are approved. Re-requesting
|
||||
* is allowed (a nudge) and simply restamps the ask.
|
||||
*/
|
||||
async requestTransitAssignee(
|
||||
bookingId: string,
|
||||
note: string | undefined,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
transitAssigneeRequestedAt: new Date(),
|
||||
transitAssigneeRequestNote: note?.trim() || null,
|
||||
} as never);
|
||||
|
||||
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti names the transit officer — free text, because the person is not
|
||||
* a platform user. Answering unblocks the declaration for Ethiopia. A later
|
||||
* call overwrites the name (reassignment) and re-notifies.
|
||||
*/
|
||||
async assignTransitAssignee(bookingId: string, assignee: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (!assignee?.trim()) {
|
||||
throw new BadRequestException('Name the officer who will handle the transit.');
|
||||
}
|
||||
if (!booking.transitAssigneeRequestedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
|
||||
);
|
||||
}
|
||||
|
||||
const previous = booking.transitAssigneeName ?? null;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
transitAssigneeName: assignee.trim(),
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async uploadDeclaration(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
@@ -369,6 +437,16 @@ export class BookingClearanceService {
|
||||
'All required customer documents must be approved before uploading a declaration.',
|
||||
);
|
||||
}
|
||||
// Import only: the declaration is filed against whoever physically handles
|
||||
// the shipment in Djibouti, so that name must be in first. Exports have no
|
||||
// such handshake — their Djibouti steps come after the declaration.
|
||||
if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) {
|
||||
throw new BadRequestException(
|
||||
booking.transitAssigneeRequestedAt
|
||||
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
|
||||
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
|
||||
);
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||
|
||||
@@ -225,6 +225,7 @@ export class ContractTransitionService {
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: new Date(),
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.submittedToStaff(updated);
|
||||
@@ -241,6 +242,7 @@ export class ContractTransitionService {
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: new Date(),
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.submittedToStaff(updated);
|
||||
|
||||
@@ -216,6 +216,10 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
/** When the customer last submitted this contract (DRAFT/CHANGES_REQUESTED → SUBMITTED). */
|
||||
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
||||
submittedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BadgeCheck,
|
||||
CalendarClock,
|
||||
Flame,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
FileSignature,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import {
|
||||
CONTRACT_APPROVAL_ROLE_LABELS,
|
||||
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
interface ContractMilestonesTimelineProps {
|
||||
contract: Freight.IContract;
|
||||
}
|
||||
|
||||
const SIGNATURE_ROLE_LABELS: Record<Freight.ContractSignatureRole, string> = {
|
||||
CUSTOMER: "Signed by customer",
|
||||
STAFF: "Signed by EDR — line staff",
|
||||
DIRECTOR: "Signed by EDR — director",
|
||||
CEO: "Signed by EDR — CEO",
|
||||
};
|
||||
|
||||
/** "27 Jul 2026, 18:18" — the exact stamp, shown in the tooltip. */
|
||||
function formatWhen(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/** "3 hours ago" — the at-a-glance read. */
|
||||
function formatAgo(iso: string): string {
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
];
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
||||
for (const [unit, secondsPerUnit] of units) {
|
||||
if (seconds >= secondsPerUnit) {
|
||||
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
|
||||
}
|
||||
}
|
||||
return "just now";
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||
}
|
||||
|
||||
type MilestoneIcon = typeof Send;
|
||||
|
||||
interface Milestone {
|
||||
key: string;
|
||||
at: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
color: string;
|
||||
icon: MilestoneIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dated moments of a contract's life — submission, hazardous approval,
|
||||
* final approval, both parties' signatures, full execution — read straight off
|
||||
* the contract and its already-loaded approvalSteps/signatures (no extra
|
||||
* fetch). Sits above the document edit history on the History tab.
|
||||
*/
|
||||
export function ContractMilestonesTimeline({
|
||||
contract,
|
||||
}: ContractMilestonesTimelineProps) {
|
||||
const milestones = useMemo<Milestone[]>(() => {
|
||||
const items: Milestone[] = [];
|
||||
|
||||
// A DRAFT/RENEWAL_DRAFT contract hasn't been (re)submitted yet — nothing
|
||||
// to date. submittedAt is only tracked going forward; a contract that
|
||||
// reached SUBMITTED before that column existed falls back to createdAt.
|
||||
const submittedAt =
|
||||
contract.submittedAt ??
|
||||
(contract.status !== "DRAFT" && contract.status !== "RENEWAL_DRAFT"
|
||||
? contract.createdAt
|
||||
: null);
|
||||
if (submittedAt) {
|
||||
items.push({
|
||||
key: "submitted",
|
||||
at: submittedAt,
|
||||
title: "Submitted for review",
|
||||
color: "blue",
|
||||
icon: Send,
|
||||
});
|
||||
}
|
||||
|
||||
for (const step of contract.approvalSteps ?? []) {
|
||||
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
|
||||
if (!step.actedAt) continue;
|
||||
items.push({
|
||||
key: `hazard-${step.id}`,
|
||||
at: step.actedAt,
|
||||
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole,
|
||||
detail: step.status === "REJECTED" ? "Rejected" : "Approved",
|
||||
color: step.status === "REJECTED" ? "red" : "orange",
|
||||
icon: Flame,
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.contractGeneratedAt) {
|
||||
items.push({
|
||||
key: "approved",
|
||||
at: contract.contractGeneratedAt,
|
||||
title: "Contract approved",
|
||||
detail: "Every approval step cleared and the document was generated",
|
||||
color: "edr-green",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
}
|
||||
|
||||
for (const sig of contract.signatures ?? []) {
|
||||
items.push({
|
||||
key: `signature-${sig.id}`,
|
||||
at: sig.signedAt,
|
||||
title: SIGNATURE_ROLE_LABELS[sig.role] ?? `Signed by ${sig.role}`,
|
||||
detail: sig.signerDisplayName,
|
||||
color: "grape",
|
||||
icon: FileSignature,
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.fullyExecutedAt) {
|
||||
items.push({
|
||||
key: "executed",
|
||||
at: contract.fullyExecutedAt,
|
||||
title: "Fully executed",
|
||||
detail: "Both parties have signed",
|
||||
color: "edr-green",
|
||||
icon: BadgeCheck,
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort(
|
||||
(a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
|
||||
);
|
||||
}, [contract]);
|
||||
|
||||
const hasValidity = contract.contractValidFrom && contract.contractValidUntil;
|
||||
|
||||
if (milestones.length === 0 && !hasValidity) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No dated milestones recorded yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{hasValidity && (
|
||||
<Group
|
||||
gap="xs"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
<CalendarClock size={16} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="sm">
|
||||
Valid <strong>{formatDate(contract.contractValidFrom!)}</strong>
|
||||
{" → "}
|
||||
<strong>{formatDate(contract.contractValidUntil!)}</strong>
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{milestones.length > 0 && (
|
||||
<Timeline active={milestones.length} bulletSize={26} lineWidth={2} color="edr-green">
|
||||
{milestones.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={m.key}
|
||||
bullet={<Icon size={13} />}
|
||||
color={m.color}
|
||||
title={
|
||||
<Group gap="xs" wrap="wrap" align="baseline">
|
||||
<Text size="sm" fw={600}>
|
||||
{m.title}
|
||||
</Text>
|
||||
<Tooltip label={formatWhen(m.at)} withArrow>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatAgo(m.at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{m.detail && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{m.detail}
|
||||
</Text>
|
||||
)}
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -349,11 +349,11 @@ export function PhasedClearanceActionPanel({
|
||||
API refuses the upload until the name is in. */}
|
||||
{showEt &&
|
||||
canEt &&
|
||||
!isBooking &&
|
||||
!clearance.transitAssignee?.name &&
|
||||
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
|
||||
<TransitAssigneePanel
|
||||
contractId={entityId}
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
transitAssignee={clearance.transitAssignee}
|
||||
side="ET"
|
||||
onChanged={onChanged}
|
||||
|
||||
@@ -15,10 +15,14 @@ import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
export interface TransitAssigneePanelProps {
|
||||
contractId: string;
|
||||
/** Booking id when `isBooking`, contract id otherwise. */
|
||||
entityId: string;
|
||||
/** Clearance runs per booking now; contract-level cycles are the legacy case. */
|
||||
isBooking?: boolean;
|
||||
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
|
||||
/**
|
||||
* ET asks and waits; DJ answers with a name. The same state renders from both
|
||||
@@ -50,7 +54,8 @@ const fmt = (iso?: string | null) =>
|
||||
* different name later; the newest one wins and Ethiopia is notified again.
|
||||
*/
|
||||
export function TransitAssigneePanel({
|
||||
contractId,
|
||||
entityId,
|
||||
isBooking = false,
|
||||
transitAssignee,
|
||||
side,
|
||||
readOnly = false,
|
||||
@@ -59,9 +64,12 @@ export function TransitAssigneePanel({
|
||||
const [note, setNote] = useState("");
|
||||
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
|
||||
const [changing, setChanging] = useState(false);
|
||||
const service = isBooking ? bookingsService : contractsService;
|
||||
|
||||
const request = useMutation({
|
||||
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
|
||||
mutationFn: async () => {
|
||||
await service.requestTransitAssignee(entityId, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Request sent to GL Djibouti");
|
||||
setNote("");
|
||||
@@ -70,8 +78,9 @@ export function TransitAssigneePanel({
|
||||
});
|
||||
|
||||
const assign = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.assignTransitAssignee(contractId, assignee.trim()),
|
||||
mutationFn: async () => {
|
||||
await service.assignTransitAssignee(entityId, assignee.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Transit assignee sent to GL Ethiopia");
|
||||
setChanging(false);
|
||||
|
||||
@@ -144,6 +144,10 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_DECLARATION: (id: string) =>
|
||||
`/bookings/${id}/clearance/declaration`,
|
||||
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
||||
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
|
||||
`/bookings/${id}/clearance/transit-assignee/request`,
|
||||
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
|
||||
`/bookings/${id}/clearance/transit-assignee/assign`,
|
||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||
`/bookings/${id}/clearance/finalize-pre-clearance`,
|
||||
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Flame,
|
||||
History,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
@@ -54,6 +55,7 @@ import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprov
|
||||
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
||||
import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline";
|
||||
import {
|
||||
ContractCustomerCard,
|
||||
ContractDocumentsCard,
|
||||
@@ -531,13 +533,22 @@ export default function ContractRequestDetailPage() {
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "history" ? (
|
||||
<SectionCard
|
||||
icon={History}
|
||||
title="Change history"
|
||||
subtitle="Every recorded edit to this contract — who changed what, and when."
|
||||
>
|
||||
<ContractRevisionTimeline contractId={contract.id} bare />
|
||||
</SectionCard>
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={Milestone}
|
||||
title="Key milestones"
|
||||
subtitle="Submission, approval, signatures and validity — the dated record of this contract."
|
||||
>
|
||||
<ContractMilestonesTimeline contract={contract} />
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
icon={History}
|
||||
title="Change history"
|
||||
subtitle="Every recorded edit to this contract — who changed what, and when."
|
||||
>
|
||||
<ContractRevisionTimeline contractId={contract.id} bare />
|
||||
</SectionCard>
|
||||
</Stack>
|
||||
) : currentTab === "customer" ? (
|
||||
<ContractCustomerCard contract={contract} />
|
||||
) : (
|
||||
|
||||
@@ -236,13 +236,15 @@ export default function GlClearanceDetailPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
{/* GL Ethiopia cannot file the customs declaration until this desk
|
||||
names the officer handling the shipment in transit, so the ask
|
||||
sits above everything else on the page. */}
|
||||
{data.kind === "contract" ? (
|
||||
{/* GL Ethiopia cannot file the import customs declaration until this
|
||||
desk names the officer handling the shipment in transit, so the
|
||||
ask sits above everything else on the page. Exports have no such
|
||||
gate — Djibouti's steps come after the declaration. */}
|
||||
{isImport ? (
|
||||
<Box mb="md">
|
||||
<TransitAssigneePanel
|
||||
contractId={id!}
|
||||
entityId={id!}
|
||||
isBooking={data.kind === "booking"}
|
||||
transitAssignee={data.clearance.transitAssignee}
|
||||
side="DJ"
|
||||
readOnly={
|
||||
|
||||
@@ -332,6 +332,18 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceView;
|
||||
},
|
||||
|
||||
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
||||
requestTransitAssignee: (id: string, note?: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
|
||||
note,
|
||||
}),
|
||||
|
||||
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
|
||||
assignTransitAssignee: (id: string, assignee: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
|
||||
assignee,
|
||||
}),
|
||||
|
||||
uploadDeclaration: async (
|
||||
id: string,
|
||||
files: Record<string, File | null>,
|
||||
|
||||
@@ -11,4 +11,18 @@ export const PassengerStaff = (permission: string | string[]) =>
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Like {@link PassengerStaff} but the permission must be explicitly granted —
|
||||
* super admins / org admins get no automatic bypass.
|
||||
*/
|
||||
export const PassengerStaffStrict = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
PassengerPermissionGuard(Array.isArray(permission) ? permission : [permission], {
|
||||
strict: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin);
|
||||
|
||||
@@ -6,9 +6,25 @@ import {
|
||||
Type,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { hasPassengerPermission } from './passenger-permission.util';
|
||||
import {
|
||||
hasPassengerPermission,
|
||||
hasPassengerPermissionStrict,
|
||||
} from './passenger-permission.util';
|
||||
|
||||
export type PassengerPermissionGuardOptions = {
|
||||
/**
|
||||
* When true, super admins and org admins do NOT bypass the check — the
|
||||
* permission key must be explicitly granted to them like anyone else.
|
||||
*/
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
export function PassengerPermissionGuard(
|
||||
permissions: string[],
|
||||
options: PassengerPermissionGuardOptions = {},
|
||||
): Type<CanActivate> {
|
||||
const check = options.strict ? hasPassengerPermissionStrict : hasPassengerPermission;
|
||||
|
||||
export function PassengerPermissionGuard(permissions: string[]): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class PassengerPermissionsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
@@ -18,10 +34,11 @@ export function PassengerPermissionGuard(permissions: string[]): Type<CanActivat
|
||||
if (!permissions?.length) return true;
|
||||
if (!user) throw new UnauthorizedException('Authentication required');
|
||||
|
||||
if (permissions.some((p) => hasPassengerPermission(user, p))) return true;
|
||||
if (permissions.some((p) => check(user, p))) return true;
|
||||
|
||||
throw new ForbiddenException(
|
||||
`Missing permission. Required one of: ${permissions.join(', ')}`,
|
||||
`Missing permission. Required one of: ${permissions.join(', ')}` +
|
||||
(options.strict ? ' (granted explicitly — admin role does not bypass)' : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,20 @@ export function hasPassengerPermission(
|
||||
return collectPermissionKeys(user).includes(permissionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same check as {@link hasPassengerPermission} but WITHOUT the super-admin /
|
||||
* org-admin bypass — the permission key must be explicitly granted, whether via
|
||||
* a role or an employee position. Use for actions that must stay auditable to a
|
||||
* deliberate grant (e.g. ticket generation, which can waive a fare).
|
||||
*/
|
||||
export function hasPassengerPermissionStrict(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
return collectPermissionKeys(user).includes(permissionKey);
|
||||
}
|
||||
|
||||
export function assertPassengerPermission(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
IssueReservationBookingDto,
|
||||
} from "./guest-booking.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerAdmin, PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@ApiTags("Booking")
|
||||
@@ -352,12 +352,12 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post("reservations/:seatId/issue")
|
||||
@PassengerAdmin()
|
||||
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Issue a booking from a reserved (blocked) seat — admin only",
|
||||
summary: "Issue a booking from a reserved (blocked) seat — requires tickets:generate",
|
||||
description:
|
||||
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone. Restricted to admins (edr_passenger_app:admin) because STAFF issuance waives the fare.",
|
||||
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone. Because STAFF issuance waives the fare, this requires edr_passenger_app:tickets:generate to be explicitly granted — super admins and org admins do NOT bypass it.",
|
||||
})
|
||||
@ApiBody({ type: IssueReservationBookingDto })
|
||||
issueBookingFromReservation(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api';
|
||||
import { routesApi } from '@/lib/api/routes';
|
||||
import { usePermission } from '@/lib/use-permission';
|
||||
import { usePermissionStrict } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton'
|
||||
@@ -44,9 +44,10 @@ export default function SeatsPage() {
|
||||
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Issuing a booking off a reserved seat is admin-only (POST /bookings/reservations/:seatId/issue
|
||||
// is guarded by @PassengerAdmin) — hide the ticket action for everyone else.
|
||||
const canIssueBooking = usePermission(PERMS.admin);
|
||||
// Issuing a booking off a reserved seat needs edr_passenger_app:tickets:generate
|
||||
// (POST /bookings/reservations/:seatId/issue is guarded by @PassengerStaffStrict).
|
||||
// Strict: being an admin is not enough, the permission has to be granted.
|
||||
const canIssueBooking = usePermissionStrict(PERMS.tickets.generate);
|
||||
|
||||
const { data: schedulesData } = useQuery({
|
||||
queryKey: ['schedules'],
|
||||
|
||||
@@ -23,6 +23,7 @@ interface AuthState {
|
||||
setUser: (user: AdminUser, token: string) => void;
|
||||
initialize: () => void;
|
||||
hasPermission: (key: string) => boolean;
|
||||
hasPermissionStrict: (key: string) => boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
@@ -123,4 +124,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (user.isSuperAdmin || user.isOrgAdmin) return true;
|
||||
return user.permissions.includes(key);
|
||||
},
|
||||
|
||||
// No super-admin / org-admin bypass — mirrors PassengerStaffStrict on the API,
|
||||
// so we don't render actions that would 403.
|
||||
hasPermissionStrict: (key: string) => {
|
||||
const { user } = get();
|
||||
if (!user) return false;
|
||||
return user.permissions.includes(key);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -13,3 +13,15 @@ import { useAuthStore } from './auth-store';
|
||||
export function usePermission(key: string): boolean {
|
||||
return useAuthStore((s) => s.hasPermission(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as usePermission but WITHOUT the super-admin / org-admin bypass — the
|
||||
* permission must be explicitly granted. Use it wherever the API endpoint is
|
||||
* guarded with PassengerStaffStrict, so the UI matches what the API allows.
|
||||
*
|
||||
* Usage:
|
||||
* const canIssue = usePermissionStrict(PERMS.tickets.generate);
|
||||
*/
|
||||
export function usePermissionStrict(key: string): boolean {
|
||||
return useAuthStore((s) => s.hasPermissionStrict(key));
|
||||
}
|
||||
|
||||
@@ -752,6 +752,9 @@ export interface IContract extends BaseEntity {
|
||||
contractValidUntil?: string | null;
|
||||
expiresAt?: string | null;
|
||||
|
||||
/** When the customer last submitted this contract for review. */
|
||||
submittedAt?: string | null;
|
||||
|
||||
status: ContractStatus;
|
||||
/**
|
||||
* Body of the latest staff CHANGES_REQUESTED review note (detail response
|
||||
|
||||
@@ -783,6 +783,17 @@ export interface ClearanceView {
|
||||
/** Boundary milestone complete — customer may proceed to operations. */
|
||||
operationReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
/**
|
||||
* Pre-declaration handshake with GL Djibouti: who handles this shipment in
|
||||
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
|
||||
* file the import customs declaration before it is set.
|
||||
*/
|
||||
transitAssignee?: {
|
||||
requestedAt: string | null;
|
||||
requestNote: string | null;
|
||||
name: string | null;
|
||||
assignedAt: string | null;
|
||||
} | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
|
||||
Reference in New Issue
Block a user