mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
feat: fix the settings preview for the business licence and changes request to the approval
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -12,9 +13,17 @@ import {
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
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 { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
@@ -129,6 +138,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
api.customers.rejectChangeRequest.mutationOptions(),
|
||||
);
|
||||
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
@@ -142,6 +152,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
? Object.keys(pending.snapshot ?? {})
|
||||
: ([] as string[]);
|
||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejectId) return;
|
||||
@@ -206,6 +217,48 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</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">
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -300,6 +353,8 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -684,12 +685,12 @@ export default function CustomerDetailPage() {
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
{licenseProfiles.map((p) => (
|
||||
<Stack key={p.id} gap={4}>
|
||||
<Stack key={p.id} gap={6}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{humanize(p.type)} · {p.reference}
|
||||
</Text>
|
||||
{(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" />
|
||||
<Anchor
|
||||
component="button"
|
||||
@@ -697,16 +698,37 @@ export default function CustomerDetailPage() {
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
style={{
|
||||
textDecoration:
|
||||
f.status === "pending_remove"
|
||||
? "line-through"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</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>
|
||||
))}
|
||||
{(p.licenseFiles ?? []).length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
No license documents.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -37,12 +37,17 @@ export type ProfileStatus =
|
||||
| "suspended"
|
||||
| "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 {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
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. */
|
||||
@@ -66,6 +71,14 @@ export interface CompanyProfile {
|
||||
/** Lifecycle of a staged customer profile-edit review. */
|
||||
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
|
||||
* (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). */
|
||||
snapshot: Record<string, unknown>;
|
||||
documentFileIds: string[];
|
||||
/** Staged business-license add/remove intents attached to this request. */
|
||||
licenseChanges: LicenseChangeIntent[];
|
||||
note: string | null;
|
||||
submittedAt: string | null;
|
||||
reviewedAt: string | null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 useAuth from "@/hooks/useAuth";
|
||||
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
|
||||
* still being reviewed. Communicates that approval is per-profile and that
|
||||
* bookings unlock as each profile is cleared. Self-hides when nothing is pending.
|
||||
* Post-onboarding review banner. Surfaces (in priority order):
|
||||
* 1. A pending profile-edit review — the whole account is locked until an admin
|
||||
* 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() {
|
||||
const { company } = useAuth();
|
||||
const { company, reviewStatus, reviewNote } = useAuth();
|
||||
const profiles = company?.company?.companyProfiles ?? [];
|
||||
const pending = profiles.filter((p) => p.status === "pending");
|
||||
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;
|
||||
|
||||
const pendingLabel = pending
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Paperclip } from "lucide-react";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import type { IFileUploadSetting } from "@edr/types/freight";
|
||||
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import type { LicenseFile } from "@/services/companies.service";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
@@ -107,10 +108,10 @@ export default function RoleLicenseStep({
|
||||
{hasExisting && (
|
||||
<Stack gap={4} mb="sm">
|
||||
{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" />
|
||||
<Anchor
|
||||
href={f.url}
|
||||
href={fileViewUrl(f.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xs"
|
||||
|
||||
@@ -96,6 +96,13 @@ export const URL_CONSTANTS = {
|
||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||
PROFILE_LICENSE: (profileId: string) =>
|
||||
`/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: {
|
||||
|
||||
@@ -44,7 +44,21 @@ const useAuth = () => {
|
||||
api.companies.getInfo.queryOptions({
|
||||
enabled: !!authQuery.data?.id,
|
||||
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,
|
||||
}),
|
||||
);
|
||||
@@ -166,6 +180,14 @@ const useAuth = () => {
|
||||
const activeProfileStatus = activeProfile?.status ?? null;
|
||||
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. */
|
||||
const invalidateScopedData = async () => {
|
||||
await Promise.all([
|
||||
@@ -179,9 +201,7 @@ const useAuth = () => {
|
||||
]);
|
||||
};
|
||||
|
||||
const switchMode = async (
|
||||
type: ProfileTypeValue,
|
||||
): Promise<Result<void>> => {
|
||||
const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
|
||||
try {
|
||||
await api.companies.setActiveMode.call({ type });
|
||||
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 () => {
|
||||
try {
|
||||
await api.auth.logout.call();
|
||||
@@ -239,10 +272,14 @@ const useAuth = () => {
|
||||
companyType,
|
||||
companyStatus,
|
||||
isCompanyApproved,
|
||||
reviewStatus,
|
||||
reviewNote,
|
||||
isUnderReview,
|
||||
onboardingCompleted,
|
||||
onboardingStep,
|
||||
switchMode,
|
||||
createProfileAndSwitch,
|
||||
reapplyProfile,
|
||||
login,
|
||||
signup,
|
||||
setPassword,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Fieldset,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
@@ -17,9 +19,11 @@ import {
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
BadgeCheck,
|
||||
Briefcase,
|
||||
Building2,
|
||||
Clock,
|
||||
FileCheck,
|
||||
Globe,
|
||||
ShieldCheck,
|
||||
@@ -225,6 +229,9 @@ export default function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const reviewStatus = profile.reviewStatus ?? null;
|
||||
const locked = reviewStatus === "pending";
|
||||
|
||||
return (
|
||||
<Container size="xl" px="lg" py="xl">
|
||||
<Stack gap="xl">
|
||||
@@ -239,6 +246,39 @@ export default function SettingsPage() {
|
||||
</Text>
|
||||
</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
|
||||
value={tab}
|
||||
onChange={(value) => value && setTab(value as SettingsTab)}
|
||||
@@ -269,20 +309,33 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</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">
|
||||
<TabCompanyProfile mode="edit" profile={profile} />
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabCompanyProfile mode="edit" profile={profile} />
|
||||
</Fieldset>
|
||||
</Tabs.Panel>
|
||||
<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 value="gm">
|
||||
<TabGeneralManager profile={profile} mode="edit" />
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabGeneralManager profile={profile} mode="edit" />
|
||||
</Fieldset>
|
||||
</Tabs.Panel>
|
||||
<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 value="documents">
|
||||
<TabDocuments profile={profile} mode="edit" />
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabDocuments profile={profile} mode="edit" />
|
||||
</Fieldset>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
@@ -87,7 +87,7 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
||||
</Text>
|
||||
{onboardingDocs.map((doc, i) => (
|
||||
<Group
|
||||
key={`${doc.url}-${i}`}
|
||||
key={`${doc.id}-${i}`}
|
||||
gap={12}
|
||||
align="center"
|
||||
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] =
|
||||
useState<GenerateContractPriceResponse | null>(null);
|
||||
// In edit mode the contract already exists, so seed its id — this makes
|
||||
@@ -191,14 +203,18 @@ export default function NewContractPage({
|
||||
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 };
|
||||
},
|
||||
onSuccess: ({ contractId, pricing, mode }) => {
|
||||
setPriceContractId(contractId);
|
||||
setPricingData(pricing);
|
||||
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();
|
||||
setPriceModalMode(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.list.queryKey(),
|
||||
});
|
||||
navigate("/contracts");
|
||||
},
|
||||
});
|
||||
@@ -228,7 +246,9 @@ export default function NewContractPage({
|
||||
clearContractDraft();
|
||||
setPriceChangeResult(null);
|
||||
setPriceModalMode(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.list.queryKey(),
|
||||
});
|
||||
navigate("/contracts");
|
||||
},
|
||||
});
|
||||
@@ -242,7 +262,9 @@ export default function NewContractPage({
|
||||
clearContractDraft();
|
||||
setPriceModalMode(null);
|
||||
setPriceContractId(null);
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.list.queryKey(),
|
||||
});
|
||||
navigate("/contracts");
|
||||
},
|
||||
});
|
||||
@@ -325,6 +347,17 @@ export default function NewContractPage({
|
||||
m.set(p.type, p.status);
|
||||
return m;
|
||||
}, [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(
|
||||
() => [...profileStatusByType.keys()],
|
||||
[profileStatusByType],
|
||||
@@ -343,12 +376,14 @@ export default function NewContractPage({
|
||||
// badges. Intercity rides any customer profile, so always "approved".
|
||||
const operationStatus = useMemo(
|
||||
() =>
|
||||
(op: OperationType): "approved" | "pending" | "missing" => {
|
||||
(op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
|
||||
if (op === "intercity") return "approved";
|
||||
const target = operationToProfileType(op, profileTypes);
|
||||
const status = profileStatusByType.get(target);
|
||||
if (!status) return "missing";
|
||||
return status === "active" ? "approved" : "pending";
|
||||
if (status === "active") return "approved";
|
||||
if (status === "rejected") return "rejected";
|
||||
return "pending";
|
||||
},
|
||||
[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) => {
|
||||
// Intercity (domestic) runs on any existing customer profile — no switch.
|
||||
if (op === "intercity") return;
|
||||
@@ -551,23 +597,23 @@ export default function NewContractPage({
|
||||
: {}),
|
||||
...(serviceType?.includesFirstMile && data.firstMile.enabled
|
||||
? {
|
||||
firstMilePickupAddress: data.firstMile.pickUpAddress,
|
||||
firstMilePickupLat: data.firstMile.lat ?? undefined,
|
||||
firstMilePickupLng: data.firstMile.lng ?? undefined,
|
||||
}
|
||||
firstMilePickupAddress: data.firstMile.pickUpAddress,
|
||||
firstMilePickupLat: data.firstMile.lat ?? undefined,
|
||||
firstMilePickupLng: data.firstMile.lng ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
...(serviceType?.includesLastMile && data.lastMile.enabled
|
||||
? {
|
||||
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
|
||||
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
|
||||
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
|
||||
}
|
||||
lastMileDeliveryAddress: data.lastMile.deliveryAddress,
|
||||
lastMileDeliveryLat: data.lastMile.lat ?? undefined,
|
||||
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
...(serviceType?.includesCustoms && data.customsClearingEnabled
|
||||
? {
|
||||
customsClearingEnabled: true,
|
||||
customsClearingAgent: data.customsClearingAgent || undefined,
|
||||
}
|
||||
customsClearingEnabled: true,
|
||||
customsClearingAgent: data.customsClearingAgent || undefined,
|
||||
}
|
||||
: { customsClearingEnabled: false }),
|
||||
cargoScope,
|
||||
routes,
|
||||
@@ -652,7 +698,12 @@ export default function NewContractPage({
|
||||
mb="lg"
|
||||
>
|
||||
<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"}
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
@@ -791,12 +842,12 @@ export default function NewContractPage({
|
||||
errors={
|
||||
showDocErrors
|
||||
? Object.fromEntries(
|
||||
missingRequiredDocKeys(
|
||||
editDocSettingQuery.data,
|
||||
editContract,
|
||||
editDocuments,
|
||||
).map((k) => [k, "Required"]),
|
||||
)
|
||||
missingRequiredDocKeys(
|
||||
editDocSettingQuery.data,
|
||||
editContract,
|
||||
editDocuments,
|
||||
).map((k) => [k, "Required"]),
|
||||
)
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
@@ -814,9 +865,9 @@ export default function NewContractPage({
|
||||
pricing={
|
||||
pricingData
|
||||
? {
|
||||
currency: pricingData.currency,
|
||||
lineItems: pricingData.lineItems,
|
||||
}
|
||||
currency: pricingData.currency,
|
||||
lineItems: pricingData.lineItems,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
@@ -926,8 +977,8 @@ export default function NewContractPage({
|
||||
Pricing schedule
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Final amount is calculated at booking — quantities are unknown at
|
||||
the contract stage.
|
||||
Final amount is calculated at booking — quantities are unknown
|
||||
at the contract stage.
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{pricingData.lineItems.map((item) => (
|
||||
@@ -1087,7 +1138,7 @@ export default function NewContractPage({
|
||||
loading={confirmSubmitMutation.isPending}
|
||||
onClick={() => confirmSubmitMutation.mutate()}
|
||||
>
|
||||
Confirm & submit
|
||||
Confirm {"&"} submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -1131,9 +1182,9 @@ export default function NewContractPage({
|
||||
<Check size={18} />
|
||||
</Box>
|
||||
<Text size="sm" c="dimmed">
|
||||
License submitted. Your {createTargetLabel.toLowerCase()} profile
|
||||
is now awaiting staff approval. We'll notify you once it's
|
||||
approved — then you can create this contract as{" "}
|
||||
License submitted. Your {createTargetLabel.toLowerCase()}{" "}
|
||||
profile is now awaiting staff approval. We'll notify you once
|
||||
it's approved — then you can create this contract as{" "}
|
||||
{createTargetLabel.toLowerCase()}.
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -1146,9 +1197,9 @@ export default function NewContractPage({
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
|
||||
your business license to create one. It goes to staff for approval
|
||||
before you can use it.
|
||||
You don't have a {createTargetLabel.toLowerCase()} profile yet.
|
||||
Add your business license to create one. It goes to staff for
|
||||
approval before you can use it.
|
||||
</Text>
|
||||
<FileInput
|
||||
label="Business license"
|
||||
@@ -1181,35 +1232,78 @@ export default function NewContractPage({
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Awaiting-approval modal — the chosen operation maps to a profile that
|
||||
exists but isn't approved yet. The select was already reverted. */}
|
||||
<Modal
|
||||
opened={pendingApprovalProfile !== null}
|
||||
onClose={() => setPendingApprovalProfile(null)}
|
||||
title="Awaiting approval"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Your{" "}
|
||||
{pendingApprovalProfile
|
||||
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
||||
pendingApprovalProfile)
|
||||
: ""}{" "}
|
||||
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>
|
||||
{/* Awaiting-approval / rejected modal — the chosen operation maps to a
|
||||
profile that exists but isn't active. The select was already reverted. */}
|
||||
{(() => {
|
||||
const target = pendingApprovalProfile
|
||||
? profileByType.get(pendingApprovalProfile)
|
||||
: undefined;
|
||||
const isRejected = target?.status === "rejected";
|
||||
const label = pendingApprovalProfile
|
||||
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
||||
pendingApprovalProfile)
|
||||
: "";
|
||||
return (
|
||||
<Modal
|
||||
opened={pendingApprovalProfile !== null}
|
||||
onClose={() => setPendingApprovalProfile(null)}
|
||||
title={isRejected ? "Profile not approved" : "Awaiting approval"}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{isRejected ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Your {label} profile was not approved. Fix the issue below
|
||||
and resubmit it for review.
|
||||
</Text>
|
||||
{target?.reviewNote && (
|
||||
<Alert color="red" variant="light" radius="md">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export function StepDocuments({
|
||||
</Text>
|
||||
{onboardingDocs.map((doc, i) => (
|
||||
<Group
|
||||
key={`${doc.url}-${i}`}
|
||||
key={`${doc.id}-${i}`}
|
||||
gap={12}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
|
||||
@@ -43,7 +43,9 @@ export function Step1ContractType({
|
||||
allowedOperations: OperationType[];
|
||||
onOperationSelect?: (op: OperationType) => void;
|
||||
/** 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");
|
||||
|
||||
@@ -221,6 +223,11 @@ export function Step1ContractType({
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{status === "rejected" && (
|
||||
<Badge size="xs" color="red" variant="light" radius="sm">
|
||||
Rejected
|
||||
</Badge>
|
||||
)}
|
||||
{status === "missing" && (
|
||||
<Badge size="xs" color="gray" variant="light" radius="sm">
|
||||
Add license
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
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 type { ProfileResponse } from "@/types/profile";
|
||||
import { SmartFileInput, useFileViewer } from "@edr/ui-common";
|
||||
import {
|
||||
// Anchor,
|
||||
SmartFileInput,
|
||||
useFileViewer,
|
||||
type ViewableFile,
|
||||
} from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
@@ -13,18 +23,23 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileCheck,
|
||||
FileText,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
importer: "Importer",
|
||||
@@ -137,9 +152,7 @@ export default function TabDocuments({
|
||||
return errs;
|
||||
};
|
||||
|
||||
const licenseProfiles = profile.companyProfiles.filter(
|
||||
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||
);
|
||||
const licenseProfiles = profile.companyProfiles;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -246,24 +259,19 @@ export default function TabDocuments({
|
||||
<Title order={3}>Business licenses</Title>
|
||||
</Group>
|
||||
<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>
|
||||
|
||||
<Stack gap="md">
|
||||
<Stack gap="xl">
|
||||
{licenseProfiles.map((p) => (
|
||||
<Stack key={p.id} gap={4}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{ROLE_LABELS[p.type] ?? p.type} · {p.reference}
|
||||
</Text>
|
||||
{p.licenseFiles.map((f) => (
|
||||
<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>
|
||||
<ProfileLicenseRow
|
||||
key={p.id}
|
||||
profile={p}
|
||||
onViewFile={view}
|
||||
reviewPending={profile.reviewStatus === "pending"}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</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,
|
||||
} from "@/types/dropdownSettings";
|
||||
import type {
|
||||
ChangeRequestResponse,
|
||||
CompanyDocument,
|
||||
CompanyInfoResponse,
|
||||
CompanyNationality,
|
||||
@@ -211,6 +212,18 @@ export const api = {
|
||||
"documents",
|
||||
({ 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: {
|
||||
|
||||
@@ -14,11 +14,16 @@ export type ProfileTypeValue =
|
||||
|
||||
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;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType?: string;
|
||||
mimeType: string;
|
||||
/** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */
|
||||
status: LicenseFileStatus;
|
||||
}
|
||||
|
||||
export interface ExternalProfileResponse {
|
||||
@@ -73,6 +78,8 @@ export interface CompanyProfileResponse {
|
||||
/** 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;
|
||||
}
|
||||
@@ -80,6 +87,28 @@ export interface CompanyProfileResponse {
|
||||
export interface CompanyInfoResponse {
|
||||
profile: ExternalProfileResponse;
|
||||
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. */
|
||||
@@ -328,7 +357,11 @@ export const companiesService = {
|
||||
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 (
|
||||
profileId: string,
|
||||
files: File[],
|
||||
@@ -343,7 +376,33 @@ export const companiesService = {
|
||||
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[]> => {
|
||||
const response = await client.get<ApiResponse<LicenseFile[]>>(
|
||||
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
|
||||
@@ -351,6 +410,24 @@ export const companiesService = {
|
||||
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>>(
|
||||
|
||||
@@ -40,6 +40,14 @@ export interface ProfileResponse {
|
||||
poaLocation: string | null;
|
||||
poaAddress: string | null;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user