mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
feat(companies): onboard co-operative unions and farms
They hold a TIN but no business licence, so there is no eTrade record to look their registration up in. A checkbox on the first wizard step marks them, and everything that assumed a trade licence bends around it: - The company step replaces the eTrade lookup with typed registration details — name, region, zone, woreda, kebele, house number — required exactly because they are now on screen. applyEtradeSourcedFields skips the lookup rather than failing it, so what the customer sends is what is stored. - No freight-forwarder role. Forwarding is licensed work, so the option is not offered, and the API refuses it at start-onboarding and at every later role-add rather than letting approval fail on a document they cannot produce. - No per-role business-licence upload, client-side or in the completion gate. - Their own document set (company_onboarding_documents_cooperative) merges on top of the nationality one, admin-managed like every other set. Nationality wins a fileKey collision so no slot renders twice, and the DARS paper is not injected into it — the set it merges onto already carries one. - The owner is typed in full; with no eTrade manager on file the licence comparison reports "nothing to compare against", which backoffice now explains rather than leaving as a bare dash. Stored as an attributes flag, not a column: everything it changes is behavioural, and nothing queries or joins on it.
This commit is contained in:
@@ -49,6 +49,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
|||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||||
|
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
||||||
import { OtpModule } from "./modules/otp/otp.module";
|
import { OtpModule } from "./modules/otp/otp.module";
|
||||||
@@ -221,6 +222,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
ExchangeSettingsModule,
|
ExchangeSettingsModule,
|
||||||
|
StampSettingsModule,
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
SupportContentModule,
|
SupportContentModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row table holding the one company stamp/seal image stamped onto
|
||||||
|
* generated invoice/receipt PDFs (see StampSettingsService /
|
||||||
|
* InvoiceDocumentService). Same single-row shape as exchange_settings; the
|
||||||
|
* app never inserts more than one row.
|
||||||
|
*/
|
||||||
|
export class StampSettings3400000000000 implements MigrationInterface {
|
||||||
|
name = "StampSettings3400000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.stamp_settings (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
stamp_file_id uuid REFERENCES freight.files(id),
|
||||||
|
updated_by_id uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service";
|
|||||||
* Standalone document infrastructure — generic HTML→PDF plus the shared
|
* Standalone document infrastructure — generic HTML→PDF plus the shared
|
||||||
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
|
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
|
||||||
* warehouses, …) can import it to print invoices without coupling to the
|
* warehouses, …) can import it to print invoices without coupling to the
|
||||||
* billing payment graph.
|
* billing payment graph. StampSettingsService is @Global (see
|
||||||
|
* StampSettingsModule) so InvoiceDocumentService can inject it without this
|
||||||
|
* module declaring an explicit import.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
providers: [PdfRenderService, InvoiceDocumentService],
|
providers: [PdfRenderService, InvoiceDocumentService],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
|
||||||
import { PdfRenderService } from "./pdf-render.service";
|
import { PdfRenderService } from "./pdf-render.service";
|
||||||
import {
|
import {
|
||||||
PdfColor,
|
PdfColor,
|
||||||
@@ -53,6 +54,13 @@ export interface InvoiceDocumentModel {
|
|||||||
totals: InvoiceDocumentTotal[];
|
totals: InvoiceDocumentTotal[];
|
||||||
/** Override the round seal text; defaults from kind/status. */
|
/** Override the round seal text; defaults from kind/status. */
|
||||||
sealText?: string;
|
sealText?: string;
|
||||||
|
/**
|
||||||
|
* Company stamp image (data URL) to render instead of the plain text seal.
|
||||||
|
* Callers normally leave this unset — `InvoiceDocumentService.render()`
|
||||||
|
* fills it in from the single global stamp in StampSettingsService; set it
|
||||||
|
* explicitly only to override that default for one document.
|
||||||
|
*/
|
||||||
|
stampImageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,12 +71,21 @@ export interface InvoiceDocumentModel {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InvoiceDocumentService {
|
export class InvoiceDocumentService {
|
||||||
constructor(private readonly pdf: PdfRenderService) {}
|
constructor(
|
||||||
|
private readonly pdf: PdfRenderService,
|
||||||
|
private readonly stampSettings: StampSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async render(
|
async render(
|
||||||
model: InvoiceDocumentModel,
|
model: InvoiceDocumentModel,
|
||||||
): Promise<{ filename: string; buffer: Buffer }> {
|
): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const html = this.buildHtml(model);
|
const stampImageUrl =
|
||||||
|
model.stampImageUrl !== undefined
|
||||||
|
? model.stampImageUrl
|
||||||
|
: await this.stampSettings.getStampImageUrl();
|
||||||
|
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl };
|
||||||
|
|
||||||
|
const html = this.buildHtml(resolvedModel);
|
||||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||||
return {
|
return {
|
||||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||||
@@ -77,7 +94,11 @@ export class InvoiceDocumentService {
|
|||||||
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
|
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
|
||||||
// summary grid, line-item table, totals) from the model — not a flat
|
// summary grid, line-item table, totals) from the model — not a flat
|
||||||
// plain-text dump — so it still reads as a proper invoice document.
|
// plain-text dump — so it still reads as a proper invoice document.
|
||||||
fallback: () => this.buildFallbackPdf(model),
|
// ponytail: still draws the plain vector seal, not the uploaded stamp
|
||||||
|
// image — embedding a raster image needs a new PDF XObject primitive
|
||||||
|
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
|
||||||
|
// carry the real stamp too; today it's a rare degraded fallback.
|
||||||
|
fallback: () => this.buildFallbackPdf(resolvedModel),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -218,6 +239,10 @@ export class InvoiceDocumentService {
|
|||||||
const showCategory = Boolean(model.categoryHeader);
|
const showCategory = Boolean(model.categoryHeader);
|
||||||
const sealText =
|
const sealText =
|
||||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||||
|
const sealMarkup = model.stampImageUrl
|
||||||
|
? `<img src="${esc(model.stampImageUrl)}" alt="Company stamp" />`
|
||||||
|
: esc(sealText);
|
||||||
|
const sealClass = model.stampImageUrl ? "seal seal-image" : "seal";
|
||||||
|
|
||||||
const summaryRows = model.summary
|
const summaryRows = model.summary
|
||||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||||
@@ -256,6 +281,8 @@ export class InvoiceDocumentService {
|
|||||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||||
|
.seal.seal-image { border: none; border-radius: 0; opacity: 1; transform: none; }
|
||||||
|
.seal img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||||
@@ -283,7 +310,7 @@ export class InvoiceDocumentService {
|
|||||||
Issued: ${esc(date(model.issuedAt))}
|
Issued: ${esc(date(model.issuedAt))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="seal">${esc(sealText)}</div>
|
<div class="${sealClass}">${sealMarkup}</div>
|
||||||
<div class="summary">${summaryRows}</div>
|
<div class="summary">${summaryRows}</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export class CompaniesController {
|
|||||||
dto.companyType,
|
dto.companyType,
|
||||||
dto.roles,
|
dto.roles,
|
||||||
dto.nationality,
|
dto.nationality,
|
||||||
|
dto.cooperative,
|
||||||
);
|
);
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ function makeService(existing: ExistingProfile[]) {
|
|||||||
})),
|
})),
|
||||||
softDelete: jest.fn(async () => undefined),
|
softDelete: jest.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
const companiesRepo = { update: jest.fn(async () => null) };
|
// `findById` is only consulted when the co-operative flag is in play (adding
|
||||||
|
// a forwarder role, or setting the flag itself) — a plain company row is the
|
||||||
|
// right answer for every case here.
|
||||||
|
const companiesRepo = {
|
||||||
|
update: jest.fn(async () => null),
|
||||||
|
findById: jest.fn(async () => ({ id: "company-1", attributes: {} })),
|
||||||
|
};
|
||||||
const profilesRepo = {
|
const profilesRepo = {
|
||||||
findByUserId: jest.fn(async () => ({
|
findByUserId: jest.fn(async () => ({
|
||||||
id: "external-1",
|
id: "external-1",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { FilesService } from "../files/files.service";
|
|||||||
import { FileRecord } from "../files/entities/file.entity";
|
import { FileRecord } from "../files/entities/file.entity";
|
||||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||||
import {
|
import {
|
||||||
|
COOPERATIVE_ONBOARDING_CODE,
|
||||||
POA_DELEGATION_FILE_KEY,
|
POA_DELEGATION_FILE_KEY,
|
||||||
POA_DELEGATION_LABEL,
|
POA_DELEGATION_LABEL,
|
||||||
POA_DELEGATION_PENDING_CODE,
|
POA_DELEGATION_PENDING_CODE,
|
||||||
@@ -60,6 +61,8 @@ import {
|
|||||||
CompanyNationality,
|
CompanyNationality,
|
||||||
CompanyStatus,
|
CompanyStatus,
|
||||||
CompanyType,
|
CompanyType,
|
||||||
|
COOPERATIVE_KEY,
|
||||||
|
isCooperative,
|
||||||
} from "./entities/company.entity";
|
} from "./entities/company.entity";
|
||||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import {
|
import {
|
||||||
@@ -262,6 +265,7 @@ export class CompaniesService {
|
|||||||
: "company_onboarding_documents_ethiopian";
|
: "company_onboarding_documents_ethiopian";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -368,19 +372,41 @@ export class CompaniesService {
|
|||||||
companyType: CompanyType,
|
companyType: CompanyType,
|
||||||
roles: ProfileType[],
|
roles: ProfileType[],
|
||||||
nationality?: CompanyNationality,
|
nationality?: CompanyNationality,
|
||||||
|
cooperative?: boolean,
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
// Already started — reuse the existing draft, just ensure roles exist and
|
// Already started — reuse the existing draft, just ensure roles exist and
|
||||||
// keep the nationality up to date if it was (re)selected.
|
// keep the nationality up to date if it was (re)selected.
|
||||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const companyId = existing.company?.id ?? existing.companyId;
|
const companyId = existing.company?.id ?? existing.companyId;
|
||||||
|
// Only load the row when the answer actually depends on it: to merge the
|
||||||
|
// flag into `attributes`, or to read a stored one the caller didn't send.
|
||||||
|
const needsCompany =
|
||||||
|
cooperative !== undefined ||
|
||||||
|
roles.includes(ProfileType.freightForwarder);
|
||||||
|
const current = needsCompany
|
||||||
|
? await this.companiesRepo.findById(companyId)
|
||||||
|
: null;
|
||||||
|
this.assertRolesAllowedForCooperative(
|
||||||
|
cooperative ?? isCooperative(current),
|
||||||
|
roles,
|
||||||
|
);
|
||||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||||
if (nationality) {
|
const updates: Partial<Company> = {};
|
||||||
await this.companiesRepo.update(companyId, { nationality });
|
if (nationality) updates.nationality = nationality;
|
||||||
|
if (cooperative !== undefined) {
|
||||||
|
updates.attributes = {
|
||||||
|
...(current?.attributes ?? {}),
|
||||||
|
[COOPERATIVE_KEY]: cooperative,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await this.companiesRepo.update(companyId, updates);
|
||||||
}
|
}
|
||||||
return this.getCompanyInfoByUserId(identity.userId);
|
return this.getCompanyInfoByUserId(identity.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.assertRolesAllowedForCooperative(cooperative === true, roles);
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
|
|
||||||
@@ -393,6 +419,7 @@ export class CompaniesService {
|
|||||||
country: "Ethiopia",
|
country: "Ethiopia",
|
||||||
nationality: nationality ?? CompanyNationality.Ethiopian,
|
nationality: nationality ?? CompanyNationality.Ethiopian,
|
||||||
status: CompanyStatus.Pending,
|
status: CompanyStatus.Pending,
|
||||||
|
...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.profilesRepo.create({
|
await this.profilesRepo.create({
|
||||||
@@ -410,6 +437,27 @@ export class CompaniesService {
|
|||||||
return this.getCompanyInfoByUserId(identity.userId);
|
return this.getCompanyInfoByUserId(identity.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A co-operative union or farm cannot hold the freight-forwarder role.
|
||||||
|
*
|
||||||
|
* Forwarding is licensed work — the forwarder signs on other companies'
|
||||||
|
* behalf, which is why the role carries a mandatory Power of Attorney and a
|
||||||
|
* DARS delegation paper. A co-op is here precisely because it has no business
|
||||||
|
* licence, so the role is refused at the door rather than left to fail later
|
||||||
|
* at approval with a document it can never produce.
|
||||||
|
*/
|
||||||
|
private assertRolesAllowedForCooperative(
|
||||||
|
cooperative: boolean,
|
||||||
|
roles: ProfileType[],
|
||||||
|
): void {
|
||||||
|
if (!cooperative) return;
|
||||||
|
if (roles.includes(ProfileType.freightForwarder)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconcile the company's operational profiles with the roles the user has
|
* Reconcile the company's operational profiles with the roles the user has
|
||||||
* selected: create the missing ones, drop the ones they deselected.
|
* selected: create the missing ones, drop the ones they deselected.
|
||||||
@@ -1887,6 +1935,7 @@ export class CompaniesService {
|
|||||||
// without a Power of Attorney and its DARS paper — checked here so the
|
// without a Power of Attorney and its DARS paper — checked here so the
|
||||||
// customer is told at the point of asking, not at review.
|
// customer is told at the point of asking, not at review.
|
||||||
if (type === ProfileType.freightForwarder) {
|
if (type === ProfileType.freightForwarder) {
|
||||||
|
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||||
const asForwarder = this.withProfileType(company, type);
|
const asForwarder = this.withProfileType(company, type);
|
||||||
this.assertIdentityVerified(asForwarder);
|
this.assertIdentityVerified(asForwarder);
|
||||||
await this.assertPoaDelegationSatisfied(
|
await this.assertPoaDelegationSatisfied(
|
||||||
@@ -1933,6 +1982,7 @@ export class CompaniesService {
|
|||||||
|
|
||||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
if (!created && type === ProfileType.freightForwarder) {
|
if (!created && type === ProfileType.freightForwarder) {
|
||||||
|
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||||
const asForwarder = this.withProfileType(company, type);
|
const asForwarder = this.withProfileType(company, type);
|
||||||
this.assertIdentityVerified(asForwarder);
|
this.assertIdentityVerified(asForwarder);
|
||||||
await this.assertPoaDelegationSatisfied(
|
await this.assertPoaDelegationSatisfied(
|
||||||
@@ -1986,18 +2036,35 @@ export class CompaniesService {
|
|||||||
.filter((f) => !f.get(company))
|
.filter((f) => !f.get(company))
|
||||||
.map((f) => ({ key: f.key, label: f.label }));
|
.map((f) => ({ key: f.key, label: f.label }));
|
||||||
|
|
||||||
// 2. Nationality-based company documents + which are already uploaded.
|
// 2. Nationality-based company documents + which are already uploaded. A
|
||||||
|
// co-operative adds its own set on top: it provides everything its
|
||||||
|
// nationality demands, plus the papers standing in for the business licence
|
||||||
|
// it does not hold.
|
||||||
|
const cooperative = isCooperative(company);
|
||||||
const documentSettingCode = this.documentSettingCodeFor(
|
const documentSettingCode = this.documentSettingCodeFor(
|
||||||
company.nationality,
|
company.nationality,
|
||||||
);
|
);
|
||||||
const [setting, uploadedFiles] = await Promise.all([
|
const [setting, coopSetting, uploadedFiles] = await Promise.all([
|
||||||
this.fileUploadSettingsService
|
this.fileUploadSettingsService
|
||||||
.getByCode(documentSettingCode)
|
.getByCode(documentSettingCode)
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
|
cooperative
|
||||||
|
? this.fileUploadSettingsService
|
||||||
|
.getByCode(COOPERATIVE_ONBOARDING_CODE)
|
||||||
|
.catch(() => null)
|
||||||
|
: Promise.resolve(null),
|
||||||
this.filesService.findByResource(company.id, "companies"),
|
this.filesService.findByResource(company.id, "companies"),
|
||||||
]);
|
]);
|
||||||
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
|
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
|
||||||
const documents = (setting?.fields ?? [])
|
// The co-op set is admin-managed and could name a fileKey the nationality
|
||||||
|
// set already carries; the nationality field wins so the same slot is never
|
||||||
|
// rendered (or required) twice.
|
||||||
|
const baseFields = setting?.fields ?? [];
|
||||||
|
const baseKeys = new Set(baseFields.map((f) => f.fileKey));
|
||||||
|
const documents = [
|
||||||
|
...baseFields,
|
||||||
|
...(coopSetting?.fields ?? []).filter((f) => !baseKeys.has(f.fileKey)),
|
||||||
|
]
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
@@ -2029,7 +2096,13 @@ export class CompaniesService {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
// A co-operative holds no business licence — that is the whole reason it
|
||||||
|
// skips the eTrade lookup — so the per-role licence is not owed. Its own
|
||||||
|
// document set (merged above) is what stands in for it. The profiles are
|
||||||
|
// still reported so the portal can show them; only the requirement lifts.
|
||||||
|
const missingLicenses = cooperative
|
||||||
|
? []
|
||||||
|
: licenseProfiles.filter((p) => !p.uploaded);
|
||||||
|
|
||||||
// 4. Power of Attorney. Whether there is one at all is the company's own
|
// 4. Power of Attorney. Whether there is one at all is the company's own
|
||||||
// declaration — the question the wizard asks outright — and that answer is
|
// declaration — the question the wizard asks outright — and that answer is
|
||||||
@@ -2115,7 +2188,7 @@ export class CompaniesService {
|
|||||||
const total =
|
const total =
|
||||||
requiredInfo.length +
|
requiredInfo.length +
|
||||||
requiredDocCount +
|
requiredDocCount +
|
||||||
licenseProfiles.length +
|
(cooperative ? 0 : licenseProfiles.length) +
|
||||||
poaItemCount +
|
poaItemCount +
|
||||||
// The declaration and the verification it selects.
|
// The declaration and the verification it selects.
|
||||||
2;
|
2;
|
||||||
@@ -2130,7 +2203,11 @@ export class CompaniesService {
|
|||||||
|
|
||||||
return new OnboardingRequirementsResponseDto({
|
return new OnboardingRequirementsResponseDto({
|
||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
|
cooperativeDocumentSettingCode: cooperative
|
||||||
|
? COOPERATIVE_ONBOARDING_CODE
|
||||||
|
: null,
|
||||||
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||||
|
cooperative,
|
||||||
companyInfo: {
|
companyInfo: {
|
||||||
complete: missingInfo.length === 0,
|
complete: missingInfo.length === 0,
|
||||||
missingFields: missingInfo,
|
missingFields: missingInfo,
|
||||||
@@ -3369,6 +3446,13 @@ export class CompaniesService {
|
|||||||
company: Company,
|
company: Company,
|
||||||
dto: UpdateProfileDto & { etradeManager?: { name: string; phone: string } },
|
dto: UpdateProfileDto & { etradeManager?: { name: string; phone: string } },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// A co-operative union or farm has a TIN but no business licence, so eTrade holds no
|
||||||
|
// record to check these against — the customer types the company name and
|
||||||
|
// the registered address themselves, and what they send IS the data. The
|
||||||
|
// check is skipped rather than failed: running the lookup would 400 every
|
||||||
|
// save with "no registration found for this TIN".
|
||||||
|
if (isCooperative(company)) return;
|
||||||
|
|
||||||
const touched = ETRADE_SOURCED_FIELDS.some(
|
const touched = ETRADE_SOURCED_FIELDS.some(
|
||||||
(key) => key !== "tin" && dto[key] !== undefined,
|
(key) => key !== "tin" && dto[key] !== undefined,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,7 +68,19 @@ export interface OnboardingPoaState {
|
|||||||
export class OnboardingRequirementsResponseDto {
|
export class OnboardingRequirementsResponseDto {
|
||||||
/** Resolved document setting code (by nationality) the docs were drawn from. */
|
/** Resolved document setting code (by nationality) the docs were drawn from. */
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
|
/**
|
||||||
|
* The co-operative document set, merged on top of the nationality one — null
|
||||||
|
* for every other company. `documents` below already carries the merged
|
||||||
|
* result; this is only so the portal can fetch the same extra fields when it
|
||||||
|
* renders the pickers from the file-settings endpoint.
|
||||||
|
*/
|
||||||
|
cooperativeDocumentSettingCode: string | null;
|
||||||
nationality: string;
|
nationality: string;
|
||||||
|
/**
|
||||||
|
* The company trades as a co-operative: no business licence, so no eTrade
|
||||||
|
* lookup, no per-role licence upload, and no freight-forwarder role.
|
||||||
|
*/
|
||||||
|
cooperative: boolean;
|
||||||
|
|
||||||
/** Required company-information fields and whether each is filled. */
|
/** Required company-information fields and whether each is filled. */
|
||||||
companyInfo: {
|
companyInfo: {
|
||||||
@@ -106,7 +118,9 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
|
|
||||||
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
|
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
|
||||||
this.documentSettingCode = init.documentSettingCode;
|
this.documentSettingCode = init.documentSettingCode;
|
||||||
|
this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode;
|
||||||
this.nationality = init.nationality;
|
this.nationality = init.nationality;
|
||||||
|
this.cooperative = init.cooperative;
|
||||||
this.companyInfo = init.companyInfo;
|
this.companyInfo = init.companyInfo;
|
||||||
this.documents = init.documents;
|
this.documents = init.documents;
|
||||||
this.licenseProfiles = init.licenseProfiles;
|
this.licenseProfiles = init.licenseProfiles;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
buildCompanyIdentityState,
|
buildCompanyIdentityState,
|
||||||
CompanyIdentityStateDto,
|
CompanyIdentityStateDto,
|
||||||
} from "./complete-identity-verification.dto";
|
} from "./complete-identity-verification.dto";
|
||||||
import { Company } from "../entities/company.entity";
|
import { Company, isCooperative } from "../entities/company.entity";
|
||||||
import { ExternalProfile } from "../entities/external-profile.entity";
|
import { ExternalProfile } from "../entities/external-profile.entity";
|
||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
@@ -15,6 +15,12 @@ export class ProfileResponseDto {
|
|||||||
companyName: string;
|
companyName: string;
|
||||||
companyType: string;
|
companyType: string;
|
||||||
nationality: string | null;
|
nationality: string | null;
|
||||||
|
/**
|
||||||
|
* The company trades as a co-operative: it has a TIN but no business licence,
|
||||||
|
* so the company step collects the registration by hand instead of fetching
|
||||||
|
* it from eTrade.
|
||||||
|
*/
|
||||||
|
cooperative: boolean;
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
companyAddress: string | null;
|
companyAddress: string | null;
|
||||||
tinNumber: string;
|
tinNumber: string;
|
||||||
@@ -86,6 +92,7 @@ export class ProfileResponseDto {
|
|||||||
this.companyName = company.name;
|
this.companyName = company.name;
|
||||||
this.companyType = company.type;
|
this.companyType = company.type;
|
||||||
this.nationality = company.nationality ?? null;
|
this.nationality = company.nationality ?? null;
|
||||||
|
this.cooperative = isCooperative(company);
|
||||||
this.companyProfiles =
|
this.companyProfiles =
|
||||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||||
[];
|
[];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CompanyType,
|
CompanyType,
|
||||||
CompanyStatus,
|
CompanyStatus,
|
||||||
CompanyNationality,
|
CompanyNationality,
|
||||||
|
isCooperative,
|
||||||
} from '../entities/company.entity';
|
} from '../entities/company.entity';
|
||||||
import {
|
import {
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
@@ -55,6 +56,12 @@ export class ResponseCompanyDto {
|
|||||||
type: CompanyType;
|
type: CompanyType;
|
||||||
status: CompanyStatus;
|
status: CompanyStatus;
|
||||||
nationality?: CompanyNationality | null;
|
nationality?: CompanyNationality | null;
|
||||||
|
/**
|
||||||
|
* The company trades as a co-operative: no business licence, so its
|
||||||
|
* registration was typed rather than fetched from eTrade and there is no
|
||||||
|
* eTrade manager to check the owner against.
|
||||||
|
*/
|
||||||
|
cooperative: boolean;
|
||||||
tin: string;
|
tin: string;
|
||||||
vatNumber?: string | null;
|
vatNumber?: string | null;
|
||||||
fanNumber?: string | null;
|
fanNumber?: string | null;
|
||||||
@@ -110,6 +117,7 @@ export class ResponseCompanyDto {
|
|||||||
this.type = company.type;
|
this.type = company.type;
|
||||||
this.status = company.status;
|
this.status = company.status;
|
||||||
this.nationality = company.nationality ?? null;
|
this.nationality = company.nationality ?? null;
|
||||||
|
this.cooperative = isCooperative(company);
|
||||||
this.tin = company.tin;
|
this.tin = company.tin;
|
||||||
this.vatNumber = company.vatNumber;
|
this.vatNumber = company.vatNumber;
|
||||||
this.fanNumber = company.fanNumber;
|
this.fanNumber = company.fanNumber;
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator";
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
} from "class-validator";
|
||||||
import { CompanyNationality, CompanyType } from "../entities/company.entity";
|
import { CompanyNationality, CompanyType } from "../entities/company.entity";
|
||||||
import { ProfileType } from "../entities/company-profile.entity";
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
@@ -14,4 +20,15 @@ export class StartOnboardingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(CompanyNationality)
|
@IsEnum(CompanyNationality)
|
||||||
nationality?: CompanyNationality;
|
nationality?: CompanyNationality;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The company trades as a co-operative: it holds a TIN but no business
|
||||||
|
* licence, so there is no eTrade record to fetch its registration from.
|
||||||
|
* Chosen on the same step as the nationality and the roles, because it
|
||||||
|
* decides all three of what the next step asks for, which documents apply,
|
||||||
|
* and which roles are even available (a co-op cannot freight-forward).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
cooperative?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,25 @@ export enum CompanyNationality {
|
|||||||
Foreign = "foreign",
|
Foreign = "foreign",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `attributes` key marking a co-operative union or farm.
|
||||||
|
*
|
||||||
|
* Such a company has a TIN but no business licence, so there is no eTrade record to
|
||||||
|
* look its registration up in — the company name, registered address and the
|
||||||
|
* owner are all typed instead of fetched, and the eTrade authenticity check is
|
||||||
|
* skipped rather than failed. It is a flag rather than a column because
|
||||||
|
* everything it changes is behavioural (which lookup runs, which documents
|
||||||
|
* apply, which roles are offered); nothing queries or joins on it.
|
||||||
|
*/
|
||||||
|
export const COOPERATIVE_KEY = "cooperative";
|
||||||
|
|
||||||
|
/** Is this a co-operative union or farm (a TIN, but no business licence)? */
|
||||||
|
export function isCooperative(
|
||||||
|
company: Pick<Company, "attributes"> | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return company?.attributes?.[COOPERATIVE_KEY] === true;
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "companies" })
|
@Entity({ schema: "freight", name: "companies" })
|
||||||
@Index(["tin"])
|
@Index(["tin"])
|
||||||
@Index(["type"])
|
@Index(["type"])
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "./interfaces/file-upload-settings.repository.interface";
|
} from "./interfaces/file-upload-settings.repository.interface";
|
||||||
import {
|
import {
|
||||||
COMPANY_ONBOARDING_CODE_PREFIX,
|
COMPANY_ONBOARDING_CODE_PREFIX,
|
||||||
|
COOPERATIVE_ONBOARDING_CODE,
|
||||||
POA_DELEGATION_FILE_KEY,
|
POA_DELEGATION_FILE_KEY,
|
||||||
poaDelegationField,
|
poaDelegationField,
|
||||||
} from "./poa-delegation.constants";
|
} from "./poa-delegation.constants";
|
||||||
@@ -56,6 +57,10 @@ export class FileUploadSettingsService {
|
|||||||
*/
|
*/
|
||||||
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
|
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
|
||||||
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
|
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
|
||||||
|
// The co-operative set is merged ON TOP of a nationality set that already
|
||||||
|
// carries the paper; injecting it here too would hand the portal the same
|
||||||
|
// slot twice.
|
||||||
|
if (setting.code === COOPERATIVE_ONBOARDING_CODE) return setting;
|
||||||
const fields = setting.fields ?? [];
|
const fields = setting.fields ?? [];
|
||||||
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
|
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
|
|||||||
/** Prefix of the setting codes the field is injected into. */
|
/** Prefix of the setting codes the field is injected into. */
|
||||||
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
|
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The co-operative onboarding set. Unlike the nationality sets it is ADDITIVE —
|
||||||
|
* merged on top of the company's `_ethiopian`/`_foreign` set rather than
|
||||||
|
* replacing it — which is why the delegation paper is not injected into it: the
|
||||||
|
* set it is merged onto already carries one.
|
||||||
|
*/
|
||||||
|
export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`;
|
||||||
|
|
||||||
const POA_DELEGATION_HELP =
|
const POA_DELEGATION_HELP =
|
||||||
"Delegation paper issued by the Documents Authentication and Registration " +
|
"Delegation paper issued by the Documents Authentication and Registration " +
|
||||||
"Service (DARS) delegating the representative named above. Upload the " +
|
"Service (DARS) delegating the representative named above. Upload the " +
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateStampSettingDto {
|
||||||
|
@ApiProperty({ description: "Stamp image as a base64 data URL (PNG/JPG)." })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
stampImageBase64!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
|
||||||
|
import { FileRecord } from "../../files/entities/file.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row table holding the one company stamp/seal image stamped onto
|
||||||
|
* generated invoice/receipt PDFs (see InvoiceDocumentService). Mirrors the
|
||||||
|
* exchange_settings single-row pattern — `get()` lazily creates the row, and
|
||||||
|
* there is never more than one.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "stamp_settings" })
|
||||||
|
export class StampSetting extends BaseEntity {
|
||||||
|
@Column({ name: "stamp_file_id", type: "uuid", nullable: true })
|
||||||
|
stampFileId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => FileRecord, { nullable: true })
|
||||||
|
@JoinColumn({ name: "stamp_file_id" })
|
||||||
|
stampFile?: FileRecord | null;
|
||||||
|
|
||||||
|
/** IAM user id of the last operator to set/clear the stamp. */
|
||||||
|
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||||
|
updatedById?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Put } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { UpdateStampSettingDto } from "./dto/update-stamp-setting.dto";
|
||||||
|
import { StampSettingsService } from "./stamp-settings.service";
|
||||||
|
|
||||||
|
@ApiTags("stamp-settings")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("stamp-settings")
|
||||||
|
export class StampSettingsController {
|
||||||
|
constructor(private readonly service: StampSettingsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.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])
|
||||||
|
@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])
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Clear the company stamp (invoices fall back to the plain seal)",
|
||||||
|
})
|
||||||
|
clear(@CurrentUser() user: TCurrentUser) {
|
||||||
|
return this.service.clearStamp(user?.id ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { FilesModule } from "../files/files.module";
|
||||||
|
import { MinioModule } from "../minio/minio.module";
|
||||||
|
import { StampSetting } from "./entities/stamp-setting.entity";
|
||||||
|
import { StampSettingsController } from "./stamp-settings.controller";
|
||||||
|
import { StampSettingsRepository } from "./stamp-settings.repository";
|
||||||
|
import { StampSettingsService } from "./stamp-settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global so DocumentsModule (invoice PDF rendering) can inject
|
||||||
|
* {@link StampSettingsService} without pulling in a circular billing/warehouse
|
||||||
|
* dependency — same reasoning as ExchangeSettingsModule.
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([StampSetting]), FilesModule, MinioModule],
|
||||||
|
controllers: [StampSettingsController],
|
||||||
|
providers: [StampSettingsRepository, StampSettingsService],
|
||||||
|
exports: [StampSettingsService],
|
||||||
|
})
|
||||||
|
export class StampSettingsModule {}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
|
||||||
|
import { StampSetting } from "./entities/stamp-setting.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StampSettingsRepository extends BaseRepository<StampSetting> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(StampSetting)
|
||||||
|
repo: Repository<StampSetting>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single settings row, with its stamp file joined, or null before first upload. */
|
||||||
|
findSingleton(): Promise<StampSetting | null> {
|
||||||
|
return this.repository.findOne({ where: {}, relations: ["stampFile"] });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { FilesService } from "../files/files.service";
|
||||||
|
import { FileRecord } from "../files/entities/file.entity";
|
||||||
|
import { MinioService } from "../minio/minio.service";
|
||||||
|
import { StampSettingsRepository } from "./stamp-settings.repository";
|
||||||
|
import { StampSetting } from "./entities/stamp-setting.entity";
|
||||||
|
|
||||||
|
export interface StampSettingView {
|
||||||
|
stampImageUrl: string | null;
|
||||||
|
updatedById: string | null;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the single `stamp_settings` row: the one company stamp/seal image used
|
||||||
|
* on generated invoice/receipt PDFs (see InvoiceDocumentService). Same
|
||||||
|
* single-row shape as ExchangeSettingsService, but the value is an uploaded
|
||||||
|
* image (via FilesService) rather than a scalar.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class StampSettingsService {
|
||||||
|
private readonly logger = new Logger(StampSettingsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly repository: StampSettingsRepository,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly minioService: MinioService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The settings row, created empty on first access. */
|
||||||
|
async get(): Promise<StampSetting> {
|
||||||
|
const existing = await this.repository.findSingleton();
|
||||||
|
if (existing) return existing;
|
||||||
|
return this.repository.create({ stampFileId: null, updatedById: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current stamp, with the image inlined as a data URL (or null if unset). */
|
||||||
|
async getView(): Promise<StampSettingView> {
|
||||||
|
const setting = await this.get();
|
||||||
|
return {
|
||||||
|
stampImageUrl: await this.inlineImageUrl(setting.stampFile?.url),
|
||||||
|
updatedById: setting.updatedById ?? null,
|
||||||
|
updatedAt: setting.updatedAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stamp image for embedding into invoice PDFs. Never throws — invoice
|
||||||
|
* generation must succeed even if the stamp lookup fails; callers fall back
|
||||||
|
* to the programmatic seal when this returns null.
|
||||||
|
*/
|
||||||
|
async getStampImageUrl(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const setting = await this.get();
|
||||||
|
return await this.inlineImageUrl(setting.stampFile?.url);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not load company stamp for PDF rendering: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the stamp image, storing it in MinIO via FilesService. */
|
||||||
|
async setStamp(
|
||||||
|
stampImageBase64: string,
|
||||||
|
updatedById?: string | null,
|
||||||
|
): Promise<StampSettingView> {
|
||||||
|
const current = await this.get();
|
||||||
|
const previousFileId = current.stampFileId ?? null;
|
||||||
|
|
||||||
|
const fileRecord = await this.filesService.upload({
|
||||||
|
resourceId: current.id,
|
||||||
|
resource: "stamp_settings",
|
||||||
|
code: "stamp",
|
||||||
|
file: this.toUploadFile(stampImageBase64),
|
||||||
|
uploadedByUserId: updatedById ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.repository.update(current.id, {
|
||||||
|
stampFileId: fileRecord.id,
|
||||||
|
updatedById: updatedById ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousFileId && previousFileId !== fileRecord.id) {
|
||||||
|
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`);
|
||||||
|
return this.getView();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the stamp (invoices fall back to the programmatic seal). */
|
||||||
|
async clearStamp(updatedById?: string | null): Promise<StampSettingView> {
|
||||||
|
const current = await this.get();
|
||||||
|
const previousFileId = current.stampFileId ?? null;
|
||||||
|
|
||||||
|
await this.repository.update(current.id, {
|
||||||
|
stampFileId: null,
|
||||||
|
updatedById: updatedById ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousFileId) {
|
||||||
|
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUploadFile(base64: string): Express.Multer.File {
|
||||||
|
const raw = base64.includes(",") ? base64.split(",")[1]! : base64;
|
||||||
|
const buffer = Buffer.from(raw, "base64");
|
||||||
|
return {
|
||||||
|
fieldname: "stamp",
|
||||||
|
originalname: "company-stamp.png",
|
||||||
|
encoding: "7bit",
|
||||||
|
mimetype: "image/png",
|
||||||
|
size: buffer.length,
|
||||||
|
buffer,
|
||||||
|
stream: Readable.from(buffer),
|
||||||
|
destination: "",
|
||||||
|
filename: "",
|
||||||
|
path: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inlineImageUrl(url?: string | null): Promise<string | null> {
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith("data:")) return url;
|
||||||
|
try {
|
||||||
|
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||||
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
const buffer = await this.streamToBuffer(stream);
|
||||||
|
return `data:image/png;base64,${buffer.toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on("data", (chunk: Buffer | string) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
stream.on("error", reject);
|
||||||
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,31 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
// },
|
// },
|
||||||
// ];
|
// ];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra documents a co-operative union or farm provides, merged on top of its
|
||||||
|
* nationality set. It has a TIN but no business licence, so the papers that
|
||||||
|
* evidence the co-operative itself stand in for the trade licence every other
|
||||||
|
* company uploads.
|
||||||
|
*
|
||||||
|
* Only the registration certificate is seeded, and the set is admin-managed
|
||||||
|
* like every other onboarding set — what these members must actually produce
|
||||||
|
* is a backoffice decision, edited in the file-settings editor.
|
||||||
|
*/
|
||||||
|
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||||
|
{
|
||||||
|
fileKey: "cooperative_registration_certificate",
|
||||||
|
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
||||||
|
helpText:
|
||||||
|
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 50,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
interface OnboardingDocumentSetting {
|
interface OnboardingDocumentSetting {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -176,6 +201,14 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
|
|||||||
entity: "customer",
|
entity: "customer",
|
||||||
fields: FOREIGN_ONBOARDING_FIELDS,
|
fields: FOREIGN_ONBOARDING_FIELDS,
|
||||||
},
|
},
|
||||||
|
// Additive, not a nationality of its own: a union or farm still uploads
|
||||||
|
// everything its nationality set demands, and these on top.
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_cooperative",
|
||||||
|
label: "Co-operative union / farm onboarding documents (additional)",
|
||||||
|
entity: "customer",
|
||||||
|
fields: COOPERATIVE_ONBOARDING_FIELDS,
|
||||||
|
},
|
||||||
// Legacy per-company-type codes — removed, unused by any resolver or portal
|
// Legacy per-company-type codes — removed, unused by any resolver or portal
|
||||||
// lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live).
|
// lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live).
|
||||||
// {
|
// {
|
||||||
|
|||||||
@@ -1164,6 +1164,26 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:settings:dropdown:manage",
|
"edr_freight_app:settings:dropdown:manage",
|
||||||
"Manage dropdown settings",
|
"Manage dropdown settings",
|
||||||
),
|
),
|
||||||
|
perm(
|
||||||
|
"b4b00002-0001-4000-8000-000000000001",
|
||||||
|
"edr_freight_app:settings:stamp:view",
|
||||||
|
"View stamp settings",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"b4b00002-0001-4000-8000-000000000002",
|
||||||
|
"edr_freight_app:settings:stamp:manage",
|
||||||
|
"Manage stamp settings",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"b4b00003-0001-4000-8000-000000000001",
|
||||||
|
"edr_freight_app:settings:invoice_stamp:view",
|
||||||
|
"View invoice stamp settings",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"b4b00003-0001-4000-8000-000000000002",
|
||||||
|
"edr_freight_app:settings:invoice_stamp:manage",
|
||||||
|
"Manage invoice stamp settings",
|
||||||
|
),
|
||||||
perm(
|
perm(
|
||||||
"b4c00001-0001-4000-8000-000000000001",
|
"b4c00001-0001-4000-8000-000000000001",
|
||||||
"edr_freight_app:audit:view",
|
"edr_freight_app:audit:view",
|
||||||
@@ -1849,6 +1869,16 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:settings:dropdown:view",
|
view: "edr_freight_app:settings:dropdown:view",
|
||||||
manage: "edr_freight_app:settings:dropdown:manage",
|
manage: "edr_freight_app:settings:dropdown:manage",
|
||||||
},
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
exchangeRate: {
|
exchangeRate: {
|
||||||
view: "edr_freight_app:settings:exchange_rate:view",
|
view: "edr_freight_app:settings:exchange_rate:view",
|
||||||
manage: "edr_freight_app:settings:exchange_rate:manage",
|
manage: "edr_freight_app:settings:exchange_rate:manage",
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
|
|||||||
import NoAccessPage from "./pages/NoAccessPage";
|
import NoAccessPage from "./pages/NoAccessPage";
|
||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||||
|
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature";
|
||||||
|
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
|
||||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||||
@@ -783,6 +785,24 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="stamp-settings"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
|
||||||
|
<StampSettings />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="invoice-stamp-settings"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
|
||||||
|
>
|
||||||
|
<InvoiceStampSettingsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="audit-logs"
|
path="audit-logs"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -485,6 +485,18 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
|||||||
icon: <Settings />,
|
icon: <Settings />,
|
||||||
permission: FREIGHT_PERMS.settings.dropdown.view,
|
permission: FREIGHT_PERMS.settings.dropdown.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Stamp settings",
|
||||||
|
href: "/dashboard/stamp-settings",
|
||||||
|
icon: <FileSignature />,
|
||||||
|
permission: FREIGHT_PERMS.settings.stamp.view,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Invoice stamp",
|
||||||
|
href: "/dashboard/invoice-stamp-settings",
|
||||||
|
icon: <Receipt />,
|
||||||
|
permission: FREIGHT_PERMS.settings.invoiceStamp.view,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Contract templates",
|
label: "Contract templates",
|
||||||
href: "/dashboard/contract-templates",
|
href: "/dashboard/contract-templates",
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { stampSettingsService } from "@/services/stampSettings.service";
|
||||||
|
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||||
|
|
||||||
|
const QUERY_KEY = ["stampSettings"];
|
||||||
|
|
||||||
|
export const useStampSettingsQuery = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: QUERY_KEY,
|
||||||
|
queryFn: () => stampSettingsService.get(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSetStamp = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler(t);
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (stampImageBase64: string) =>
|
||||||
|
stampSettingsService.set(stampImageBase64),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
toast.success(t("stampSettings.updated", "Company stamp updated"));
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useClearStamp = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler(t);
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => stampSettingsService.clear(),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
toast.success(t("stampSettings.cleared", "Company stamp removed"));
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -315,6 +315,16 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:settings:dropdown:view",
|
view: "edr_freight_app:settings:dropdown:view",
|
||||||
manage: "edr_freight_app:settings:dropdown:manage",
|
manage: "edr_freight_app:settings:dropdown:manage",
|
||||||
},
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
exchangeRate: {
|
exchangeRate: {
|
||||||
view: "edr_freight_app:settings:exchange_rate:view",
|
view: "edr_freight_app:settings:exchange_rate:view",
|
||||||
manage: "edr_freight_app:settings:exchange_rate:manage",
|
manage: "edr_freight_app:settings:exchange_rate:manage",
|
||||||
|
|||||||
@@ -822,6 +822,16 @@ export default function CustomerDetailPage() {
|
|||||||
: undefined
|
: 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="Address" value={company.address} />
|
||||||
<InfoField label="Website" value={company.website} />
|
<InfoField label="Website" value={company.website} />
|
||||||
<InfoField label="Email" value={company.email} />
|
<InfoField label="Email" value={company.email} />
|
||||||
@@ -945,6 +955,11 @@ export default function CustomerDetailPage() {
|
|||||||
>
|
>
|
||||||
Matches the eTrade licence
|
Matches the eTrade licence
|
||||||
</Badge>
|
</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">
|
<Text size="xs" c="dimmed">
|
||||||
No eTrade manager name on file to compare against.
|
No eTrade manager name on file to compare against.
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/common/ui/card";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { Save, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||||
|
import {
|
||||||
|
useClearStamp,
|
||||||
|
useSetStamp,
|
||||||
|
useStampSettingsQuery,
|
||||||
|
} from "@/hooks/useStampSettings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one company stamp/seal stamped onto every generated invoice/receipt
|
||||||
|
* PDF (InvoiceDocumentService). Single global image — no per-employee choice.
|
||||||
|
*/
|
||||||
|
export default function InvoiceStampSettingsPage() {
|
||||||
|
const { data, isLoading } = useStampSettingsQuery();
|
||||||
|
const setStamp = useSetStamp();
|
||||||
|
const clearStamp = useClearStamp();
|
||||||
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(null);
|
||||||
|
}, [data?.stampImageUrl]);
|
||||||
|
|
||||||
|
const value = draft !== null ? draft : (data?.stampImageUrl ?? null);
|
||||||
|
const dirty = draft !== null && draft !== data?.stampImageUrl;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
await setStamp.mutateAsync(draft);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
if (!data?.stampImageUrl) return;
|
||||||
|
await clearStamp.mutateAsync();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<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.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<StampUpload
|
||||||
|
value={isLoading ? null : value}
|
||||||
|
onChange={setDraft}
|
||||||
|
label="Company stamp"
|
||||||
|
description="Shown on every invoice/receipt PDF in place of the plain seal."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || setStamp.isPending}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
{data?.stampImageUrl && !dirty && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={clearStamp.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -609,10 +609,18 @@ const UploadTeeterAndSignature = () => {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Teeter Tab */}
|
{/* Teeter Tab — single active stamp only: remove the current one to upload a replacement. */}
|
||||||
<TabsContent value="teeter" className="p-4 space-y-6">
|
<TabsContent value="teeter" className="p-4 space-y-6">
|
||||||
{teeters.length > 0 && (
|
{teeters.length > 0 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{teeters.length > 1 && (
|
||||||
|
<p className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-300">
|
||||||
|
{t(
|
||||||
|
"signatureUpload.multipleStampsWarning",
|
||||||
|
"Only one stamp is allowed. Remove the extras below to keep a single active stamp.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{teeters.map(({ id, url }) => (
|
{teeters.map(({ id, url }) => (
|
||||||
<div key={id} className="space-y-3">
|
<div key={id} className="space-y-3">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
@@ -635,6 +643,7 @@ const UploadTeeterAndSignature = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{teeters.length === 0 && (
|
||||||
<div className="border-2 border-dashed border-primary-300 dark:border-primary-600 rounded-lg p-6 text-center space-y-4">
|
<div className="border-2 border-dashed border-primary-300 dark:border-primary-600 rounded-lg p-6 text-center space-y-4">
|
||||||
{!stampBlocks && !showLanguagePicker && (
|
{!stampBlocks && !showLanguagePicker && (
|
||||||
<Button
|
<Button
|
||||||
@@ -792,6 +801,7 @@ const UploadTeeterAndSignature = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { api as client } from "../auth/http";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
|
||||||
|
const BASE = "/stamp-settings";
|
||||||
|
|
||||||
|
/** Company stamp/seal used on generated invoice/receipt PDFs. */
|
||||||
|
export interface StampSettings {
|
||||||
|
stampImageUrl: string | null;
|
||||||
|
updatedById: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stampSettingsService = {
|
||||||
|
get: async (): Promise<StampSettings> => {
|
||||||
|
const response = await client.get<ApiResponse<StampSettings>>(BASE);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
set: async (stampImageBase64: string): Promise<StampSettings> => {
|
||||||
|
const response = await client.put<ApiResponse<StampSettings>>(BASE, {
|
||||||
|
stampImageBase64,
|
||||||
|
});
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: async (): Promise<StampSettings> => {
|
||||||
|
const response = await client.delete<ApiResponse<StampSettings>>(BASE);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -222,6 +222,12 @@ export interface Company {
|
|||||||
fanNumber?: string | null;
|
fanNumber?: string | null;
|
||||||
country: string;
|
country: string;
|
||||||
nationality?: CompanyNationality | null;
|
nationality?: CompanyNationality | null;
|
||||||
|
/**
|
||||||
|
* A co-operative union or farm: a TIN but no trade licence, so its
|
||||||
|
* registration was typed rather than fetched from eTrade, there is no eTrade
|
||||||
|
* manager to check the owner against, and it holds no freight-forwarder role.
|
||||||
|
*/
|
||||||
|
cooperative?: boolean;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -164,6 +165,15 @@ export default function OnboardingWizardDialog({
|
|||||||
const [roles, setRoles] = useState<string[]>(
|
const [roles, setRoles] = useState<string[]>(
|
||||||
existingProfiles.map((p) => p.type),
|
existingProfiles.map((p) => p.type),
|
||||||
);
|
);
|
||||||
|
const [cooperative, setCooperative] = useState<boolean>(
|
||||||
|
company?.company?.attributes?.cooperative === true,
|
||||||
|
);
|
||||||
|
// Ticking the box drops a role the company can no longer hold, rather than
|
||||||
|
// letting Continue fail on a selection the API refuses.
|
||||||
|
const handleCooperativeChange = useCallback((checked: boolean) => {
|
||||||
|
setCooperative(checked);
|
||||||
|
if (checked) setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
||||||
|
}, []);
|
||||||
const [documentFiles, setDocumentFiles] = useState<
|
const [documentFiles, setDocumentFiles] = useState<
|
||||||
Record<string, File | File[] | null>
|
Record<string, File | File[] | null>
|
||||||
>({});
|
>({});
|
||||||
@@ -214,6 +224,7 @@ export default function OnboardingWizardDialog({
|
|||||||
companyType: string;
|
companyType: string;
|
||||||
roles: ProfileTypeValue[];
|
roles: ProfileTypeValue[];
|
||||||
nationality?: CompanyNationality;
|
nationality?: CompanyNationality;
|
||||||
|
cooperative?: boolean;
|
||||||
}) => api.companies.startOnboarding.call(vars),
|
}) => api.companies.startOnboarding.call(vars),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
// Nationality drives the server-resolved identity requirements (Fayda vs
|
// Nationality drives the server-resolved identity requirements (Fayda vs
|
||||||
@@ -296,6 +307,7 @@ export default function OnboardingWizardDialog({
|
|||||||
resumedRef.current = true;
|
resumedRef.current = true;
|
||||||
setRoles(existingProfiles.map((p) => p.type));
|
setRoles(existingProfiles.map((p) => p.type));
|
||||||
setNationality(savedNationality);
|
setNationality(savedNationality);
|
||||||
|
setCooperative(company?.company?.attributes?.cooperative === true);
|
||||||
// Resume into the form only when profiles exist; otherwise send the user to
|
// Resume into the form only when profiles exist; otherwise send the user to
|
||||||
// role selection so the missing operational profiles get created.
|
// role selection so the missing operational profiles get created.
|
||||||
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
|
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
|
||||||
@@ -310,8 +322,9 @@ export default function OnboardingWizardDialog({
|
|||||||
companyType: companyTypeForRoles(roles),
|
companyType: companyTypeForRoles(roles),
|
||||||
roles: roles as ProfileTypeValue[],
|
roles: roles as ProfileTypeValue[],
|
||||||
nationality: nationality ?? undefined,
|
nationality: nationality ?? undefined,
|
||||||
|
cooperative,
|
||||||
});
|
});
|
||||||
}, [roles, nationality, startMutation]);
|
}, [roles, nationality, cooperative, startMutation]);
|
||||||
|
|
||||||
// Back from the form's first step returns to nationality/role selection.
|
// Back from the form's first step returns to nationality/role selection.
|
||||||
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
|
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
|
||||||
@@ -463,6 +476,15 @@ export default function OnboardingWizardDialog({
|
|||||||
// mandatory for an Ethiopian company; a foreign one may instead type a
|
// mandatory for an Ethiopian company; a foreign one may instead type a
|
||||||
// passport number for the same person.
|
// passport number for the same person.
|
||||||
identity: requirementsQuery.data?.identity,
|
identity: requirementsQuery.data?.identity,
|
||||||
|
// Server-confirmed, not the local checkbox: the flag is only real once
|
||||||
|
// startOnboarding has persisted it, and the form's whole company step
|
||||||
|
// branches on it.
|
||||||
|
cooperative: requirementsQuery.data?.cooperative ?? cooperative,
|
||||||
|
extraDocumentSettingCode:
|
||||||
|
requirementsQuery.data?.cooperativeDocumentSettingCode ?? null,
|
||||||
|
// A freight forwarder cannot answer the power-of-attorney question — the
|
||||||
|
// API forces "yes" — so the step offers no way to change it.
|
||||||
|
declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
|
||||||
onIdentityChange: () => {
|
onIdentityChange: () => {
|
||||||
void profileQuery.refetch();
|
void profileQuery.refetch();
|
||||||
void requirementsQuery.refetch();
|
void requirementsQuery.refetch();
|
||||||
@@ -530,6 +552,16 @@ export default function OnboardingWizardDialog({
|
|||||||
onChange={setNationality}
|
onChange={setNationality}
|
||||||
embedded
|
embedded
|
||||||
/>
|
/>
|
||||||
|
{/* A co-operative union or farm registers on a TIN alone. It
|
||||||
|
changes what the next step asks for (typed registration, no
|
||||||
|
eTrade lookup), which documents apply, and which roles are on
|
||||||
|
offer — so it is answered here, alongside the other two. */}
|
||||||
|
<Checkbox
|
||||||
|
checked={cooperative}
|
||||||
|
onChange={(e) => handleCooperativeChange(e.currentTarget.checked)}
|
||||||
|
label="We're a co-operative union or farm"
|
||||||
|
description="For members with a TIN but no business licence. You'll type your registration details instead of us pulling them from eTrade, and upload your co-operative papers in place of a trade licence."
|
||||||
|
/>
|
||||||
<Text fw={600} size="lg" c="edr-text">
|
<Text fw={600} size="lg" c="edr-text">
|
||||||
What does your company do?(multiple)
|
What does your company do?(multiple)
|
||||||
</Text>
|
</Text>
|
||||||
@@ -537,6 +569,9 @@ export default function OnboardingWizardDialog({
|
|||||||
value={roles}
|
value={roles}
|
||||||
onChange={setRoles}
|
onChange={setRoles}
|
||||||
embedded
|
embedded
|
||||||
|
// Forwarding is licensed work — a co-op holds no licence, so
|
||||||
|
// the role is not offered rather than refused later.
|
||||||
|
excludeTypes={cooperative ? ["freight_forwarder"] : undefined}
|
||||||
/>
|
/>
|
||||||
{startError && (
|
{startError && (
|
||||||
<Text size="sm" c="red">
|
<Text size="sm" c="red">
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ import type {
|
|||||||
import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep";
|
import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep";
|
||||||
import OwnerStep from "./companyProfileForm/steps/OwnerStep";
|
import OwnerStep from "./companyProfileForm/steps/OwnerStep";
|
||||||
import ContactStep from "./companyProfileForm/steps/ContactStep";
|
import ContactStep from "./companyProfileForm/steps/ContactStep";
|
||||||
import RepresentationStep from "./companyProfileForm/steps/RepresentationStep";
|
import RepresentationStep, {
|
||||||
|
type IdentityMethod,
|
||||||
|
} from "./companyProfileForm/steps/RepresentationStep";
|
||||||
import DocumentsStep from "./companyProfileForm/steps/DocumentsStep";
|
import DocumentsStep from "./companyProfileForm/steps/DocumentsStep";
|
||||||
|
|
||||||
export default function CompanyProfileForm({
|
export default function CompanyProfileForm({
|
||||||
@@ -60,6 +62,9 @@ export default function CompanyProfileForm({
|
|||||||
onUploadDocuments,
|
onUploadDocuments,
|
||||||
identity: rawIdentity,
|
identity: rawIdentity,
|
||||||
onIdentityChange,
|
onIdentityChange,
|
||||||
|
cooperative = false,
|
||||||
|
declarationLocked = false,
|
||||||
|
extraDocumentSettingCode,
|
||||||
}: {
|
}: {
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
documentFiles?: Record<string, File | File[] | null>;
|
documentFiles?: Record<string, File | File[] | null>;
|
||||||
@@ -105,6 +110,20 @@ export default function CompanyProfileForm({
|
|||||||
* a freshly booted app, so it has nothing to notify.
|
* a freshly booted app, so it has nothing to notify.
|
||||||
*/
|
*/
|
||||||
onIdentityChange?: () => void;
|
onIdentityChange?: () => void;
|
||||||
|
/**
|
||||||
|
* The company trades as a co-operative: a TIN but no business licence, so the
|
||||||
|
* eTrade lookup is replaced by typed registration details, the per-role
|
||||||
|
* licence upload is not owed, and its own document set applies on top of the
|
||||||
|
* nationality one.
|
||||||
|
*/
|
||||||
|
cooperative?: boolean;
|
||||||
|
/**
|
||||||
|
* The company operates as a freight forwarder, so the power-of-attorney
|
||||||
|
* answer is forced to "yes" and cannot be changed here.
|
||||||
|
*/
|
||||||
|
declarationLocked?: boolean;
|
||||||
|
/** Additional document set merged in (the co-operative one), if any. */
|
||||||
|
extraDocumentSettingCode?: string | null;
|
||||||
}) {
|
}) {
|
||||||
// A Fayda claim carries the phone as the national registry holds it, which is
|
// A Fayda claim carries the phone as the national registry holds it, which is
|
||||||
// often a local number the form's E.164 validation (and the API's
|
// often a local number the form's E.164 validation (and the API's
|
||||||
@@ -168,12 +187,36 @@ export default function CompanyProfileForm({
|
|||||||
const documentFiles = controlledFiles ?? internalFiles;
|
const documentFiles = controlledFiles ?? internalFiles;
|
||||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||||
|
|
||||||
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
const { data: nationalitySetting, isLoading: loadingDocuments } = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: documentSettingCode },
|
input: { code: documentSettingCode },
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// A co-operative's own documents come as a second, additive set — it uploads
|
||||||
|
// everything its nationality demands, plus the papers standing in for the
|
||||||
|
// business licence it does not hold. The API merges the same two sets when it
|
||||||
|
// decides what is outstanding.
|
||||||
|
const { data: extraSetting } = useQuery(
|
||||||
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
|
input: { code: extraDocumentSettingCode ?? "" },
|
||||||
|
enabled: Boolean(extraDocumentSettingCode),
|
||||||
|
refetchOnMount: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const uploadSetting = useMemo(() => {
|
||||||
|
if (!nationalitySetting) return nationalitySetting;
|
||||||
|
if (!extraSetting?.fields?.length) return nationalitySetting;
|
||||||
|
// Nationality wins a fileKey collision, so a slot is never rendered twice.
|
||||||
|
const seen = new Set(nationalitySetting.fields.map((f) => f.fileKey));
|
||||||
|
return {
|
||||||
|
...nationalitySetting,
|
||||||
|
fields: [
|
||||||
|
...nationalitySetting.fields,
|
||||||
|
...extraSetting.fields.filter((f) => !seen.has(f.fileKey)),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [nationalitySetting, extraSetting]);
|
||||||
|
|
||||||
// Which fields the current step renders an input for and therefore requires.
|
// Which fields the current step renders an input for and therefore requires.
|
||||||
// Filled in further down (it depends on values this form owns), and read at
|
// Filled in further down (it depends on values this form owns), and read at
|
||||||
@@ -388,30 +431,58 @@ export default function CompanyProfileForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What no source supplied, per person.
|
* Which fields a verification owns, per person.
|
||||||
*
|
*
|
||||||
* A Fayda verification owns the fields its claims filled — the API refuses to
|
* A Fayda verification owns the fields its claims filled — the API refuses to
|
||||||
* let those be overwritten — but its email, phone and address claims are
|
* let those be overwritten — but its email, phone and address claims are
|
||||||
* optional and routinely come back empty. eTrade fills the owner's name and
|
* optional and routinely come back empty. Everything it did NOT fill stays
|
||||||
* phone, and nothing at all fills an email.
|
* the customer's: an editable input, prefilled from eTrade or from what was
|
||||||
|
* saved earlier, and required precisely because there is an input for it.
|
||||||
*
|
*
|
||||||
* So "what still has to be asked" varies per company. Computed here, once,
|
* Keyed off `verified`, deliberately, not off "does a value exist". A value
|
||||||
* and handed to both the step (which renders an input per gap) and the schema
|
* exists the moment eTrade prefills the owner or the customer types one and
|
||||||
* (which requires exactly those): **a field is required if and only if there
|
* the step saves — so a presence test turned the input they had just filled
|
||||||
* is an input on screen to fix it in.**
|
* into a read-only badge on the way back through the wizard, and dropped the
|
||||||
|
* field out of `requiredKeys` at the same time. Only a verification locks.
|
||||||
*/
|
*/
|
||||||
const ownerGaps = {
|
const ownerVerified = identity?.owner.verified ?? false;
|
||||||
name: !identity?.owner.name?.trim(),
|
const poaVerified = identity?.poa.verified ?? false;
|
||||||
email: !identity?.owner.email?.trim(),
|
const ownerLocked = {
|
||||||
phone: !identity?.owner.phone?.trim(),
|
name: ownerVerified && Boolean(identity?.owner.name?.trim()),
|
||||||
|
email: ownerVerified && Boolean(identity?.owner.email?.trim()),
|
||||||
|
phone: ownerVerified && Boolean(identity?.owner.phone?.trim()),
|
||||||
};
|
};
|
||||||
const poaGaps = {
|
const poaLocked = {
|
||||||
name: !identity?.poa.name?.trim(),
|
name: poaVerified && Boolean(identity?.poa.name?.trim()),
|
||||||
email: !identity?.poa.email?.trim(),
|
email: poaVerified && Boolean(identity?.poa.email?.trim()),
|
||||||
phone: !identity?.poa.phone?.trim(),
|
phone: poaVerified && Boolean(identity?.poa.phone?.trim()),
|
||||||
address: !identity?.poa.address?.trim(),
|
address: poaVerified && Boolean(identity?.poa.address?.trim()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a foreign company chose to prove its subject: Fayda, or a passport.
|
||||||
|
*
|
||||||
|
* An either/or rather than a fallback, so nothing is asked until one side is
|
||||||
|
* picked. Seeded from what already happened — a completed verification or a
|
||||||
|
* saved passport number is itself the answer — and only then held locally,
|
||||||
|
* because the choice is a UI fork with nothing to persist: what the API
|
||||||
|
* stores is the proof, not the route taken to it.
|
||||||
|
*/
|
||||||
|
const [identityMethod, setIdentityMethod] = useState<IdentityMethod | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const passportSaved = Boolean(
|
||||||
|
identity?.subject === "poa"
|
||||||
|
? identity?.poa.passportNumber?.trim()
|
||||||
|
: identity?.owner.passportNumber?.trim(),
|
||||||
|
);
|
||||||
|
const subjectVerified = identity?.subject === "poa" ? poaVerified : ownerVerified;
|
||||||
|
const effectiveMethod: IdentityMethod | null = !identity?.passportAccepted
|
||||||
|
? "fayda" // An Ethiopian company has no choice to make.
|
||||||
|
: subjectVerified
|
||||||
|
? "fayda"
|
||||||
|
: (identityMethod ?? (passportSaved ? "passport" : null));
|
||||||
|
|
||||||
// The owner's name from whichever source established them — powers the
|
// The owner's name from whichever source established them — powers the
|
||||||
// contact step's "same as owner" card.
|
// contact step's "same as owner" card.
|
||||||
const ownerName = firstPresent(identity?.owner.name, watch("ownerName"));
|
const ownerName = firstPresent(identity?.owner.name, watch("ownerName"));
|
||||||
@@ -494,9 +565,12 @@ export default function CompanyProfileForm({
|
|||||||
return errs;
|
return errs;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Every role needs at least one license file (existing or newly selected).
|
// Every role needs at least one license file (existing or newly selected) —
|
||||||
|
// except a co-operative's, which holds no business licence at all. Its own
|
||||||
|
// document set is what stands in, and the API lifts the same requirement.
|
||||||
const validateLicenses = (): Record<string, string> => {
|
const validateLicenses = (): Record<string, string> => {
|
||||||
const errs: Record<string, string> = {};
|
const errs: Record<string, string> = {};
|
||||||
|
if (cooperative) return errs;
|
||||||
for (const p of roleProfiles ?? []) {
|
for (const p of roleProfiles ?? []) {
|
||||||
const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0;
|
const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0;
|
||||||
const hasExisting = p.existingFiles.length > 0;
|
const hasExisting = p.existingFiles.length > 0;
|
||||||
@@ -549,7 +623,10 @@ export default function CompanyProfileForm({
|
|||||||
const hasRegistrationDetails = registration.some((v) => v && v.trim());
|
const hasRegistrationDetails = registration.some((v) => v && v.trim());
|
||||||
// A previously-saved (rehydrated) TIN counts as verified without a refetch —
|
// A previously-saved (rehydrated) TIN counts as verified without a refetch —
|
||||||
// the registration fields being populated at all is proof it passed before.
|
// the registration fields being populated at all is proof it passed before.
|
||||||
const tinVerified = tinStatus === "verified" || hasRegistrationDetails;
|
// A co-operative never runs the lookup, so there is nothing to be verified
|
||||||
|
// against; its TIN is validated by the schema like any other typed field.
|
||||||
|
const tinVerified =
|
||||||
|
cooperative || tinStatus === "verified" || hasRegistrationDetails;
|
||||||
|
|
||||||
// Single source of truth for step sequence — navigation, labels and the
|
// Single source of truth for step sequence — navigation, labels and the
|
||||||
// progress bar all derive from this so adding/removing a step is one edit.
|
// progress bar all derive from this so adding/removing a step is one edit.
|
||||||
@@ -589,18 +666,29 @@ export default function CompanyProfileForm({
|
|||||||
Boolean(watch(passportField)?.trim()));
|
Boolean(watch(passportField)?.trim()));
|
||||||
|
|
||||||
const requiredKeys: (keyof FormData)[] = [];
|
const requiredKeys: (keyof FormData)[] = [];
|
||||||
if (step === "owner") {
|
if (step === "company" && cooperative) {
|
||||||
|
// A co-operative has no eTrade record, so the fields every other company
|
||||||
|
// gets read-only from the licence are typed here — and are therefore
|
||||||
|
// required here. House number stays optional: plenty of addresses have none.
|
||||||
|
requiredKeys.push("companyName", "region", "zone", "woreda", "kebele");
|
||||||
|
} else if (step === "owner") {
|
||||||
// All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input
|
// All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input
|
||||||
// is rendered for each one a Fayda claim did not already own.
|
// is rendered for each one a Fayda verification does not own.
|
||||||
if (ownerGaps.name) requiredKeys.push("ownerName");
|
if (!ownerLocked.name) requiredKeys.push("ownerName");
|
||||||
if (ownerGaps.email) requiredKeys.push("ownerEmail");
|
if (!ownerLocked.email) requiredKeys.push("ownerEmail");
|
||||||
if (ownerGaps.phone) requiredKeys.push("ownerPhone");
|
if (!ownerLocked.phone) requiredKeys.push("ownerPhone");
|
||||||
} else if (step === "representation" && identity?.poaDeclared === "yes") {
|
} else if (
|
||||||
// Only once a representative is actually declared: a company that answered
|
step === "representation" &&
|
||||||
// "no" has no representative to describe.
|
identity?.poaDeclared === "yes" &&
|
||||||
if (poaGaps.name) requiredKeys.push("poaName");
|
// The details are only on screen once the person is established — before
|
||||||
if (poaGaps.email) requiredKeys.push("poaEmail");
|
// that the step is still asking how to prove them, and requiring a name
|
||||||
if (poaGaps.phone) requiredKeys.push("poaPhone");
|
// with no input rendered is the dead Continue button this rule exists to
|
||||||
|
// prevent.
|
||||||
|
(poaVerified || effectiveMethod === "passport")
|
||||||
|
) {
|
||||||
|
if (!poaLocked.name) requiredKeys.push("poaName");
|
||||||
|
if (!poaLocked.email) requiredKeys.push("poaEmail");
|
||||||
|
if (!poaLocked.phone) requiredKeys.push("poaPhone");
|
||||||
}
|
}
|
||||||
requiredKeysRef.current = requiredKeys;
|
requiredKeysRef.current = requiredKeys;
|
||||||
|
|
||||||
@@ -695,6 +783,8 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
// The TIN must resolve to a real eTrade record before anything else on
|
// The TIN must resolve to a real eTrade record before anything else on
|
||||||
// this step is even worth validating — gates here rather than through zod.
|
// this step is even worth validating — gates here rather than through zod.
|
||||||
|
// A co-operative is exempt: it has no licence for eTrade to hold, so
|
||||||
|
// `tinVerified` is true for it and only the duplicate-TIN check applies.
|
||||||
if (step === "company" && tinStatus === "taken") {
|
if (step === "company" && tinStatus === "taken") {
|
||||||
setSaveError(
|
setSaveError(
|
||||||
"This TIN is already registered to another company account.",
|
"This TIN is already registered to another company account.",
|
||||||
@@ -793,6 +883,7 @@ export default function CompanyProfileForm({
|
|||||||
tinStatus={tinStatus}
|
tinStatus={tinStatus}
|
||||||
tinVerified={tinVerified}
|
tinVerified={tinVerified}
|
||||||
hasRegistrationDetails={hasRegistrationDetails}
|
hasRegistrationDetails={hasRegistrationDetails}
|
||||||
|
cooperative={cooperative}
|
||||||
onETradeDataLoaded={handleETradeDataLoaded}
|
onETradeDataLoaded={handleETradeDataLoaded}
|
||||||
onETradeStatusChange={setTinStatus}
|
onETradeStatusChange={setTinStatus}
|
||||||
onETradeReset={handleETradeReset}
|
onETradeReset={handleETradeReset}
|
||||||
@@ -804,7 +895,8 @@ export default function CompanyProfileForm({
|
|||||||
form={form}
|
form={form}
|
||||||
identity={identity}
|
identity={identity}
|
||||||
etradeOwner={etradeOwner}
|
etradeOwner={etradeOwner}
|
||||||
gaps={ownerGaps}
|
locked={ownerLocked}
|
||||||
|
cooperative={cooperative}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -814,7 +906,10 @@ export default function CompanyProfileForm({
|
|||||||
identity={identity}
|
identity={identity}
|
||||||
onDeclare={handleDeclare}
|
onDeclare={handleDeclare}
|
||||||
declarePending={declarePending}
|
declarePending={declarePending}
|
||||||
gaps={poaGaps}
|
declarationLocked={declarationLocked}
|
||||||
|
locked={poaLocked}
|
||||||
|
method={effectiveMethod}
|
||||||
|
onMethodChange={setIdentityMethod}
|
||||||
poaDocumentSetting={poaDocumentSetting}
|
poaDocumentSetting={poaDocumentSetting}
|
||||||
documentFiles={documentFiles}
|
documentFiles={documentFiles}
|
||||||
uploadedDocumentKeys={uploadedDocumentKeys}
|
uploadedDocumentKeys={uploadedDocumentKeys}
|
||||||
@@ -840,7 +935,10 @@ export default function CompanyProfileForm({
|
|||||||
uploadedDocumentKeys={uploadedDocumentKeys}
|
uploadedDocumentKeys={uploadedDocumentKeys}
|
||||||
documentFieldErrors={documentFieldErrors}
|
documentFieldErrors={documentFieldErrors}
|
||||||
onDocumentFilesChange={handleDocumentFilesChange}
|
onDocumentFilesChange={handleDocumentFilesChange}
|
||||||
roleProfiles={roleProfiles}
|
// A co-operative union or farm holds no business licence, so the
|
||||||
|
// per-role upload cards are not shown at all — offering a slot
|
||||||
|
// nothing can fill reads as an unfinishable step.
|
||||||
|
roleProfiles={cooperative ? [] : roleProfiles}
|
||||||
licenseFiles={licenseFiles}
|
licenseFiles={licenseFiles}
|
||||||
licenseFieldErrors={licenseFieldErrors}
|
licenseFieldErrors={licenseFieldErrors}
|
||||||
onLicenseFilesChange={handleLicenseFilesChange}
|
onLicenseFilesChange={handleLicenseFilesChange}
|
||||||
|
|||||||
@@ -65,18 +65,16 @@ describe("VAT number", () => {
|
|||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects twelve digits", () => {
|
// No shape rule any more: a foreign tax authority's VAT number carries
|
||||||
expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe(
|
// letters and dashes, and a co-operative union's registration numbering
|
||||||
"VAT number must be 10 or 11 digits",
|
// follows the trade-licence pattern not at all. Length and alphabet are not
|
||||||
);
|
// ours to police — only presence is.
|
||||||
});
|
it.each(["001234567890", "GB123456789", "ET-2024/0091"])(
|
||||||
|
"accepts %s",
|
||||||
// `.length(10)` used to pass this, so a ten-letter string reached the API.
|
(vat) => {
|
||||||
it("rejects ten non-digits", () => {
|
expect(errorFor(values({ vatNumber: vat }), "vatNumber")).toBeUndefined();
|
||||||
expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe(
|
},
|
||||||
"VAT number must be 10 or 11 digits",
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects blank", () => {
|
it("rejects blank", () => {
|
||||||
expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe(
|
expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe(
|
||||||
@@ -104,24 +102,44 @@ describe("stepFields", () => {
|
|||||||
// The regression this whole change exists to prevent: a step must not gate on
|
// The regression this whole change exists to prevent: a step must not gate on
|
||||||
// a field it renders no input for, or Continue fails with the error attached
|
// a field it renders no input for, or Continue fails with the error attached
|
||||||
// to nothing on screen.
|
// to nothing on screen.
|
||||||
it("never gates the company step on a derived or read-only field", () => {
|
//
|
||||||
const unreachable = [
|
// Listing a field on a step is no longer the gate — `requiredKeys` is. The
|
||||||
"etradePhone",
|
// registration fields appear on the company step because a co-operative union
|
||||||
|
// or farm types them, and a licensed company gets them read-only from eTrade;
|
||||||
|
// the base schema must accept them blank either way.
|
||||||
|
it("never gates the company step on a field with no input", () => {
|
||||||
|
const derived = ["etradePhone", "licenceNumber", "statusDescription"];
|
||||||
|
expect(stepFields.company.filter((f) => derived.includes(f))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the registration fields optional in the base schema", () => {
|
||||||
|
for (const field of ["companyName", "region", "zone", "woreda", "kebele"] as const) {
|
||||||
|
expect(errorFor(values({ [field]: "" }), field)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the registration fields once a co-operative types them", () => {
|
||||||
|
const parsed = buildOnboardingSchema([
|
||||||
"companyName",
|
"companyName",
|
||||||
"licenceNumber",
|
|
||||||
"statusDescription",
|
|
||||||
"dateRegistered",
|
|
||||||
"renewedFrom",
|
|
||||||
"renewalDate",
|
|
||||||
"renewedTo",
|
|
||||||
"region",
|
"region",
|
||||||
"zone",
|
"zone",
|
||||||
"woreda",
|
"woreda",
|
||||||
"kebele",
|
"kebele",
|
||||||
"houseNo",
|
]).safeParse(
|
||||||
];
|
values({ companyName: "", region: "", zone: "", woreda: "", kebele: "" }),
|
||||||
expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual(
|
);
|
||||||
[],
|
expect(parsed.success).toBe(false);
|
||||||
|
const paths = parsed.success
|
||||||
|
? []
|
||||||
|
: parsed.error.issues.map((i) => String(i.path[0]));
|
||||||
|
expect(paths).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"companyName",
|
||||||
|
"region",
|
||||||
|
"zone",
|
||||||
|
"woreda",
|
||||||
|
"kebele",
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ export const onboardingSchema = z.object({
|
|||||||
// can diverge without the backend's eTrade-authenticity check misfiring.
|
// can diverge without the backend's eTrade-authenticity check misfiring.
|
||||||
etradePhone: z.string().optional(),
|
etradePhone: z.string().optional(),
|
||||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||||
// `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits.
|
// Required, but no shape check. Ethiopian VAT numbers are usually 10 or 11
|
||||||
vatNumber: z
|
// digits; a foreign company's is whatever its own tax authority issues, and a
|
||||||
.string()
|
// co-operative's registration numbering follows neither. A format rule here
|
||||||
.min(1, "VAT number is required")
|
// only ever rejected valid numbers we had no business judging.
|
||||||
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
|
vatNumber: z.string().min(1, "VAT number is required"),
|
||||||
// Passport numbers — the alternative identity credential for a foreign
|
// Passport numbers — the alternative identity credential for a foreign
|
||||||
// company (Fayda is an Ethiopian national ID). Only the one belonging to the
|
// company (Fayda is an Ethiopian national ID). Only the one belonging to the
|
||||||
// declared identity subject is ever asked for, and only when that person has
|
// declared identity subject is ever asked for, and only when that person has
|
||||||
@@ -40,10 +40,15 @@ export const onboardingSchema = z.object({
|
|||||||
renewedFrom: z.string().optional(),
|
renewedFrom: z.string().optional(),
|
||||||
renewalDate: z.string().optional(),
|
renewalDate: z.string().optional(),
|
||||||
renewedTo: z.string().optional(),
|
renewedTo: z.string().optional(),
|
||||||
// The registered address comes from eTrade and nowhere else — the form
|
// The registered address normally comes from eTrade and nowhere else — the
|
||||||
// renders these read-only, so requiring them would be a Continue button that
|
// form renders these read-only, so requiring them would be a Continue button
|
||||||
// fails on a field with no input to fix it. A gap in eTrade's own data stays
|
// that fails on a field with no input to fix it. A gap in eTrade's own data
|
||||||
// a gap rather than becoming a customer-typed claim wearing eTrade's badge.
|
// stays a gap rather than becoming a customer-typed claim wearing eTrade's
|
||||||
|
// badge.
|
||||||
|
//
|
||||||
|
// A co-operative is the exception: it has no business licence, so there is no
|
||||||
|
// eTrade record at all and these ARE typed. Requiredness follows the same
|
||||||
|
// invariant as everywhere else — it is decided per render, in `requiredKeys`.
|
||||||
region: z.string().optional(),
|
region: z.string().optional(),
|
||||||
zone: z.string().optional(),
|
zone: z.string().optional(),
|
||||||
woreda: z.string().optional(),
|
woreda: z.string().optional(),
|
||||||
@@ -107,6 +112,12 @@ export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
|||||||
* message has to be built here rather than attached to the base schema.
|
* message has to be built here rather than attached to the base schema.
|
||||||
*/
|
*/
|
||||||
const CONDITIONAL_LABELS: Partial<Record<keyof FormData, string>> = {
|
const CONDITIONAL_LABELS: Partial<Record<keyof FormData, string>> = {
|
||||||
|
// Typed only by a co-operative — every other company gets these from eTrade.
|
||||||
|
companyName: "Company name",
|
||||||
|
region: "Region",
|
||||||
|
zone: "Zone",
|
||||||
|
woreda: "Woreda",
|
||||||
|
kebele: "Kebele",
|
||||||
poaName: "Representative's name",
|
poaName: "Representative's name",
|
||||||
poaEmail: "Representative's email",
|
poaEmail: "Representative's email",
|
||||||
poaPhone: "Representative's phone",
|
poaPhone: "Representative's phone",
|
||||||
@@ -184,8 +195,18 @@ export const ETRADE_BUNDLE_FIELDS = [
|
|||||||
*/
|
*/
|
||||||
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||||
// Only what this step actually renders an input for. The company name and the
|
// Only what this step actually renders an input for. The company name and the
|
||||||
// registered address are eTrade's, shown read-only.
|
// registered address are eTrade's, shown read-only — except for a
|
||||||
company: ["tinNumber", "vatNumber"],
|
// co-operative, which types them (added per render via `requiredKeys`).
|
||||||
|
company: [
|
||||||
|
"tinNumber",
|
||||||
|
"vatNumber",
|
||||||
|
"companyName",
|
||||||
|
"region",
|
||||||
|
"zone",
|
||||||
|
"woreda",
|
||||||
|
"kebele",
|
||||||
|
"houseNo",
|
||||||
|
],
|
||||||
// `REQUIRED_COMPANY_INFO` demands all three server-side, so the step offers
|
// `REQUIRED_COMPANY_INFO` demands all three server-side, so the step offers
|
||||||
// an input wherever eTrade and Fayda between them left a gap.
|
// an input wherever eTrade and Fayda between them left a gap.
|
||||||
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerPassportNumber"],
|
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerPassportNumber"],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Stack, TextInput } from "@mantine/core";
|
import { Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import type { UseFormReturn } from "react-hook-form";
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
import type { CompanyRegistrationData } from "@edr/types";
|
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
|
||||||
import ETradeInfo, {
|
import ETradeInfo, {
|
||||||
type ETradeStatus,
|
type ETradeStatus,
|
||||||
} from "@/components/onboarding/ETradeInfo";
|
} from "@/components/onboarding/ETradeInfo";
|
||||||
@@ -16,6 +16,12 @@ export interface CompanyInfoStepProps {
|
|||||||
tinVerified: boolean;
|
tinVerified: boolean;
|
||||||
/** Registration fields are already populated (a lookup passed, now or earlier). */
|
/** Registration fields are already populated (a lookup passed, now or earlier). */
|
||||||
hasRegistrationDetails: boolean;
|
hasRegistrationDetails: boolean;
|
||||||
|
/**
|
||||||
|
* The company is a co-operative union or farm: it has a TIN but no business
|
||||||
|
* licence, so eTrade holds no record to look up and the registration is typed
|
||||||
|
* here instead.
|
||||||
|
*/
|
||||||
|
cooperative?: boolean;
|
||||||
onETradeDataLoaded: (data: CompanyRegistrationData) => void;
|
onETradeDataLoaded: (data: CompanyRegistrationData) => void;
|
||||||
onETradeStatusChange: (status: ETradeStatus) => void;
|
onETradeStatusChange: (status: ETradeStatus) => void;
|
||||||
onETradeReset: () => void;
|
onETradeReset: () => void;
|
||||||
@@ -26,6 +32,7 @@ export default function CompanyInfoStep({
|
|||||||
tinStatus,
|
tinStatus,
|
||||||
tinVerified,
|
tinVerified,
|
||||||
hasRegistrationDetails,
|
hasRegistrationDetails,
|
||||||
|
cooperative = false,
|
||||||
onETradeDataLoaded,
|
onETradeDataLoaded,
|
||||||
onETradeStatusChange,
|
onETradeStatusChange,
|
||||||
onETradeReset,
|
onETradeReset,
|
||||||
@@ -33,6 +40,7 @@ export default function CompanyInfoStep({
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
watch,
|
watch,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = form;
|
} = form;
|
||||||
|
|
||||||
@@ -41,43 +49,112 @@ export default function CompanyInfoStep({
|
|||||||
<StepSection
|
<StepSection
|
||||||
index={1}
|
index={1}
|
||||||
title="VAT number"
|
title="VAT number"
|
||||||
status={
|
status={watch("vatNumber")?.trim() && !errors.vatNumber ? "done" : "todo"}
|
||||||
(watch("vatNumber")?.length ?? 0) >= 10 && !errors.vatNumber
|
|
||||||
? "done"
|
|
||||||
: "todo"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
aria-label="VAT Number"
|
aria-label="VAT Number"
|
||||||
placeholder="0012345678"
|
placeholder="0012345678"
|
||||||
maxLength={11}
|
|
||||||
error={errors.vatNumber?.message}
|
error={errors.vatNumber?.message}
|
||||||
{...register("vatNumber")}
|
{...register("vatNumber")}
|
||||||
/>
|
/>
|
||||||
</StepSection>
|
</StepSection>
|
||||||
|
|
||||||
<StepSection
|
{cooperative ? (
|
||||||
index={2}
|
<>
|
||||||
title="Company TIN"
|
<StepSection
|
||||||
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
index={2}
|
||||||
status={
|
title="Company TIN"
|
||||||
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
|
subtitle="A co-operative union or farm has no trade licence for us to look up, so we take the TIN as you give it."
|
||||||
}
|
status={watch("tinNumber")?.trim() && !errors.tinNumber ? "done" : "todo"}
|
||||||
>
|
>
|
||||||
<ETradeInfo
|
<TextInput
|
||||||
tin={watch("tinNumber")}
|
aria-label="Company TIN"
|
||||||
register={register("tinNumber")}
|
placeholder="0012345678"
|
||||||
error={errors.tinNumber?.message}
|
error={errors.tinNumber?.message}
|
||||||
onDataLoaded={onETradeDataLoaded}
|
{...register("tinNumber")}
|
||||||
onStatusChange={onETradeStatusChange}
|
/>
|
||||||
onReset={onETradeReset}
|
</StepSection>
|
||||||
alreadyVerified={hasRegistrationDetails}
|
|
||||||
selectedLicenceNumber={watch("licenceNumber")}
|
<StepSection
|
||||||
/>
|
index={3}
|
||||||
{tinVerified && (
|
title="Registration details"
|
||||||
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
|
subtitle="Everything we'd normally read off an eTrade licence. We need it from you instead."
|
||||||
)}
|
status={
|
||||||
</StepSection>
|
watch("companyName")?.trim() && watch("region")?.trim()
|
||||||
|
? "done"
|
||||||
|
: "todo"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Company Name"
|
||||||
|
placeholder="Registered name of the union or farm"
|
||||||
|
error={errors.companyName?.message}
|
||||||
|
{...register("companyName")}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="edr-muted">
|
||||||
|
Registered address
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
|
<Select
|
||||||
|
label="Region"
|
||||||
|
placeholder="Select region"
|
||||||
|
data={[...ETHIOPIAN_REGIONS]}
|
||||||
|
searchable
|
||||||
|
value={watch("region") || null}
|
||||||
|
onChange={(v) =>
|
||||||
|
setValue("region", v ?? "", { shouldValidate: true })
|
||||||
|
}
|
||||||
|
error={errors.region?.message}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Zone"
|
||||||
|
error={errors.zone?.message}
|
||||||
|
{...register("zone")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Woreda"
|
||||||
|
error={errors.woreda?.message}
|
||||||
|
{...register("woreda")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Kebele"
|
||||||
|
error={errors.kebele?.message}
|
||||||
|
{...register("kebele")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="House No."
|
||||||
|
error={errors.houseNo?.message}
|
||||||
|
{...register("houseNo")}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</StepSection>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<StepSection
|
||||||
|
index={2}
|
||||||
|
title="Company TIN"
|
||||||
|
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||||
|
status={
|
||||||
|
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ETradeInfo
|
||||||
|
tin={watch("tinNumber")}
|
||||||
|
register={register("tinNumber")}
|
||||||
|
error={errors.tinNumber?.message}
|
||||||
|
onDataLoaded={onETradeDataLoaded}
|
||||||
|
onStatusChange={onETradeStatusChange}
|
||||||
|
onReset={onETradeReset}
|
||||||
|
alreadyVerified={hasRegistrationDetails}
|
||||||
|
selectedLicenceNumber={watch("licenceNumber")}
|
||||||
|
/>
|
||||||
|
{tinVerified && (
|
||||||
|
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
|
||||||
|
)}
|
||||||
|
</StepSection>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ interface OnboardingRoleSelectProps {
|
|||||||
onChange: (next: string[]) => void;
|
onChange: (next: string[]) => void;
|
||||||
/** Render only the option grid — the wizard supplies its own header/card. */
|
/** Render only the option grid — the wizard supplies its own header/card. */
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
/**
|
||||||
|
* Roles this company cannot hold, hidden rather than shown-and-refused. A
|
||||||
|
* co-operative has no business licence, so it cannot freight-forward.
|
||||||
|
*/
|
||||||
|
excludeTypes?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,8 +27,12 @@ export default function OnboardingRoleSelect({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
excludeTypes,
|
||||||
}: OnboardingRoleSelectProps) {
|
}: OnboardingRoleSelectProps) {
|
||||||
const selected = new Set(value);
|
const selected = new Set(value);
|
||||||
|
const roles = excludeTypes?.length
|
||||||
|
? CUSTOMER_ROLES.filter((r) => !excludeTypes.includes(r.type))
|
||||||
|
: CUSTOMER_ROLES;
|
||||||
|
|
||||||
const toggleRole = (type: string) => {
|
const toggleRole = (type: string) => {
|
||||||
const next = new Set(value);
|
const next = new Set(value);
|
||||||
@@ -34,7 +43,7 @@ export default function OnboardingRoleSelect({
|
|||||||
|
|
||||||
const grid = (
|
const grid = (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
{CUSTOMER_ROLES.map((role) => (
|
{roles.map((role) => (
|
||||||
<RoleCard
|
<RoleCard
|
||||||
key={role.type}
|
key={role.type}
|
||||||
label={role.label}
|
label={role.label}
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ export const api = {
|
|||||||
companyType: string;
|
companyType: string;
|
||||||
roles: ProfileTypeValue[];
|
roles: ProfileTypeValue[];
|
||||||
nationality?: CompanyNationality;
|
nationality?: CompanyNationality;
|
||||||
|
/** No business licence: registration typed, no eTrade lookup, no forwarding. */
|
||||||
|
cooperative?: boolean;
|
||||||
},
|
},
|
||||||
CompanyInfoResponse
|
CompanyInfoResponse
|
||||||
>("companies", "startOnboarding", companiesService.startOnboarding),
|
>("companies", "startOnboarding", companiesService.startOnboarding),
|
||||||
|
|||||||
@@ -184,7 +184,16 @@ export interface OnboardingPoaState {
|
|||||||
*/
|
*/
|
||||||
export interface OnboardingRequirements {
|
export interface OnboardingRequirements {
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
|
/**
|
||||||
|
* Extra document set merged on top of the nationality one for a co-operative,
|
||||||
|
* null otherwise. `documents` already carries the merged list; this is only
|
||||||
|
* so the pickers, which render from the file-settings endpoint, can fetch the
|
||||||
|
* same extra fields.
|
||||||
|
*/
|
||||||
|
cooperativeDocumentSettingCode: string | null;
|
||||||
nationality: string;
|
nationality: string;
|
||||||
|
/** No business licence: registration typed by hand, no eTrade lookup. */
|
||||||
|
cooperative: boolean;
|
||||||
companyInfo: {
|
companyInfo: {
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
missingFields: { key: string; label: string }[];
|
missingFields: { key: string; label: string }[];
|
||||||
@@ -324,6 +333,7 @@ export const companiesService = {
|
|||||||
companyType: string;
|
companyType: string;
|
||||||
roles: ProfileTypeValue[];
|
roles: ProfileTypeValue[];
|
||||||
nationality?: CompanyNationality;
|
nationality?: CompanyNationality;
|
||||||
|
cooperative?: boolean;
|
||||||
}): Promise<CompanyInfoResponse> => {
|
}): Promise<CompanyInfoResponse> => {
|
||||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||||
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
|
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface ProfileResponse {
|
|||||||
companyName: string;
|
companyName: string;
|
||||||
companyType: string;
|
companyType: string;
|
||||||
nationality: string | null;
|
nationality: string | null;
|
||||||
|
/** No business licence: the registration is typed, not fetched from eTrade. */
|
||||||
|
cooperative: boolean;
|
||||||
companyProfiles: CompanyProfileResponse[];
|
companyProfiles: CompanyProfileResponse[];
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
companyAddress: string | null;
|
companyAddress: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user