mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: fix the settings preview for the business licence and changes request to the approval
This commit is contained in:
@@ -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';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 & 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>>(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user