feat: fix the settings preview for the business licence and changes request to the approval

This commit is contained in:
Nathnael
2026-07-08 10:42:00 +00:00
parent 3bc4514b04
commit 8947ab8614
26 changed files with 1368 additions and 204 deletions

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Business-license files used to live inline as a jsonb array on
* `company_profiles.business_license_files`. They now belong to the FileRecord
* model (`freight.files`, resource `company_profiles`, code `business_license`)
* so they get stable ids and stream through `GET /api/files/:id` — the same
* proxy path regular documents use — instead of broken direct-MinIO URLs.
*
* This copies each existing inline entry into `freight.files` by reference
* (keeping the stored object URL — no bytes are re-uploaded). The original jsonb
* column is left intact for rollback safety.
*/
export class MigrateLicenseFilesToFileRecords2040000000000
implements MigrationInterface
{
name = "MigrateLicenseFilesToFileRecords2040000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO freight.files
(id, resource_id, resource, code, name, url, size, mime_type, created_at, updated_at)
SELECT
gen_random_uuid(),
cp.id,
'company_profiles',
'business_license',
COALESCE(elem->>'name', 'license'),
elem->>'url',
COALESCE(NULLIF(elem->>'size', '')::int, 0),
COALESCE(NULLIF(elem->>'mimeType', ''), 'application/octet-stream'),
now(),
now()
FROM freight.company_profiles cp
CROSS JOIN LATERAL jsonb_array_elements(cp.business_license_files) AS elem
WHERE cp.business_license_files IS NOT NULL
AND jsonb_typeof(cp.business_license_files) = 'array'
AND elem->>'url' IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.files f
WHERE f.resource_id = cp.id
AND f.resource = 'company_profiles'
AND f.code = 'business_license'
AND f.url = elem->>'url'
AND f.deleted_at IS NULL
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Reverse the model migration by dropping the license FileRecords. The
// original jsonb column was never cleared, so the data still exists there.
await queryRunner.query(`
DELETE FROM freight.files
WHERE resource = 'company_profiles'
AND code = 'business_license';
`);
}
}

View File

@@ -12,6 +12,7 @@ import {
HttpStatus, HttpStatus,
UseInterceptors, UseInterceptors,
UploadedFiles, UploadedFiles,
BadRequestException,
} from "@nestjs/common"; } from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
@@ -33,7 +34,7 @@ import {
ResponseCompanyDto, ResponseCompanyDto,
ResponseCompanyProfileDto, ResponseCompanyProfileDto,
} from "./dto/response-company.dto"; } from "./dto/response-company.dto";
import { BusinessLicenseFile } from "./entities/company-profile.entity"; import { ProfileLicenseFileView } from "./entities/company-profile.entity";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto";
@@ -63,6 +64,25 @@ export class CompaniesController {
private readonly filesService: FilesService, private readonly filesService: FilesService,
) { } ) { }
/**
* License files are FileRecord-backed and previewed through `GET /api/files/:id`
* (the client builds that URL from the returned `id`). Populate each profile
* DTO's `licenseFiles` with its live/pending files in one batched lookup.
*/
private async populateLicenseFiles(
companyId: string,
profiles: { id: string; licenseFiles: ProfileLicenseFileView[] }[],
): Promise<void> {
if (profiles.length === 0) return;
const byProfile = await this.companiesService.assembleLicenseFilesByProfile(
companyId,
profiles.map((p) => p.id),
);
for (const p of profiles) {
p.licenseFiles = byProfile[p.id] ?? [];
}
}
@Get("getInfo") @Get("getInfo")
@ApiOperation({ summary: "Get company info for the current user" }) @ApiOperation({ summary: "Get company info for the current user" })
async getInfo( async getInfo(
@@ -86,7 +106,9 @@ export class CompaniesController {
const review = await this.companiesService.getOpenChangeRequestForCompany( const review = await this.companiesService.getOpenChangeRequestForCompany(
company.id, company.id,
); );
return new ProfileResponseDto(profile, company, review); const dto = new ProfileResponseDto(profile, company, review);
await this.populateLicenseFiles(company.id, dto.companyProfiles);
return dto;
} }
@Get("profile/change-request") @Get("profile/change-request")
@@ -215,28 +237,72 @@ export class CompaniesController {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: summary:
"Upload business-license document(s) for one of the current user's company profiles", "Add business-license document(s) to a profile. For an approved company " +
"the upload is staged for backoffice review; during onboarding it goes live.",
}) })
async uploadProfileLicense( async uploadProfileLicense(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string, @Param("profileId", ParseUUIDPipe) profileId: string,
@UploadedFiles() files: Array<Express.Multer.File>, @UploadedFiles() files: Array<Express.Multer.File>,
): Promise<BusinessLicenseFile[]> { ): Promise<ProfileLicenseFileView[]> {
return this.companiesService.uploadProfileLicenseFiles( return this.companiesService.addProfileLicenseFiles(
user.id, user.id,
profileId, profileId,
files, files,
); );
} }
@Post("company-profiles/:profileId/license/:fileId/replace")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Replace a business-license file with a newly uploaded one (staged for " +
"review on an approved company).",
})
async replaceProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@Param("fileId", ParseUUIDPipe) fileId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
): Promise<ProfileLicenseFileView[]> {
const file = files?.[0];
if (!file) {
throw new BadRequestException("A replacement file is required");
}
return this.companiesService.replaceProfileLicenseFile(
user.id,
profileId,
fileId,
file,
);
}
@Delete("company-profiles/:profileId/license/:fileId")
@ApiOperation({
summary:
"Remove a business-license file (staged for review on an approved company).",
})
async removeProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@Param("fileId", ParseUUIDPipe) fileId: string,
): Promise<ProfileLicenseFileView[]> {
return this.companiesService.removeProfileLicenseFile(
user.id,
profileId,
fileId,
);
}
@Get("company-profiles/:profileId/license") @Get("company-profiles/:profileId/license")
@ApiOperation({ @ApiOperation({
summary: "List business-license documents for a company profile", summary: "List business-license documents (with review state) for a profile",
}) })
async listProfileLicense( async listProfileLicense(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string, @Param("profileId", ParseUUIDPipe) profileId: string,
): Promise<BusinessLicenseFile[]> { ): Promise<ProfileLicenseFileView[]> {
return this.companiesService.listProfileLicenseFiles(user.id, profileId); return this.companiesService.listProfileLicenseFiles(user.id, profileId);
} }
@@ -344,7 +410,9 @@ export class CompaniesController {
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
): Promise<ResponseCompanyDto> { ): Promise<ResponseCompanyDto> {
const company = await this.companiesService.findCompanyById(id); const company = await this.companiesService.findCompanyById(id);
return new ResponseCompanyDto(company); const dto = new ResponseCompanyDto(company);
await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []);
return dto;
} }
@Patch(":id") @Patch(":id")

View File

