mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Backoffice can now "Request changes" on a pending settings change request without rejecting it outright: a new ChangesRequested status keeps the row open so the customer's next edit appends into the same request instead of starting a fresh cycle, and the reviewer's note persists across that round instead of being cleared on resubmit. Version History and Review History (previously two separate, differently-shaped lists) are merged into one chronological timeline under a new History tab, including document changes shown as a real previous-vs-current diff (both files openable). Bug fixes surfaced while wiring this up: - Replacing a single-file document slot left the old file live alongside the new one instead of retiring it (customer settings + onboarding uploads). - The "previous" file in a document diff 404'd once superseded — the preview route now also matches soft-deleted records. - A document replace was recorded twice in the timeline (once at upload, once again at change-request approval).
488 lines
15 KiB
TypeScript
488 lines
15 KiB
TypeScript
import { client } from "@/utils/api";
|
|
import { unwrap } from "@/utils/endpoint";
|
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
|
import type { ApiResponse } from "@/types/apiResponse";
|
|
import type { CompanyIdentityState } from "./verifayda.service";
|
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
|
import { isAxiosError } from "axios";
|
|
|
|
export type ProfileTypeValue =
|
|
| "importer"
|
|
| "exporter"
|
|
| "freight_forwarder"
|
|
| "dj_freight_forwarder"
|
|
| "transporter";
|
|
|
|
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 {
|
|
id: string;
|
|
name: string;
|
|
size: number;
|
|
mimeType: string;
|
|
/** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */
|
|
status: LicenseFileStatus;
|
|
/**
|
|
* A reviewer's verdict on this specific document. `change_requested` means the
|
|
* customer must upload a corrected version before the role can be approved —
|
|
* orthogonal to `status`, which tracks the staged add/remove workflow.
|
|
*/
|
|
reviewStatus?: "change_requested" | "approved" | null;
|
|
/** The reviewer's reason, shown to the customer verbatim. */
|
|
reviewNote?: string | null;
|
|
}
|
|
|
|
export interface ExternalProfileResponse {
|
|
id: string;
|
|
userId: string;
|
|
companyId: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
phone: string | null;
|
|
nationalId: string | null;
|
|
jobTitle: string | null;
|
|
isPrimaryContact: boolean;
|
|
onboardingStep: string | null;
|
|
onboardingCompleted: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface CompanyResponse {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
status: string;
|
|
nationality: CompanyNationality | null;
|
|
tin: string;
|
|
vatNumber: string | null;
|
|
businessLicense: string | null;
|
|
fanNumber: string | null;
|
|
country: string;
|
|
address: string | null;
|
|
phone: string | null;
|
|
email: string | null;
|
|
website: string | null;
|
|
attributes: Record<string, any> | null;
|
|
companyProfiles?: CompanyProfileResponse[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface CompanyProfileResponse {
|
|
id: string;
|
|
type: string;
|
|
reference: string;
|
|
status: string;
|
|
/** @deprecated Superseded by licenseFiles (file model). */
|
|
businessLicense: string | null;
|
|
/** Business-license documents uploaded for this profile. */
|
|
licenseFiles: LicenseFile[];
|
|
attributes: Record<string, any> | null;
|
|
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
|
reviewNote?: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface CompanyInfoResponse {
|
|
profile: ExternalProfileResponse;
|
|
company: CompanyResponse;
|
|
/**
|
|
* Open profile-edit review, if any. `pending` locks the settings page + new
|
|
* contract/booking creation; `rejected`/`changes_requested` both surface the
|
|
* note for reapply — `changes_requested` just means the edit appends to the
|
|
* same request instead of starting a fresh one.
|
|
*/
|
|
review?: {
|
|
status: "pending" | "rejected" | "changes_requested";
|
|
note: string | null;
|
|
} | null;
|
|
}
|
|
|
|
/** A staged profile-edit review request (portal view). */
|
|
export interface ChangeRequestResponse {
|
|
id: string;
|
|
companyId: string;
|
|
status: "pending" | "approved" | "rejected" | "changes_requested";
|
|
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. */
|
|
export interface CompanyDocument {
|
|
id: string;
|
|
name: string;
|
|
/** The `fileKey` of the setting field it was uploaded against. */
|
|
code: string;
|
|
mimeType: string;
|
|
size: number;
|
|
uploadedAt: string;
|
|
url: string;
|
|
/**
|
|
* `change_requested` means a reviewer has asked for a corrected version of
|
|
* this document; the role cannot be approved until it is re-uploaded.
|
|
*/
|
|
reviewStatus?: "change_requested" | "approved" | null;
|
|
/** The reviewer's reason, shown to the customer verbatim. */
|
|
reviewNote?: string | null;
|
|
}
|
|
|
|
/** A single onboarding document field, as resolved and described by the backend. */
|
|
export interface OnboardingDocumentField {
|
|
fileKey: string;
|
|
fileLabel: string;
|
|
helpText: string | null;
|
|
isRequired: boolean;
|
|
isMultiple: boolean;
|
|
maxFiles: number;
|
|
allowedExtensions: string[];
|
|
maxSizeMb: number;
|
|
displayOrder: number;
|
|
uploaded: boolean;
|
|
}
|
|
|
|
export interface OnboardingLicenseProfile {
|
|
profileId: string;
|
|
type: string;
|
|
reference: string;
|
|
uploaded: boolean;
|
|
}
|
|
|
|
/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */
|
|
export interface OnboardingPoaState {
|
|
required: boolean;
|
|
provided: boolean;
|
|
delegationLetterUploaded: boolean;
|
|
/** True when a reviewer sent the DARS delegation paper back for correction. */
|
|
delegationLetterFlagged: boolean;
|
|
missingFields: { key: string; label: string }[];
|
|
complete: boolean;
|
|
}
|
|
|
|
/**
|
|
* Server-driven onboarding requirements. The portal renders this verbatim: the
|
|
* backend decides which documents apply (by nationality) and what is still
|
|
* outstanding, so the client never hardcodes required fields or document sets.
|
|
*/
|
|
export interface OnboardingRequirements {
|
|
documentSettingCode: string;
|
|
nationality: string;
|
|
companyInfo: {
|
|
complete: boolean;
|
|
missingFields: { key: string; label: string }[];
|
|
};
|
|
documents: OnboardingDocumentField[];
|
|
licenseProfiles: OnboardingLicenseProfile[];
|
|
poa: OnboardingPoaState;
|
|
/** Fayda verification state; `required` is false for a foreign company. */
|
|
identity: CompanyIdentityState;
|
|
progress: { completed: number; total: number };
|
|
isComplete: boolean;
|
|
onboardingCompleted: boolean;
|
|
outstanding: string[];
|
|
}
|
|
|
|
export interface CompanyProfileInput {
|
|
type:
|
|
| "importer"
|
|
| "exporter"
|
|
| "freight_forwarder"
|
|
| "dj_freight_forwarder"
|
|
| "transporter";
|
|
businessLicense?: string;
|
|
}
|
|
|
|
export interface CreateCompanyPayload {
|
|
companyType?: string;
|
|
nationality?: CompanyNationality;
|
|
companyName: string;
|
|
companyEmail?: string;
|
|
companyPhone?: string;
|
|
companyLocation?: string;
|
|
companyAddress?: string;
|
|
tin?: string;
|
|
vatNumber?: string;
|
|
fanNumber?: string;
|
|
jobTitle?: string;
|
|
isPrimaryContact?: boolean;
|
|
attributes?: Record<string, any>;
|
|
companyProfiles?: CompanyProfileInput[];
|
|
}
|
|
|
|
export interface FreightVolumePoint {
|
|
month: string;
|
|
tonnes: number;
|
|
}
|
|
|
|
export interface DashboardSummary {
|
|
deliveredCount: number;
|
|
completionRate: number;
|
|
spendYtd: number;
|
|
spendCurrency: string;
|
|
spendYtdChangePct: number;
|
|
freightVolume: {
|
|
totalTonnes: number;
|
|
totalValue: number;
|
|
currency: string;
|
|
ytdChangePct: number;
|
|
monthly: FreightVolumePoint[];
|
|
};
|
|
}
|
|
|
|
export const companiesService = {
|
|
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
|
try {
|
|
const response = await client.get<ApiResponse<CompanyInfoResponse>>(
|
|
URL_CONSTANTS.COMPANIES_API.GET_INFO,
|
|
);
|
|
return unwrap(response.data);
|
|
} catch (e) {
|
|
if (isAxiosError(e) && e.response?.status === 404) {
|
|
return null;
|
|
}
|
|
throw e;
|
|
}
|
|
},
|
|
|
|
create: async (
|
|
payload: CreateCompanyPayload,
|
|
): Promise<CompanyInfoResponse> => {
|
|
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
|
URL_CONSTANTS.COMPANIES_API.CREATE,
|
|
payload,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
getProfile: async (): Promise<ProfileResponse> => {
|
|
const response = await client.get<ApiResponse<ProfileResponse>>(
|
|
URL_CONSTANTS.COMPANIES_API.PROFILE,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
updateProfile: async (
|
|
payload: UpdateProfilePayload,
|
|
): Promise<ProfileResponse> => {
|
|
const response = await client.patch<ApiResponse<ProfileResponse>>(
|
|
URL_CONSTANTS.COMPANIES_API.PROFILE,
|
|
payload,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
getDashboard: async (
|
|
companyProfileId?: string,
|
|
): Promise<DashboardSummary> => {
|
|
const response = await client.get<ApiResponse<DashboardSummary>>(
|
|
URL_CONSTANTS.COMPANIES_API.DASHBOARD,
|
|
{ params: companyProfileId ? { companyProfileId } : undefined },
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
addCompanyProfiles: async (payload: {
|
|
types: string[];
|
|
}): Promise<CompanyProfileResponse[]> => {
|
|
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
|
|
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
|
|
payload,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/** Create a single operational profile and make it the active mode. */
|
|
createCompanyProfile: async (payload: {
|
|
type: ProfileTypeValue;
|
|
businessLicense?: string;
|
|
}): Promise<CompanyProfileResponse> => {
|
|
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
|
|
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
|
|
payload,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/** Begin onboarding — create the draft company + profile + role(s) up front. */
|
|
startOnboarding: async (payload: {
|
|
companyType: string;
|
|
roles: ProfileTypeValue[];
|
|
nationality?: CompanyNationality;
|
|
}): Promise<CompanyInfoResponse> => {
|
|
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
|
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
|
|
payload,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
|
|
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
|
},
|
|
|
|
completeOnboarding: async (): Promise<CompanyInfoResponse> => {
|
|
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
|
URL_CONSTANTS.COMPANIES_API.ONBOARDING_COMPLETE,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/** Server-driven list of outstanding onboarding requirements + completeness. */
|
|
getOnboardingRequirements: async (): Promise<OnboardingRequirements> => {
|
|
const response = await client.get<ApiResponse<OnboardingRequirements>>(
|
|
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
uploadDocuments: async (
|
|
companyId: string,
|
|
files: Record<string, File | File[] | null>,
|
|
): Promise<void> => {
|
|
const formData = new FormData();
|
|
for (const [fieldName, fileOrFiles] of Object.entries(files)) {
|
|
if (!fileOrFiles) continue;
|
|
if (Array.isArray(fileOrFiles)) {
|
|
for (const f of fileOrFiles) {
|
|
formData.append(fieldName, f);
|
|
}
|
|
} else {
|
|
formData.append(fieldName, fileOrFiles);
|
|
}
|
|
}
|
|
await client.post(
|
|
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
|
|
formData,
|
|
);
|
|
},
|
|
|
|
/** List documents already uploaded for a company (settings-driven, by fileKey). */
|
|
getDocuments: async (companyId: string): Promise<CompanyDocument[]> => {
|
|
const response = await client.get<ApiResponse<CompanyDocument[]>>(
|
|
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/**
|
|
* 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 (
|
|
profileId: string,
|
|
files: File[],
|
|
code = "business_license",
|
|
): Promise<LicenseFile[]> => {
|
|
const formData = new FormData();
|
|
for (const f of files) formData.append(code, f);
|
|
const response = await client.post<ApiResponse<LicenseFile[]>>(
|
|
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
|
|
formData,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/** 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);
|
|
},
|
|
|
|
/** The PoA delegation letter on file, with its review state. */
|
|
getPoaDelegation: async (): Promise<LicenseFile[]> => {
|
|
const response = await client.get<ApiResponse<LicenseFile[]>>(
|
|
URL_CONSTANTS.COMPANIES_API.POA_DELEGATION,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/**
|
|
* Upload the PoA delegation letter, replacing any existing one. On an approved
|
|
* company the upload is staged for backoffice review; during onboarding it
|
|
* goes live immediately.
|
|
*/
|
|
uploadPoaDelegation: async (file: File): Promise<LicenseFile[]> => {
|
|
const formData = new FormData();
|
|
formData.append("poa_delegation_letter", file);
|
|
const response = await client.post<ApiResponse<LicenseFile[]>>(
|
|
URL_CONSTANTS.COMPANIES_API.POA_DELEGATION,
|
|
formData,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/** Remove the PoA delegation letter (staged for review on an approved company). */
|
|
removePoaDelegation: async (fileId: string): Promise<LicenseFile[]> => {
|
|
const response = await client.delete<ApiResponse<LicenseFile[]>>(
|
|
URL_CONSTANTS.COMPANIES_API.POA_DELEGATION_FILE(fileId),
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
/** List business-license document(s) (with review state) for a company profile. */
|
|
getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
|
|
const response = await client.get<ApiResponse<LicenseFile[]>>(
|
|
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
|
|
);
|
|
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. */
|
|
fetchETradeInfo: async (payload: { tin: string }): Promise<any> => {
|
|
const response = await client.post<ApiResponse<any>>(
|
|
URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO,
|
|
payload,
|
|
);
|
|
return unwrap(response.data);
|
|
},
|
|
};
|