@@ -13,7 +13,6 @@ import {
CompanyDashboardRepository, CompanyDashboardRepository,
DashboardScope, DashboardScope,
} from "./company-dashboard.repository"; } from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service";
import { FilesService } from "../files/files.service"; 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";
@@ -39,14 +38,22 @@ import { ExternalProfile } from "./entities/external-profile.entity";
import { import {
BusinessLicenseFile, BusinessLicenseFile,
CompanyProfile, CompanyProfile,
ProfileLicenseFileView,
ProfileType, ProfileType,
ProfileStatus, ProfileStatus,
} from "./entities/company-profile.entity"; } from "./entities/company-profile.entity";
import { import {
ChangeRequestStatus, ChangeRequestStatus,
CompanyChangeRequest, CompanyChangeRequest,
LicenseChangeIntent,
} from "./entities/company-change-request.entity"; } from "./entities/company-change-request.entity";
/** FileRecord `resource` + `code` slots for business-license documents. */
const LICENSE_RESOURCE = "company_profiles";
const LICENSE_CODE = "business_license";
/** Code for a license file staged in an open change request (not yet live). */
const LICENSE_PENDING_CODE = "business_license_pending";
export interface UserIdentity { export interface UserIdentity {
userId: string; userId: string;
firstName: string; firstName: string;
@@ -63,7 +70,6 @@ export class CompaniesService {
private readonly changeRequestRepo: CompanyChangeRequestRepository, private readonly changeRequestRepo: CompanyChangeRequestRepository,
private readonly profilesRepo: ExternalProfileRepository, private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository, private readonly dashboardRepo: CompanyDashboardRepository,
private readonly minioService: MinioService,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService, private readonly etradeService: ETradeService,
@@ -341,28 +347,9 @@ export class CompaniesService {
const company = await this.companiesRepo.findById(id); const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`); if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
for (const profile of company.companyProfiles) {
profile.businessLicenseFiles = await this.signLicenseFiles(
profile.businessLicenseFiles,
);
}
return company; return company;
} }
/**
* Business-license files are stored as raw, unsigned MinIO URLs (see
* `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them
* directly. Sign each one with a short-lived URL before it reaches a response.
*/
private async signLicenseFiles(
files?: BusinessLicenseFile[] | null,
): Promise<BusinessLicenseFile[]> {
if (!files?.length) return [];
return Promise.all(
files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })),
);
}
/** /**
* Validate an explicitly-chosen company profile for a booking: it must belong * Validate an explicitly-chosen company profile for a booking: it must belong
* to the booking's company and be Active. Used for government bookings (staff * to the booking's company and be Active. Used for government bookings (staff
@@ -777,6 +764,7 @@ export class CompaniesService {
await this.assertTinAvailable(company, snapshot.tin); await this.assertTinAvailable(company, snapshot.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
await this.companiesRepo.update(company.id, companyUpdates); await this.companiesRepo.update(company.id, companyUpdates);
await this.applyLicenseChanges(request);
return ( return (
(await this.changeRequestRepo.update(id, { (await this.changeRequestRepo.update(id, {
@@ -860,9 +848,13 @@ export class CompaniesService {
`Change request ${id} is already ${request.status}`, `Change request ${id} is already ${request.status}`,
); );
} }
await this.discardLicenseChanges(request);
return ( return (
(await this.changeRequestRepo.update(id, { (await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Rejected, status: ChangeRequestStatus.Rejected,
// Staged license uploads were just discarded; drop their intents so an
// amended resubmit never re-references deleted files.
documents: { ...request.documents, licenseChanges: [] },
note, note,
reviewedBy: reviewerId ?? null, reviewedBy: reviewerId ?? null,
reviewedAt: new Date(), reviewedAt: new Date(),
@@ -1231,13 +1223,21 @@ export class CompaniesService {
})); }));
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
// 3. Per-operational-profile business licenses. // 3. Per-operational-profile business licenses (FileRecord-backed).
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ const licenseProfiles = await Promise.all(
profileId: p.id, (company.companyProfiles ?? []).map(async (p) => {
type: p.type, const records = await this.filesService.findByResource(
reference: p.reference ?? "", p.id,
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, LICENSE_RESOURCE,
})); );
return {
profileId: p.id,
type: p.type,
reference: p.reference ?? "",
uploaded: records.some((r) => r.code === LICENSE_CODE),
};
}),
);
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
const outstanding = [ const outstanding = [
@@ -1354,61 +1354,321 @@ export class CompaniesService {
return owned; return owned;
} }
// ─── Business-license files ────────────────────────────────────────────────
//
// License documents live in the FileRecord model (`freight.files`) with
// `resource = "company_profiles"`, `resourceId = <profileId>`. Live files use
// code `LICENSE_CODE`; files staged inside an open change request (add /
// replacement) use `LICENSE_PENDING_CODE` and only become live on approval.
// Preview streams through `GET /api/files/:id` (server-side proxy) — the same
// path regular documents use — so it never hits MinIO directly from the
// browser (which fails on the internal bucket endpoint).
/** /**
* Upload business-license document(s) and store them directly on the company * Upload business-license file(s) for one of the user's profiles. During
* profile (multi-file). Bytes go to object storage; only metadata/URLs are * onboarding (company not yet Active) they go live immediately; for an Active
* persisted on the profile — intentionally not via the FileRecord file model. * company they're staged under the pending code and recorded as `add` intents
* New files are appended to any already present. Returns the full list. * on a pending change request for backoffice review. Returns the updated view.
*/ */
async uploadProfileLicenseFiles( async addProfileLicenseFiles(
userId: string, userId: string,
profileId: string, profileId: string,
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<BusinessLicenseFile[]> { ): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId); const profile = await this.resolveOwnedProfile(userId, profileId);
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
const uploaded: BusinessLicenseFile[] = []; const uploaded = await Promise.all(
for (const file of files) { files.map((file) =>
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`; this.filesService.upload({
const url = await this.minioService.uploadFile( resourceId: profileId,
objectName, resource: LICENSE_RESOURCE,
file.buffer, code,
file.mimetype, file,
}),
),
);
if (gated) {
await this.stageLicenseChange(
company.id,
uploaded.map((r) => ({
profileId,
op: "add" as const,
fileId: r.id,
fileName: r.name,
})),
userId,
); );
uploaded.push({
name: file.originalname,
url,
size: file.size,
mimeType: file.mimetype,
});
} }
const next = [...(profile.businessLicenseFiles ?? []), ...uploaded]; return this.getProfileLicenseView(profileId, company.id);
await this.companyProfilesRepo.update(profileId, {
businessLicenseFiles: next,
});
return next;
}
/** The business-license files stored on a single company profile. */
async listProfileLicenseFiles(
userId: string,
profileId: string,
): Promise<BusinessLicenseFile[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
return profile.businessLicenseFiles ?? [];
} }
/** /**
* Onboarding documents stored on a company profile, fetched by profile id. * Remove a license file. A staged (pending) file is withdrawn outright
* Internal helper (no ownership check) used when a booking reuses the active * (soft-deleted, its `add` intent dropped). A live file on an Active company
* profile's onboarding documents. Returns [] when the profile is unknown. * is kept and recorded as a `remove` intent for review; during onboarding it
* is deleted immediately.
*/
async removeProfileLicenseFile(
userId: string,
profileId: string,
fileId: string,
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const record = await this.filesService.findById(fileId);
if (
record.resource !== LICENSE_RESOURCE ||
record.resourceId !== profileId
) {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
if (record.code === LICENSE_PENDING_CODE) {
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
await this.filesService.remove(fileId);
await this.withdrawLicenseIntent(company.id, fileId);
} else if (gated) {
await this.stageLicenseChange(
company.id,
[{ profileId, op: "remove", fileId, fileName: record.name }],
userId,
);
} else {
await this.filesService.remove(fileId);
}
return this.getProfileLicenseView(profileId, company.id);
}
/**
* Replace a live license file with a freshly uploaded one — recorded as a
* `remove` of the old file plus an `add` of the new, so approval swaps them
* atomically. During onboarding the swap is applied immediately.
*/
async replaceProfileLicenseFile(
userId: string,
profileId: string,
fileId: string,
file: Express.Multer.File,
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const old = await this.filesService.findById(fileId);
if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const created = await this.filesService.upload({
resourceId: profileId,
resource: LICENSE_RESOURCE,
code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE,
file,
});
if (gated) {
await this.stageLicenseChange(
company.id,
[
{ profileId, op: "remove", fileId, fileName: old.name },
{ profileId, op: "add", fileId: created.id, fileName: created.name },
],
userId,
);
} else {
await this.filesService.remove(fileId);
}
return this.getProfileLicenseView(profileId, company.id);
}
/** License files for one profile, with each file's review status resolved. */
async listProfileLicenseFiles(
userId: string,
profileId: string,
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
return this.getProfileLicenseView(profileId, profile.companyId);
}
/**
* Live license files for a profile, shaped for by-reference reuse (bookings /
* contracts snapshot these). No ownership check — internal callers only.
* Returns the raw stored URLs; pending (unapproved) files are excluded.
*/ */
async getProfileOnboardingFiles( async getProfileOnboardingFiles(
profileId: string, profileId: string,
): Promise<BusinessLicenseFile[]> { ): Promise<BusinessLicenseFile[]> {
const profile = await this.companyProfilesRepo.findById(profileId); const records = await this.filesService.findByResource(
return profile?.businessLicenseFiles ?? []; profileId,
LICENSE_RESOURCE,
);
return records
.filter((r) => r.code === LICENSE_CODE)
.map((r) => ({
name: r.name,
url: r.url,
size: r.size,
mimeType: r.mimeType,
}));
}
/**
* Assemble the review-aware license view for a set of profiles in one pass
* (single change-request lookup). Used to enrich company/profile responses.
*/
async assembleLicenseFilesByProfile(
companyId: string,
profileIds: string[],
): Promise<Record<string, ProfileLicenseFileView[]>> {
const pending =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const removeIds = new Set(
(pending?.documents?.licenseChanges ?? [])
.filter((c) => c.op === "remove")
.map((c) => c.fileId),
);
const result: Record<string, ProfileLicenseFileView[]> = {};
await Promise.all(
profileIds.map(async (pid) => {
result[pid] = await this.mapLicenseRecords(pid, removeIds);
}),
);
return result;
}
/** Single-profile license view (fetches the company's pending request once). */
private async getProfileLicenseView(
profileId: string,
companyId: string,
): Promise<ProfileLicenseFileView[]> {
const pending =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const removeIds = new Set(
(pending?.documents?.licenseChanges ?? [])
.filter((c) => c.op === "remove")
.map((c) => c.fileId),
);
return this.mapLicenseRecords(profileId, removeIds);
}
private async mapLicenseRecords(
profileId: string,
pendingRemoveIds: Set<string>,
): Promise<ProfileLicenseFileView[]> {
const records = await this.filesService.findByResource(
profileId,
LICENSE_RESOURCE,
);
return records
.filter(
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
)
.map((r) => ({
id: r.id,
name: r.name,
size: r.size,
mimeType: r.mimeType,
status:
r.code === LICENSE_PENDING_CODE
? ("pending_add" as const)
: pendingRemoveIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
}));
}
/** Open or append a pending change request recording license add/remove intents. */
private async stageLicenseChange(
companyId: string,
changes: LicenseChangeIntent[],
submittedBy?: string,
): Promise<void> {
if (changes.length === 0) return;
const now = new Date();
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (existing) {
const prev = existing.documents?.licenseChanges ?? [];
await this.changeRequestRepo.update(existing.id, {
documents: {
...existing.documents,
licenseChanges: [...prev, ...changes],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
});
} else {
await this.changeRequestRepo.create({
companyId,
snapshot: {},
documents: { licenseChanges: changes },
status: ChangeRequestStatus.Pending,
submittedBy: submittedBy ?? null,
submittedAt: now,
});
}
}
/**
* Drop a staged license intent (add or remove) referencing `fileId` from the
* company's open request. If that empties the request entirely, delete it so
* the customer's settings page unlocks.
*/
private async withdrawLicenseIntent(
companyId: string,
fileId: string,
): Promise<void> {
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (!existing) return;
const remaining = (existing.documents?.licenseChanges ?? []).filter(
(c) => c.fileId !== fileId,
);
const docs = existing.documents ?? {};
const stillHasWork =
remaining.length > 0 ||
(docs.documentFileIds?.length ?? 0) > 0 ||
Object.keys(existing.snapshot ?? {}).length > 0;
if (stillHasWork) {
await this.changeRequestRepo.update(existing.id, {
documents: { ...docs, licenseChanges: remaining },
});
} else {
await this.changeRequestRepo.softDelete(existing.id);
}
}
/** Apply a request's staged license changes: promote adds, delete removes. */
private async applyLicenseChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.licenseChanges ?? []) {
if (change.op === "add") {
await this.filesService.setCode(change.fileId, LICENSE_CODE);
} else {
await this.filesService.remove(change.fileId);
}
}
}
/** Discard a rejected request's staged license uploads (adds only). */
private async discardLicenseChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.licenseChanges ?? []) {
if (change.op === "add") {
await this.filesService.remove(change.fileId);
}
}
} }
/** /**

View File

@@ -1,6 +1,7 @@
import { import {
ChangeRequestStatus, ChangeRequestStatus,
CompanyChangeRequest, CompanyChangeRequest,
LicenseChangeIntent,
} from "../entities/company-change-request.entity"; } from "../entities/company-change-request.entity";
/** /**
@@ -15,6 +16,8 @@ export class ChangeRequestResponseDto {
/** Proposed field values (Partial<UpdateProfileDto>) — the diff payload. */ /** Proposed field values (Partial<UpdateProfileDto>) — the diff payload. */
snapshot: Record<string, any>; snapshot: Record<string, any>;
documentFileIds: string[]; documentFileIds: string[];
/** Staged business-license add/remove intents attached to this request. */
licenseChanges: LicenseChangeIntent[];
note: string | null; note: string | null;
submittedBy: string | null; submittedBy: string | null;
submittedAt: Date | null; submittedAt: Date | null;
@@ -29,6 +32,7 @@ export class ChangeRequestResponseDto {
this.status = req.status; this.status = req.status;
this.snapshot = req.snapshot ?? {}; this.snapshot = req.snapshot ?? {};
this.documentFileIds = req.documents?.documentFileIds ?? []; this.documentFileIds = req.documents?.documentFileIds ?? [];
this.licenseChanges = req.documents?.licenseChanges ?? [];
this.note = req.note ?? null; this.note = req.note ?? null;
this.submittedBy = req.submittedBy ?? null; this.submittedBy = req.submittedBy ?? null;
this.submittedAt = req.submittedAt ?? null; this.submittedAt = req.submittedAt ?? null;

View File

@@ -5,8 +5,8 @@ import {
CompanyNationality, CompanyNationality,
} from '../entities/company.entity'; } from '../entities/company.entity';
import { import {
BusinessLicenseFile,
CompanyProfile, CompanyProfile,
ProfileLicenseFileView,
} from '../entities/company-profile.entity'; } from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto'; import { ResponseExternalProfileDto } from './response-external-profile.dto';
@@ -18,8 +18,12 @@ export class ResponseCompanyProfileDto {
status: string; status: string;
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */ /** @deprecated Superseded by licenseFiles. Kept for back-compat. */
businessLicense?: string | null; businessLicense?: string | null;
/** Business-license documents stored on the profile (multi-file). */ /**
licenseFiles: BusinessLicenseFile[]; * Business-license documents (FileRecord-backed) with review state. Left empty
* by the constructor and populated asynchronously by the controller, since the
* files and their pending-change status require DB lookups.
*/
licenseFiles: ProfileLicenseFileView[];
attributes?: Record<string, any> | null; attributes?: Record<string, any> | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */ /** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null; reviewNote?: string | null;
@@ -33,7 +37,7 @@ export class ResponseCompanyProfileDto {
this.reference = profile.reference ?? ''; this.reference = profile.reference ?? '';
this.status = profile.status; this.status = profile.status;
this.businessLicense = profile.businessLicense; this.businessLicense = profile.businessLicense;
this.licenseFiles = profile.businessLicenseFiles ?? []; this.licenseFiles = [];
this.attributes = profile.attributes; this.attributes = profile.attributes;
this.reviewNote = profile.reviewNote ?? null; this.reviewNote = profile.reviewNote ?? null;
this.createdAt = profile.createdAt; this.createdAt = profile.createdAt;

View File

@@ -15,10 +15,27 @@ export enum ChangeRequestStatus {
Rejected = "rejected", Rejected = "rejected",
} }
/**
* A single staged business-license change on one company profile, awaiting
* review. `add` → a new file was uploaded under the pending code and becomes
* live on approval; `remove` → an existing live file is deleted on approval.
* A "replace" is recorded as a `remove` of the old file plus an `add` of the
* new one. `fileId` is the FileRecord id the op targets.
*/
export interface LicenseChangeIntent {
profileId: string;
op: "add" | "remove";
fileId: string;
/** File name, snapshotted for the backoffice review screen. */
fileName?: string;
}
/** File references staged alongside a change request (documents/licenses). */ /** File references staged alongside a change request (documents/licenses). */
export interface ChangeRequestDocuments { export interface ChangeRequestDocuments {
/** FileRecord ids uploaded against the company while this request was open. */ /** FileRecord ids uploaded against the company while this request was open. */
documentFileIds?: string[]; documentFileIds?: string[];
/** Staged per-profile business-license add/remove intents. */
licenseChanges?: LicenseChangeIntent[];
} }
@Entity({ schema: "freight", name: "company_change_request" }) @Entity({ schema: "freight", name: "company_change_request" })

View File

@@ -19,7 +19,11 @@ export enum ProfileStatus {
Blacklisted = "blacklisted", Blacklisted = "blacklisted",
} }
/** A business-license document stored directly on the company profile. */ /**
* @deprecated Legacy inline shape. Business-license files now live in the
* FileRecord model (`freight.files`, resource `company_profiles`). Kept only for
* the by-reference reuse shape consumed by bookings/contracts snapshots.
*/
export interface BusinessLicenseFile { export interface BusinessLicenseFile {
name: string; name: string;
url: string; url: string;
@@ -27,6 +31,19 @@ export interface BusinessLicenseFile {
mimeType?: string; mimeType?: string;
} }
/** A business-license file plus its change-review state, surfaced to clients. */
export interface ProfileLicenseFileView {
id: string;
name: string;
size: number;
mimeType: string;
/**
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
* `pending_remove` — live but flagged for deletion on approval.
*/
status: "live" | "pending_add" | "pending_remove";
}
@Entity({ schema: "freight", name: "company_profiles" }) @Entity({ schema: "freight", name: "company_profiles" })
@Index(["reference"], { unique: true }) @Index(["reference"], { unique: true })
@Index(["type"]) @Index(["type"])

View File

@@ -12,7 +12,7 @@ import { YardCountry } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity'; import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
@@ -326,10 +326,20 @@ export class ContractsService {
companyProfileId: string | null, companyProfileId: string | null,
): Promise<void> { ): Promise<void> {
if (!companyProfileId) return; if (!companyProfileId) return;
const profile = await this.dataSource // Business-license files are FileRecords (resource "company_profiles"); carry
.getRepository(CompanyProfile) // the live ones by reference. Staged/pending uploads are excluded by code.
.findOne({ where: { id: companyProfileId } }); const records = await this.filesService.findByResource(
const docs = profile?.businessLicenseFiles ?? []; companyProfileId,
'company_profiles',
);
const docs = records
.filter((r) => r.code === 'business_license')
.map((r) => ({
name: r.name,
url: r.url,
size: r.size,
mimeType: r.mimeType,
}));
if (docs.length === 0) return; if (docs.length === 0) return;
const slug = (name: string) => const slug = (name: string) =>

View File

@@ -124,6 +124,15 @@ export class FilesService {
await this.filesRepository.softDelete(id); await this.filesRepository.softDelete(id);
} }
/**
* Re-slot a stored file under a new `code` (e.g. promote a staged
* `business_license_pending` file to the live `business_license` code once a
* change request is approved). Bytes and URL are untouched.
*/
async setCode(id: string, code: string): Promise<void> {
await this.filesRepository.update(id, { code });
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> { findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource); return this.filesRepository.findByResource(resourceId, resource);
} }

View File

@@ -1,5 +1,6 @@
import { import {
Alert, Alert,
Anchor,
Badge, Badge,
Box, Box,
Button, Button,
@@ -12,9 +13,17 @@ import {
Textarea, Textarea,
} from "@mantine/core"; } from "@mantine/core";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { AlertTriangle, ClipboardCheck, Clock } from "lucide-react"; import {
AlertTriangle,
ClipboardCheck,
Clock,
FilePlus2,
FileX2,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useFileViewer } from "@edr/ui-common";
import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { Company, CompanyChangeRequest } from "@/types/customer"; import type { Company, CompanyChangeRequest } from "@/types/customer";
import { formatDate, humanize } from "./format"; import { formatDate, humanize } from "./format";
@@ -129,6 +138,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
api.customers.rejectChangeRequest.mutationOptions(), api.customers.rejectChangeRequest.mutationOptions(),
); );
const { view, viewer } = useFileViewer();
const [rejectId, setRejectId] = useState<string | null>(null); const [rejectId, setRejectId] = useState<string | null>(null);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
@@ -142,6 +152,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
? Object.keys(pending.snapshot ?? {}) ? Object.keys(pending.snapshot ?? {})
: ([] as string[]); : ([] as string[]);
const docCount = pending?.documentFileIds?.length ?? 0; const docCount = pending?.documentFileIds?.length ?? 0;
const licenseChanges = pending?.licenseChanges ?? [];
const confirmReject = () => { const confirmReject = () => {
if (!rejectId) return; if (!rejectId) return;
@@ -206,6 +217,48 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Text> </Text>
)} )}
{licenseChanges.length > 0 && (
<Stack gap={8}>
<Text size="sm" fw={600} c="edr-text">
Business license changes
</Text>
{licenseChanges.map((c, i) => (
<Group key={`${c.fileId}-${i}`} gap={8} wrap="nowrap">
{c.op === "add" ? (
<FilePlus2 size={15} className="text-edr-muted" />
) : (
<FileX2 size={15} className="text-edr-muted" />
)}
<Badge
size="sm"
radius="sm"
variant="light"
color={c.op === "add" ? "green" : "red"}
>
{c.op === "add" ? "Add" : "Remove"}
</Badge>
<Anchor
component="button"
type="button"
size="sm"
onClick={() =>
view({
name: c.fileName ?? "License document",
url: fileViewUrl(c.fileId),
})
}
style={{
textDecoration:
c.op === "remove" ? "line-through" : undefined,
}}
>
{c.fileName ?? "License document"}
</Anchor>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button <Button
variant="light" variant="light"
@@ -300,6 +353,8 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
{viewer}
</> </>
); );
} }

View File

@@ -1,6 +1,7 @@
import { import {
ActionIcon, ActionIcon,
Anchor, Anchor,
Badge,
Box, Box,
Button, Button,
Card, Card,
@@ -684,12 +685,12 @@ export default function CustomerDetailPage() {
</Text> </Text>
<Stack gap="md"> <Stack gap="md">
{licenseProfiles.map((p) => ( {licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}> <Stack key={p.id} gap={6}>
<Text size="sm" fw={600} c="edr-text"> <Text size="sm" fw={600} c="edr-text">
{humanize(p.type)} · {p.reference} {humanize(p.type)} · {p.reference}
</Text> </Text>
{(p.licenseFiles ?? []).map((f) => ( {(p.licenseFiles ?? []).map((f) => (
<Group key={f.url} gap={6} wrap="nowrap"> <Group key={f.id} gap={8} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" /> <Paperclip size={13} className="text-edr-muted" />
<Anchor <Anchor
component="button" component="button"
@@ -697,16 +698,37 @@ export default function CustomerDetailPage() {
onClick={() => onClick={() =>
view({ view({
name: f.name, name: f.name,
url: f.url, url: fileViewUrl(f.id),
mimeType: f.mimeType, mimeType: f.mimeType,
}) })
} }
size="xs" size="xs"
style={{
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
> >
{f.name} {f.name}
</Anchor> </Anchor>
{f.status === "pending_add" && (
<Badge size="xs" color="yellow" variant="light">
Pending approval
</Badge>
)}
{f.status === "pending_remove" && (
<Badge size="xs" color="red" variant="light">
Removal pending
</Badge>
)}
</Group> </Group>
))} ))}
{(p.licenseFiles ?? []).length === 0 && (
<Text size="xs" c="dimmed">
No license documents.
</Text>
)}
</Stack> </Stack>
))} ))}
</Stack> </Stack>

View File

@@ -37,12 +37,17 @@ export type ProfileStatus =
| "suspended" | "suspended"
| "blacklisted"; | "blacklisted";
/** A business-license document uploaded for a company profile. */ /** Review state of a business-license file (mirrors API ProfileLicenseFileView). */
export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
/** A business-license document uploaded for a company profile (FileRecord-backed). */
export interface LicenseFile { export interface LicenseFile {
id: string;
name: string; name: string;
url: string;
size: number; size: number;
mimeType?: string; mimeType: string;
/** `live` = approved; `pending_add`/`pending_remove` = awaiting review. */
status: LicenseFileStatus;
} }
/** A single role a company is registered for, with its reference code. */ /** A single role a company is registered for, with its reference code. */
@@ -66,6 +71,14 @@ export interface CompanyProfile {
/** Lifecycle of a staged customer profile-edit review. */ /** Lifecycle of a staged customer profile-edit review. */
export type ChangeRequestStatus = "pending" | "approved" | "rejected"; export type ChangeRequestStatus = "pending" | "approved" | "rejected";
/** A staged business-license add/remove on one profile, awaiting review. */
export interface LicenseChangeIntent {
profileId: string;
op: "add" | "remove";
fileId: string;
fileName?: string;
}
/** /**
* A staged profile-edit change request. The customer's settings edits land here * A staged profile-edit change request. The customer's settings edits land here
* (pending) until a reviewer approves (applies them) or rejects (with a note). * (pending) until a reviewer approves (applies them) or rejects (with a note).
@@ -77,6 +90,8 @@ export interface CompanyChangeRequest {
/** Proposed field values (the diff payload vs. the live company). */ /** Proposed field values (the diff payload vs. the live company). */
snapshot: Record<string, unknown>; snapshot: Record<string, unknown>;
documentFileIds: string[]; documentFileIds: string[];
/** Staged business-license add/remove intents attached to this request. */
licenseChanges: LicenseChangeIntent[];
note: string | null; note: string | null;
submittedAt: string | null; submittedAt: string | null;
reviewedAt: string | null; reviewedAt: string | null;

View File

@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Clock } from "lucide-react"; import { Link } from "react-router-dom";
import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service"; import type { OnboardingRequirements } from "@/services/companies.service";
@@ -148,16 +149,74 @@ export default function OnboardingResumeBanner({
} }
/** /**
* Shown once onboarding is submitted but the company's operational profiles are * Post-onboarding review banner. Surfaces (in priority order):
* still being reviewed. Communicates that approval is per-profile and that * 1. A pending profile-edit review — the whole account is locked until an admin
* bookings unlock as each profile is cleared. Self-hides when nothing is pending. * approves the submitted changes.
* 2. A rejected profile-edit review — links to Settings to amend & resubmit.
* 3. Per-operational-profile approval — bookings unlock as each role clears.
* Self-hides when there's nothing outstanding.
*/ */
export function AccountReviewBanner() { export function AccountReviewBanner() {
const { company } = useAuth(); const { company, reviewStatus, reviewNote } = useAuth();
const profiles = company?.company?.companyProfiles ?? []; const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending"); const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active"); const approved = profiles.filter((p) => p.status === "active");
// 1. Profile-edit review pending — the account-wide lock.
if (reviewStatus === "pending") {
return (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Clock size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-amber-900">
Your profile changes are under review
</span>
<span className="text-xs text-amber-800">
Editing and creating new contracts or bookings is paused until an
administrator approves your submitted changes.
</span>
</span>
</div>
</div>
);
}
// 2. Profile-edit review rejected — prompt to fix & resubmit.
if (reviewStatus === "rejected") {
return (
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-700">
<AlertTriangle size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-red-900">
Your recent changes were not approved
</span>
<span className="text-xs text-red-800">
{reviewNote
? `Reviewer note: ${reviewNote}`
: "Please update your details and resubmit for review."}
</span>
</span>
</div>
<Link
to="/settings"
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-transform hover:scale-[1.02]"
>
Review &amp; resubmit
<ArrowRight size={16} />
</Link>
</div>
</div>
);
}
// 3. Per-operational-profile approval (existing behaviour).
if (profiles.length === 0 || pending.length === 0) return null; if (profiles.length === 0 || pending.length === 0) return null;
const pendingLabel = pending const pendingLabel = pending

View File

@@ -4,6 +4,7 @@ import { Paperclip } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight"; import type { IFileUploadSetting } from "@edr/types/freight";
import { fileViewUrl } from "@/constants/apiConfig";
import type { LicenseFile } from "@/services/companies.service"; import type { LicenseFile } from "@/services/companies.service";
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
@@ -107,10 +108,10 @@ export default function RoleLicenseStep({
{hasExisting && ( {hasExisting && (
<Stack gap={4} mb="sm"> <Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => ( {profile.existingFiles.map((f) => (
<Group key={f.url} gap={6} wrap="nowrap"> <Group key={f.id} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" /> <Paperclip size={13} className="text-edr-muted" />
<Anchor <Anchor
href={f.url} href={fileViewUrl(f.id)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
size="xs" size="xs"

View File

@@ -96,6 +96,13 @@ export const URL_CONSTANTS = {
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
PROFILE_LICENSE: (profileId: string) => PROFILE_LICENSE: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/license`, `/api/companies/company-profiles/${profileId}/license`,
PROFILE_LICENSE_FILE: (profileId: string, fileId: string) =>
`/api/companies/company-profiles/${profileId}/license/${fileId}`,
PROFILE_LICENSE_REPLACE: (profileId: string, fileId: string) =>
`/api/companies/company-profiles/${profileId}/license/${fileId}/replace`,
PROFILE_CHANGE_REQUEST: "/api/companies/profile/change-request",
PROFILE_REAPPLY: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/reapply`,
}, },
BOOKINGS: { BOOKINGS: {

View File

@@ -44,7 +44,21 @@ const useAuth = () => {
api.companies.getInfo.queryOptions({ api.companies.getInfo.queryOptions({
enabled: !!authQuery.data?.id, enabled: !!authQuery.data?.id,
retry: false, retry: false,
staleTime: 10 * 60 * 1000,
staleTime(query) {
// Fast-poll while anything is awaiting a backoffice decision: an
// unapproved role, or a pending profile-edit review. This surfaces
// approvals/rejections to the portal within a minute.
if (
query.state.data?.review?.status === "pending" ||
query.state.data?.company?.companyProfiles?.find(
(p) => p.status !== "active",
)
)
return 60;
return 10 * 60 * 1000;
},
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}), }),
); );
@@ -166,6 +180,14 @@ const useAuth = () => {
const activeProfileStatus = activeProfile?.status ?? null; const activeProfileStatus = activeProfile?.status ?? null;
const canBook = activeProfileStatus === "active"; const canBook = activeProfileStatus === "active";
// Profile-edit review: while a change request is pending the customer is
// locked out of editing and of creating new contracts/bookings; a rejected
// request surfaces the reviewer note so they can amend and resubmit.
const review = companyInfo?.review ?? null;
const reviewStatus = review?.status ?? null;
const reviewNote = review?.note ?? null;
const isUnderReview = reviewStatus === "pending";
/** Refetch everything scoped to the active operational profile. */ /** Refetch everything scoped to the active operational profile. */
const invalidateScopedData = async () => { const invalidateScopedData = async () => {
await Promise.all([ await Promise.all([
@@ -179,9 +201,7 @@ const useAuth = () => {
]); ]);
}; };
const switchMode = async ( const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
type: ProfileTypeValue,
): Promise<Result<void>> => {
try { try {
await api.companies.setActiveMode.call({ type }); await api.companies.setActiveMode.call({ type });
await invalidateScopedData(); await invalidateScopedData();
@@ -207,6 +227,19 @@ const useAuth = () => {
} }
}; };
/** Resubmit a rejected operational role for approval, then refresh. */
const reapplyProfile = async (
profileId: string,
): Promise<Result<void>> => {
try {
await api.companies.reapplyProfile.call({ profileId });
await invalidateScopedData();
return { success: true, data: undefined };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const logout = async () => { const logout = async () => {
try { try {
await api.auth.logout.call(); await api.auth.logout.call();
@@ -239,10 +272,14 @@ const useAuth = () => {
companyType, companyType,
companyStatus, companyStatus,
isCompanyApproved, isCompanyApproved,
reviewStatus,
reviewNote,
isUnderReview,
onboardingCompleted, onboardingCompleted,
onboardingStep, onboardingStep,
switchMode, switchMode,
createProfileAndSwitch, createProfileAndSwitch,
reapplyProfile,
login, login,
signup, signup,
setPassword, setPassword,

View File

@@ -1,11 +1,13 @@
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import { import {
Alert,
Badge, Badge,
Box, Box,
Card, Card,
Center, Center,
Container, Container,
Fieldset,
Group, Group,
Loader, Loader,
Stack, Stack,
@@ -17,9 +19,11 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
AlertTriangle,
BadgeCheck, BadgeCheck,
Briefcase, Briefcase,
Building2, Building2,
Clock,
FileCheck, FileCheck,
Globe, Globe,
ShieldCheck, ShieldCheck,
@@ -225,6 +229,9 @@ export default function SettingsPage() {
); );
} }
const reviewStatus = profile.reviewStatus ?? null;
const locked = reviewStatus === "pending";
return ( return (
<Container size="xl" px="lg" py="xl"> <Container size="xl" px="lg" py="xl">
<Stack gap="xl"> <Stack gap="xl">
@@ -239,6 +246,39 @@ export default function SettingsPage() {
</Text> </Text>
</div> </div>
{reviewStatus === "pending" && (
<Alert
color="yellow"
variant="light"
icon={<Clock size={18} />}
title="Changes submitted for review"
>
Your recent changes are awaiting administrator approval. Editing is
disabled until the review is complete you'll be notified once it's
approved or if any changes are requested.
</Alert>
)}
{reviewStatus === "rejected" && (
<Alert
color="red"
variant="light"
icon={<AlertTriangle size={18} />}
title="Changes were not approved"
>
<Stack gap={4}>
{profile.reviewNote && (
<Text size="sm">
<strong>Reviewer note:</strong> {profile.reviewNote}
</Text>
)}
<Text size="sm">
Please update the details below and save again to resubmit for
review.
</Text>
</Stack>
</Alert>
)}
<Tabs <Tabs
value={tab} value={tab}
onChange={(value) => value && setTab(value as SettingsTab)} onChange={(value) => value && setTab(value as SettingsTab)}
@@ -269,20 +309,33 @@ export default function SettingsPage() {
))} ))}
</Tabs.List> </Tabs.List>
{/* While a change request is pending, every panel's inputs + submit
buttons are disabled via the native fieldset; tab switching stays
enabled so the customer can still review what they submitted. */}
<Tabs.Panel value="company"> <Tabs.Panel value="company">
<TabCompanyProfile mode="edit" profile={profile} /> <Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} />
</Fieldset>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="contact"> <Tabs.Panel value="contact">
<TabContactPerson profile={profile} mode="edit" /> <Fieldset disabled={locked} variant="unstyled" p={0}>
<TabContactPerson profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="gm"> <Tabs.Panel value="gm">
<TabGeneralManager profile={profile} mode="edit" /> <Fieldset disabled={locked} variant="unstyled" p={0}>
<TabGeneralManager profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="poa"> <Tabs.Panel value="poa">
<TabPowerOfAttorney profile={profile} mode="edit" /> <Fieldset disabled={locked} variant="unstyled" p={0}>
<TabPowerOfAttorney profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="documents"> <Tabs.Panel value="documents">
<TabDocuments profile={profile} mode="edit" /> <Fieldset disabled={locked} variant="unstyled" p={0}>
<TabDocuments profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
</Stack> </Stack>

View File

@@ -87,7 +87,7 @@ export function StepDocuments({ form }: { form: BookingForm }) {
</Text> </Text>
{onboardingDocs.map((doc, i) => ( {onboardingDocs.map((doc, i) => (
<Group <Group
key={`${doc.url}-${i}`} key={`${doc.id}-${i}`}
gap={12} gap={12}
align="center" align="center"
wrap="nowrap" wrap="nowrap"

View File

@@ -144,6 +144,18 @@ export default function NewContractPage({
); );
} }
// Profile changes pending review lock out new contract creation too.
if (!auth.isPending && auth.isUnderReview) {
return (
<GateNotice
title="Profile Changes Under Review"
body="Your recent profile changes are awaiting administrator approval. Creating contracts is paused until the review is complete."
actionLabel="Back to Contracts"
onAction={() => navigate("/contracts")}
/>
);
}
const [pricingData, setPricingData] = const [pricingData, setPricingData] =
useState<GenerateContractPriceResponse | null>(null); useState<GenerateContractPriceResponse | null>(null);
// In edit mode the contract already exists, so seed its id — this makes // In edit mode the contract already exists, so seed its id — this makes
@@ -191,14 +203,18 @@ export default function NewContractPage({
contractId = contract.id; contractId = contract.id;
} }
const pricing = await api.contracts.generatePrice.call({ id: contractId }); const pricing = await api.contracts.generatePrice.call({
id: contractId,
});
return { contractId, pricing, mode }; return { contractId, pricing, mode };
}, },
onSuccess: ({ contractId, pricing, mode }) => { onSuccess: ({ contractId, pricing, mode }) => {
setPriceContractId(contractId); setPriceContractId(contractId);
setPricingData(pricing); setPricingData(pricing);
setPriceModalMode(mode); setPriceModalMode(mode);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() }); queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
}, },
}); });
@@ -214,7 +230,9 @@ export default function NewContractPage({
} }
clearContractDraft(); clearContractDraft();
setPriceModalMode(null); setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() }); queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
navigate("/contracts"); navigate("/contracts");
}, },
}); });
@@ -228,7 +246,9 @@ export default function NewContractPage({
clearContractDraft(); clearContractDraft();
setPriceChangeResult(null); setPriceChangeResult(null);
setPriceModalMode(null); setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() }); queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
navigate("/contracts"); navigate("/contracts");
}, },
}); });
@@ -242,7 +262,9 @@ export default function NewContractPage({
clearContractDraft(); clearContractDraft();
setPriceModalMode(null); setPriceModalMode(null);
setPriceContractId(null); setPriceContractId(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() }); queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
navigate("/contracts"); navigate("/contracts");
}, },
}); });
@@ -325,6 +347,17 @@ export default function NewContractPage({
m.set(p.type, p.status); m.set(p.type, p.status);
return m; return m;
}, [auth.company]); }, [auth.company]);
// Full profile per type, so the awaiting/rejected modal can show the reviewer
// note and offer a reapply for a rejected role.
const profileByType = useMemo(() => {
const m = new Map<
string,
{ id: string; status: string; reviewNote?: string | null }
>();
for (const p of auth.company?.company?.companyProfiles ?? [])
m.set(p.type, { id: p.id, status: p.status, reviewNote: p.reviewNote });
return m;
}, [auth.company]);
const profileTypes = useMemo( const profileTypes = useMemo(
() => [...profileStatusByType.keys()], () => [...profileStatusByType.keys()],
[profileStatusByType], [profileStatusByType],
@@ -343,12 +376,14 @@ export default function NewContractPage({
// badges. Intercity rides any customer profile, so always "approved". // badges. Intercity rides any customer profile, so always "approved".
const operationStatus = useMemo( const operationStatus = useMemo(
() => () =>
(op: OperationType): "approved" | "pending" | "missing" => { (op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
if (op === "intercity") return "approved"; if (op === "intercity") return "approved";
const target = operationToProfileType(op, profileTypes); const target = operationToProfileType(op, profileTypes);
const status = profileStatusByType.get(target); const status = profileStatusByType.get(target);
if (!status) return "missing"; if (!status) return "missing";
return status === "active" ? "approved" : "pending"; if (status === "active") return "approved";
if (status === "rejected") return "rejected";
return "pending";
}, },
[profileStatusByType, profileTypes], [profileStatusByType, profileTypes],
); );
@@ -398,6 +433,17 @@ export default function NewContractPage({
}, },
}); });
// Resubmit a rejected operational role for approval (from the block modal).
const reapplyMutation = useMutation({
mutationFn: async (profileId: string) => {
const res = await auth.reapplyProfile(profileId);
if (!res.success) {
throw new Error(res.error?.message ?? "Failed to resubmit for approval");
}
},
onSuccess: () => setPendingApprovalProfile(null),
});
const handleOperationSelect = (op: OperationType) => { const handleOperationSelect = (op: OperationType) => {
// Intercity (domestic) runs on any existing customer profile — no switch. // Intercity (domestic) runs on any existing customer profile — no switch.
if (op === "intercity") return; if (op === "intercity") return;
@@ -551,23 +597,23 @@ export default function NewContractPage({
: {}), : {}),
...(serviceType?.includesFirstMile && data.firstMile.enabled ...(serviceType?.includesFirstMile && data.firstMile.enabled
? { ? {
firstMilePickupAddress: data.firstMile.pickUpAddress, firstMilePickupAddress: data.firstMile.pickUpAddress,
firstMilePickupLat: data.firstMile.lat ?? undefined, firstMilePickupLat: data.firstMile.lat ?? undefined,
firstMilePickupLng: data.firstMile.lng ?? undefined, firstMilePickupLng: data.firstMile.lng ?? undefined,
} }
: {}), : {}),
...(serviceType?.includesLastMile && data.lastMile.enabled ...(serviceType?.includesLastMile && data.lastMile.enabled
? { ? {
lastMileDeliveryAddress: data.lastMile.deliveryAddress, lastMileDeliveryAddress: data.lastMile.deliveryAddress,
lastMileDeliveryLat: data.lastMile.lat ?? undefined, lastMileDeliveryLat: data.lastMile.lat ?? undefined,
lastMileDeliveryLng: data.lastMile.lng ?? undefined, lastMileDeliveryLng: data.lastMile.lng ?? undefined,
} }
: {}), : {}),
...(serviceType?.includesCustoms && data.customsClearingEnabled ...(serviceType?.includesCustoms && data.customsClearingEnabled
? { ? {
customsClearingEnabled: true, customsClearingEnabled: true,
customsClearingAgent: data.customsClearingAgent || undefined, customsClearingAgent: data.customsClearingAgent || undefined,
} }
: { customsClearingEnabled: false }), : { customsClearingEnabled: false }),
cargoScope, cargoScope,
routes, routes,
@@ -652,7 +698,12 @@ export default function NewContractPage({
mb="lg" mb="lg"
> >
<Box> <Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}> <Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
{isEdit ? "Edit Contract" : "New Contract"} {isEdit ? "Edit Contract" : "New Contract"}
</Title> </Title>
<Text size="sm" c="edr-muted" mt={4}> <Text size="sm" c="edr-muted" mt={4}>
@@ -791,12 +842,12 @@ export default function NewContractPage({
errors={ errors={
showDocErrors showDocErrors
? Object.fromEntries( ? Object.fromEntries(
missingRequiredDocKeys( missingRequiredDocKeys(
editDocSettingQuery.data, editDocSettingQuery.data,
editContract, editContract,
editDocuments, editDocuments,
).map((k) => [k, "Required"]), ).map((k) => [k, "Required"]),
) )
: {} : {}
} }
/> />
@@ -814,9 +865,9 @@ export default function NewContractPage({
pricing={ pricing={
pricingData pricingData
? { ? {
currency: pricingData.currency, currency: pricingData.currency,
lineItems: pricingData.lineItems, lineItems: pricingData.lineItems,
} }
: null : null
} }
onSaveDraft={handleSaveDraft} onSaveDraft={handleSaveDraft}
@@ -926,8 +977,8 @@ export default function NewContractPage({
Pricing schedule Pricing schedule
</Text> </Text>
<Text size="xs" c="dimmed" mb="md"> <Text size="xs" c="dimmed" mb="md">
Final amount is calculated at booking quantities are unknown at Final amount is calculated at booking quantities are unknown
the contract stage. at the contract stage.
</Text> </Text>
<Stack gap={10}> <Stack gap={10}>
{pricingData.lineItems.map((item) => ( {pricingData.lineItems.map((item) => (
@@ -1087,7 +1138,7 @@ export default function NewContractPage({
loading={confirmSubmitMutation.isPending} loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()} onClick={() => confirmSubmitMutation.mutate()}
> >
Confirm &amp; submit Confirm {"&"} submit
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -1131,9 +1182,9 @@ export default function NewContractPage({
<Check size={18} /> <Check size={18} />
</Box> </Box>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
License submitted. Your {createTargetLabel.toLowerCase()} profile License submitted. Your {createTargetLabel.toLowerCase()}{" "}
is now awaiting staff approval. We'll notify you once it's profile is now awaiting staff approval. We'll notify you once
approved then you can create this contract as{" "} it's approved then you can create this contract as{" "}
{createTargetLabel.toLowerCase()}. {createTargetLabel.toLowerCase()}.
</Text> </Text>
</Group> </Group>
@@ -1146,9 +1197,9 @@ export default function NewContractPage({
) : ( ) : (
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add You don't have a {createTargetLabel.toLowerCase()} profile yet.
your business license to create one. It goes to staff for approval Add your business license to create one. It goes to staff for
before you can use it. approval before you can use it.
</Text> </Text>
<FileInput <FileInput
label="Business license" label="Business license"
@@ -1181,35 +1232,78 @@ export default function NewContractPage({
)} )}
</Modal> </Modal>
{/* Awaiting-approval modal — the chosen operation maps to a profile that {/* Awaiting-approval / rejected modal — the chosen operation maps to a
exists but isn't approved yet. The select was already reverted. */} profile that exists but isn't active. The select was already reverted. */}
<Modal {(() => {
opened={pendingApprovalProfile !== null} const target = pendingApprovalProfile
onClose={() => setPendingApprovalProfile(null)} ? profileByType.get(pendingApprovalProfile)
title="Awaiting approval" : undefined;
centered const isRejected = target?.status === "rejected";
radius="lg" const label = pendingApprovalProfile
> ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
<Stack gap="md"> pendingApprovalProfile)
<Text size="sm" c="dimmed"> : "";
Your{" "} return (
{pendingApprovalProfile <Modal
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ?? opened={pendingApprovalProfile !== null}
pendingApprovalProfile) onClose={() => setPendingApprovalProfile(null)}
: ""}{" "} title={isRejected ? "Profile not approved" : "Awaiting approval"}
profile was submitted and is under staff review. You can start a centered
contract under it once it's approved. radius="lg"
</Text> >
<Group justify="flex-end" gap="sm"> <Stack gap="md">
<Button {isRejected ? (
color="edr-green" <>
onClick={() => setPendingApprovalProfile(null)} <Text size="sm" c="dimmed">
> Your {label} profile was not approved. Fix the issue below
OK and resubmit it for review.
</Button> </Text>
</Group> {target?.reviewNote && (
</Stack> <Alert color="red" variant="light" radius="md">
</Modal> <Text size="sm">
<strong>Reviewer note:</strong> {target.reviewNote}
</Text>
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setPendingApprovalProfile(null)}
disabled={reapplyMutation.isPending}
>
Close
</Button>
<Button
color="edr-green"
loading={reapplyMutation.isPending}
onClick={() =>
target && reapplyMutation.mutate(target.id)
}
>
Resubmit for approval
</Button>
</Group>
</>
) : (
<>
<Text size="sm" c="dimmed">
Your {label} profile was submitted and is under staff
review. You can start a contract under it once it's approved.
</Text>
<Group justify="flex-end" gap="sm">
<Button
color="edr-green"
onClick={() => setPendingApprovalProfile(null)}
>
OK
</Button>
</Group>
</>
)}
</Stack>
</Modal>
);
})()}
</Box> </Box>
); );
} }

View File

@@ -156,7 +156,7 @@ export function StepDocuments({
</Text> </Text>
{onboardingDocs.map((doc, i) => ( {onboardingDocs.map((doc, i) => (
<Group <Group
key={`${doc.url}-${i}`} key={`${doc.id}-${i}`}
gap={12} gap={12}
align="center" align="center"
wrap="nowrap" wrap="nowrap"

View File

@@ -43,7 +43,9 @@ export function Step1ContractType({
allowedOperations: OperationType[]; allowedOperations: OperationType[];
onOperationSelect?: (op: OperationType) => void; onOperationSelect?: (op: OperationType) => void;
/** Approval state of the profile each operation maps to (for the badges). */ /** Approval state of the profile each operation maps to (for the badges). */
operationStatus?: (op: OperationType) => "approved" | "pending" | "missing"; operationStatus?: (
op: OperationType,
) => "approved" | "pending" | "rejected" | "missing";
}) { }) {
const contractType = form.watch("contractType"); const contractType = form.watch("contractType");
@@ -221,6 +223,11 @@ export function Step1ContractType({
Pending Pending
</Badge> </Badge>
)} )}
{status === "rejected" && (
<Badge size="xs" color="red" variant="light" radius="sm">
Rejected
</Badge>
)}
{status === "missing" && ( {status === "missing" && (
<Badge size="xs" color="gray" variant="light" radius="sm"> <Badge size="xs" color="gray" variant="light" radius="sm">
Add license Add license

View File

@@ -1,11 +1,21 @@
import { fileViewUrl } from "@/constants/apiConfig"; import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service"; import {
companiesService,
type CompanyProfileResponse,
type LicenseFileStatus,
} from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings"; import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput, useFileViewer } from "@edr/ui-common";
import { import {
// Anchor, SmartFileInput,
useFileViewer,
type ViewableFile,
} from "@edr/ui-common";
import {
ActionIcon,
Anchor,
Badge,
Button, Button,
Card, Card,
Center, Center,
@@ -13,18 +23,23 @@ import {
Stack, Stack,
Text, Text,
Title, Title,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowRight, ArrowRight,
CheckCircle2, CheckCircle2,
Clock,
FileCheck, FileCheck,
FileText,
Loader2, Loader2,
Paperclip, Paperclip,
RefreshCw,
Trash2,
UploadCloud, UploadCloud,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useRef, useState } from "react";
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
importer: "Importer", importer: "Importer",
@@ -137,9 +152,7 @@ export default function TabDocuments({
return errs; return errs;
}; };
const licenseProfiles = profile.companyProfiles.filter( const licenseProfiles = profile.companyProfiles;
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
return ( return (
<> <>
@@ -246,24 +259,19 @@ export default function TabDocuments({
<Title order={3}>Business licenses</Title> <Title order={3}>Business licenses</Title>
</Group> </Group>
<Text c="edr-muted" size="sm" mb="lg"> <Text c="edr-muted" size="sm" mb="lg">
License documents uploaded per operational profile Add, replace or remove the license documents for each operational
profile. Changes are submitted to EDR for review before they take
effect.
</Text> </Text>
<Stack gap="md"> <Stack gap="xl">
{licenseProfiles.map((p) => ( {licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}> <ProfileLicenseRow
<Text size="sm" fw={600} c="edr-text"> key={p.id}
{ROLE_LABELS[p.type] ?? p.type} · {p.reference} profile={p}
</Text> onViewFile={view}
{p.licenseFiles.map((f) => ( reviewPending={profile.reviewStatus === "pending"}
<Group key={f.url} gap={6} wrap="nowrap"> />
<Paperclip size={13} className="text-edr-muted" />
<Text component="button" type="button" size="xs">
{f.name}
</Text>
</Group>
))}
</Stack>
))} ))}
</Stack> </Stack>
</Card> </Card>
@@ -273,3 +281,263 @@ export default function TabDocuments({
</> </>
); );
} }
const LICENSE_ACCEPT = ".pdf,.png,.jpg,.jpeg";
function formatBytes(bytes: number): string {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(1))} ${units[i]}`;
}
const STATUS_BADGE: Record<
LicenseFileStatus,
{ label: string; color: string; bg: string; fg: string } | null
> = {
live: null,
pending_add: {
label: "Pending approval",
color: "edr-amber",
bg: "var(--mantine-color-edr-amber-soft-0)",
fg: "var(--mantine-color-edr-amber-text-0)",
},
pending_remove: {
label: "Removal pending",
color: "edr-red",
bg: "var(--mantine-color-edr-red-soft-0)",
fg: "var(--mantine-color-edr-red-0)",
},
};
/**
* One operational profile's business-license documents. Lists each file (click
* to preview via the file proxy) with its review state, and lets the customer
* add / replace / remove files. Every mutation opens a change request the
* backoffice must approve; while one is open the parent locks this whole tab.
*/
function ProfileLicenseRow({
profile,
onViewFile,
reviewPending,
}: {
profile: CompanyProfileResponse;
onViewFile: (file: ViewableFile) => void;
reviewPending: boolean;
}) {
const queryClient = useQueryClient();
const addInputRef = useRef<HTMLInputElement>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
const replaceTargetId = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const invalidate = () => {
setError(null);
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
};
const addMutation = useMutation({
mutationFn: (files: File[]) =>
companiesService.uploadProfileLicense(profile.id, files),
onSuccess: invalidate,
onError: () => setError("Upload failed. Please try again."),
});
const replaceMutation = useMutation({
mutationFn: ({ fileId, file }: { fileId: string; file: File }) =>
companiesService.replaceProfileLicense(profile.id, fileId, file),
onSuccess: invalidate,
onError: () => setError("Replace failed. Please try again."),
});
const removeMutation = useMutation({
mutationFn: (fileId: string) =>
companiesService.removeProfileLicense(profile.id, fileId),
onSuccess: invalidate,
onError: () => setError("Remove failed. Please try again."),
});
const busy =
addMutation.isPending ||
replaceMutation.isPending ||
removeMutation.isPending;
const files = profile.licenseFiles ?? [];
return (
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text size="sm" fw={700} c="edr-text">
{ROLE_LABELS[profile.type] ?? profile.type} · {profile.reference}
</Text>
<Button
variant="light"
size="xs"
leftSection={<UploadCloud size={14} />}
loading={addMutation.isPending}
disabled={busy}
onClick={() => addInputRef.current?.click()}
>
Add document
</Button>
</Group>
{files.length === 0 ? (
<Card
padding="md"
radius="md"
style={{
borderStyle: "dashed",
backgroundColor: "var(--mantine-color-edr-bg-0)",
}}
>
<Text size="sm" c="edr-muted" ta="center">
No license documents yet.
</Text>
</Card>
) : (
<Stack gap="xs">
{files.map((f) => {
const badge = STATUS_BADGE[f.status];
const isPending = f.status !== "live";
return (
<Card
key={f.id}
padding="sm"
radius="md"
withBorder
style={{ backgroundColor: "var(--mantine-color-edr-card-0)" }}
>
<Group gap="sm" wrap="nowrap">
<FileText
size={18}
className="text-edr-muted"
style={{ flexShrink: 0 }}
/>
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
<Anchor
component="button"
type="button"
size="sm"
fw={600}
onClick={() =>
onViewFile({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
style={{
textAlign: "left",
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
lineClamp={1}
>
{f.name}
</Anchor>
{f.size > 0 && (
<Text size="xs" c="edr-muted">
{formatBytes(f.size)}
</Text>
)}
</Stack>
{badge && (
<Badge
size="sm"
radius="sm"
variant="light"
leftSection={<Clock size={11} />}
style={{
backgroundColor: badge.bg,
color: badge.fg,
flexShrink: 0,
}}
>
{badge.label}
</Badge>
)}
<Tooltip label="Replace" withArrow>
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Replace ${f.name}`}
disabled={busy || isPending}
onClick={() => {
replaceTargetId.current = f.id;
replaceInputRef.current?.click();
}}
>
<RefreshCw size={15} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove" withArrow>
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove ${f.name}`}
disabled={busy || isPending}
loading={
removeMutation.isPending &&
removeMutation.variables === f.id
}
onClick={() => removeMutation.mutate(f.id)}
>
<Trash2 size={15} />
</ActionIcon>
</Tooltip>
</Group>
</Card>
);
})}
</Stack>
)}
{reviewPending && (
<Group gap={6} c="edr-amber-text">
<Clock size={13} />
<Text size="xs" fw={500}>
Awaiting EDR review further changes are disabled until it clears.
</Text>
</Group>
)}
{error && (
<Group gap={6} c="red">
<XCircle size={13} />
<Text size="xs" fw={500}>
{error}
</Text>
</Group>
)}
<input
ref={addInputRef}
type="file"
multiple
accept={LICENSE_ACCEPT}
style={{ display: "none" }}
onChange={(e) => {
const picked = e.target.files ? Array.from(e.target.files) : [];
if (picked.length > 0) addMutation.mutate(picked);
e.target.value = "";
}}
/>
<input
ref={replaceInputRef}
type="file"
accept={LICENSE_ACCEPT}
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
const fileId = replaceTargetId.current;
if (file && fileId) replaceMutation.mutate({ fileId, file });
replaceTargetId.current = null;
e.target.value = "";
}}
/>
</Stack>
);
}

View File

@@ -54,6 +54,7 @@ import {
UpdateDropdownSettingDto, UpdateDropdownSettingDto,
} from "@/types/dropdownSettings"; } from "@/types/dropdownSettings";
import type { import type {
ChangeRequestResponse,
CompanyDocument, CompanyDocument,
CompanyInfoResponse, CompanyInfoResponse,
CompanyNationality, CompanyNationality,
@@ -211,6 +212,18 @@ export const api = {
"documents", "documents",
({ companyId }) => companiesService.getDocuments(companyId), ({ companyId }) => companiesService.getDocuments(companyId),
), ),
changeRequest: endpoint<void, ChangeRequestResponse | null>(
"companies",
"changeRequest",
companiesService.getChangeRequest,
),
reapplyProfile: endpoint<{ profileId: string }, CompanyProfileResponse>(
"companies",
"reapplyProfile",
({ profileId }) => companiesService.reapplyProfile(profileId),
),
}, },
bookings: { bookings: {

View File

@@ -14,11 +14,16 @@ export type ProfileTypeValue =
export type CompanyNationality = "ethiopian" | "foreign"; export type CompanyNationality = "ethiopian" | "foreign";
/** Review state of a business-license file (mirrors the API's ProfileLicenseFileView). */
export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
export interface LicenseFile { export interface LicenseFile {
id: string;
name: string; name: string;
url: string;
size: number; size: number;
mimeType?: string; mimeType: string;
/** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */
status: LicenseFileStatus;
} }
export interface ExternalProfileResponse { export interface ExternalProfileResponse {
@@ -73,6 +78,8 @@ export interface CompanyProfileResponse {
/** Business-license documents uploaded for this profile. */ /** Business-license documents uploaded for this profile. */
licenseFiles: LicenseFile[]; licenseFiles: LicenseFile[];
attributes: Record<string, any> | null; attributes: Record<string, any> | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -80,6 +87,28 @@ export interface CompanyProfileResponse {
export interface CompanyInfoResponse { export interface CompanyInfoResponse {
profile: ExternalProfileResponse; profile: ExternalProfileResponse;
company: CompanyResponse; company: CompanyResponse;
/**
* Open profile-edit review, if any. `pending` locks the settings page + new
* contract/booking creation; `rejected` surfaces the note for reapply.
*/
review?: {
status: "pending" | "rejected";
note: string | null;
} | null;
}
/** A staged profile-edit review request (portal view). */
export interface ChangeRequestResponse {
id: string;
companyId: string;
status: "pending" | "approved" | "rejected";
snapshot: Record<string, any>;
documentFileIds: string[];
note: string | null;
submittedAt: string | null;
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
} }
/** A single company-level document uploaded against a `file_upload_settings` field. */ /** A single company-level document uploaded against a `file_upload_settings` field. */
@@ -328,7 +357,11 @@ export const companiesService = {
return unwrap(response.data); return unwrap(response.data);
}, },
/** Upload business-license document(s) for a company profile (multi-file). */ /**
* Add business-license document(s) to a company profile. For an approved
* company the upload is staged for backoffice review; during onboarding it
* goes live immediately. Returns the profile's full license list with state.
*/
uploadProfileLicense: async ( uploadProfileLicense: async (
profileId: string, profileId: string,
files: File[], files: File[],
@@ -343,7 +376,33 @@ export const companiesService = {
return unwrap(response.data); return unwrap(response.data);
}, },
/** List business-license document(s) already uploaded for a company profile. */ /** Replace a license file with a newly uploaded one (staged for review). */
replaceProfileLicense: async (
profileId: string,
fileId: string,
file: File,
): Promise<LicenseFile[]> => {
const formData = new FormData();
formData.append("business_license", file);
const response = await client.post<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_REPLACE(profileId, fileId),
formData,
);
return unwrap(response.data);
},
/** Remove a license file (staged for review on an approved company). */
removeProfileLicense: async (
profileId: string,
fileId: string,
): Promise<LicenseFile[]> => {
const response = await client.delete<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_FILE(profileId, fileId),
);
return unwrap(response.data);
},
/** List business-license document(s) (with review state) for a company profile. */
getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => { getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
const response = await client.get<ApiResponse<LicenseFile[]>>( const response = await client.get<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId), URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
@@ -351,6 +410,24 @@ export const companiesService = {
return unwrap(response.data); return unwrap(response.data);
}, },
/** The current company's open profile change request (pending/rejected), or null. */
getChangeRequest: async (): Promise<ChangeRequestResponse | null> => {
const response = await client.get<ApiResponse<ChangeRequestResponse | null>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST,
);
return unwrap(response.data);
},
/** Resubmit a rejected operational role for approval (→ pending). */
reapplyProfile: async (
profileId: string,
): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_REAPPLY(profileId),
);
return unwrap(response.data);
},
/** Fetch company registration data from eTrade by TIN. */ /** Fetch company registration data from eTrade by TIN. */
fetchETradeInfo: async (payload: { tin: string }): Promise<any> => { fetchETradeInfo: async (payload: { tin: string }): Promise<any> => {
const response = await client.post<ApiResponse<any>>( const response = await client.post<ApiResponse<any>>(

View File

@@ -40,6 +40,14 @@ export interface ProfileResponse {
poaLocation: string | null; poaLocation: string | null;
poaAddress: string | null; poaAddress: string | null;
profileId: string; profileId: string;
/**
* Open profile-edit review. `pending` → the settings page is read-only until an
* admin decides; `rejected` → the note explains why and the forms prefill the
* declined values so the customer can amend & resubmit.
*/
reviewStatus?: "pending" | "rejected" | null;
reviewNote?: string | null;
pendingChanges?: Record<string, any> | null;
} }
export interface UpdateProfilePayload { export interface UpdateProfilePayload {

View File

@@ -767,9 +767,9 @@ export function SmartFileInput({
)} )}
</div> </div>
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex"> <span className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-primary/40 bg-card px-3 py-1.5 text-xs font-semibold text-primary shadow-2xs transition group-hover:border-primary group-hover:bg-primary/5">
<UploadCloud className="h-3.5 w-3.5" /> <UploadCloud className="h-3.5 w-3.5" />
Replace Replace file
</span> </span>
</div> </div>
) : ( ) : (