feat(documents): implement document upload functionality for companies

- Added DocumentItem component to display uploaded documents in CompanyDetailsPage.
- Introduced useUploadCompanyDocuments hook for handling document uploads.
- Updated CreateCompany form to include document upload capabilities and validation for file types and sizes.
- Enhanced API services to support batch document uploads and retrieval of document information.
- Improved response handling in the file service for better error management and user feedback.
This commit is contained in:
AmirReza Jamali
2026-06-06 17:18:02 +03:30
parent 88f223bf08
commit da1e2f89e7
19 changed files with 1038 additions and 429 deletions
@@ -1,6 +1,7 @@
"use client"; "use client";
import Loading from "@/components/loading"; import Loading from "@/components/loading";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import DocumentItem from "@/components/forms/companies/documents/DocumentItem";
import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import Status from "@/components/ui/Status"; import Status from "@/components/ui/Status";
import { useCompanyDetails } from "@/hooks/queries"; import { useCompanyDetails } from "@/hooks/queries";
@@ -72,6 +73,9 @@ const CompanyDetailsPage = ({ params }: IProps) => {
if (isLoading) return <Loading />; if (isLoading) return <Loading />;
const company = data.data; const company = data.data;
const website = company.website?.trim(); const website = company.website?.trim();
const documentFileIds: string[] = Array.isArray(company.document_file_ids)
? company.document_file_ids
: [];
return ( return (
<> <>
@@ -215,6 +219,17 @@ const CompanyDetailsPage = ({ params }: IProps) => {
)} )}
</div> </div>
</div> </div>
{documentFileIds.length > 0 && (
<div>
<SectionHeading>Documents</SectionHeading>
<div className="grid gap-3 sm:grid-cols-2">
{documentFileIds.map((fileId) => (
<DocumentItem key={fileId} fileId={fileId} />
))}
</div>
</div>
)}
</div> </div>
</ShowcaseSection> </ShowcaseSection>
</> </>
+257 -291
View File
@@ -10,6 +10,7 @@ import FormFooter from "@/components/ui/FormFooter";
import { REGEX } from "@/constants/regex"; import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts"; import { FormErrorMessages } from "@/constants/Texts";
import { ICreateCompanyCredentials } from "@/lib/api/types/Company"; import { ICreateCompanyCredentials } from "@/lib/api/types/Company";
import CompanyDocuments from "./documents/CompanyDocuments";
import useCreateCompanyPresenter from "./useCreateCompanyPresenter"; import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
interface IProps { interface IProps {
@@ -18,6 +19,12 @@ interface IProps {
id?: string; id?: string;
} }
/** Inline error for nested fields whose dotted name can't use the inputs' built-in lookup. */
const FieldError = ({ message }: { message?: string }) =>
message ? (
<p className="mt-1.5 text-sm font-medium text-error">{message}</p>
) : null;
const CreateCompany = ({ defaultValues, editMode, id }: IProps) => { const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
const { const {
errors, errors,
@@ -32,37 +39,41 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
defaultValues, defaultValues,
id, id,
}); });
return ( return (
<form <form className="flex flex-col gap-6" onSubmit={handleSubmit(onSubmit)}>
className="grid grid-cols-1 items-start gap-9 rounded-xl bg-gray-1 p-10 dark:bg-gray-7 md:grid-cols-2" <header className="flex flex-col gap-1">
onSubmit={handleSubmit(onSubmit)} <h1 className="text-xl font-bold text-dark dark:text-white">
> {editMode ? "Edit company" : "New company"}
<div className="flex flex-col gap-9"> </h1>
<p className="text-sm text-dark-5 dark:text-dark-6">
Profile, business details, address, tags, and supporting documents.
</p>
</header>
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
<ShowcaseSection <ShowcaseSection
title="Company Information" title="Company information"
className="flex flex-col gap-5" rootClassName="lg:col-span-2"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
> >
<InputGroup <InputGroup
{...register("name", { {...register("name", {
required: { message: FormErrorMessages.required, value: true }, required: { message: FormErrorMessages.required, value: true },
minLength: { minLength: { value: 2, message: FormErrorMessages.minLength(2) },
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: { maxLength: {
value: 200, value: 200,
message: FormErrorMessages.maxLength(200), message: FormErrorMessages.maxLength(200),
}, },
})} })}
errors={errors}
label="Name" label="Name"
name="name" name="name"
required required
type="text" type="text"
placeholder="Legal name as registered" placeholder="Legal name as registered"
className="sm:col-span-2"
/> />
{errors.name && (
<p className="mt-1 text-sm text-red-500">{errors.name.message}</p>
)}
<InputGroup <InputGroup
{...register("email", { {...register("email", {
required: { message: FormErrorMessages.required, value: true }, required: { message: FormErrorMessages.required, value: true },
@@ -71,15 +82,13 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
message: FormErrorMessages.invalidEmail, message: FormErrorMessages.invalidEmail,
}, },
})} })}
errors={errors}
label="Email" label="Email"
name="email" name="email"
required required
type="email" type="email"
placeholder="name@company.com" placeholder="name@company.com"
/> />
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
)}
<InputGroup <InputGroup
{...register("phone", { {...register("phone", {
required: { message: FormErrorMessages.required, value: true }, required: { message: FormErrorMessages.required, value: true },
@@ -96,19 +105,18 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
message: FormErrorMessages.invalidPattern, message: FormErrorMessages.invalidPattern,
}, },
})} })}
errors={errors}
label="Phone" label="Phone"
name="phone" name="phone"
required required
type="tel" type="tel"
placeholder="+971 50 123 4567" placeholder="+971 50 123 4567"
/> />
{errors.phone && (
<p className="mt-1 text-sm text-red-500">{errors.phone.message}</p>
)}
<Select <Select
{...register("type", { {...register("type", {
required: { message: FormErrorMessages.required, value: true }, required: { message: FormErrorMessages.required, value: true },
})} })}
errors={errors}
name="type" name="type"
label="Company type" label="Company type"
required required
@@ -122,47 +130,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
defaultValue="Private" defaultValue="Private"
prefixIcon={<GlobeIcon />} prefixIcon={<GlobeIcon />}
/> />
{errors.type && (
<p className="mt-1 text-sm text-red-500">{errors.type.message}</p>
)}
<InputGroup
{...register("registration_number", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: {
value: 50,
message: FormErrorMessages.maxLength(50),
},
})}
label="Registration Number"
name="registration_number"
type="text"
required
placeholder="Registry or license number"
/>
{errors.registration_number && (
<p className="mt-1 text-sm text-red-500">
{errors.registration_number.message}
</p>
)}
<InputGroup
{...register("tax_id", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: {
value: 50,
message: FormErrorMessages.maxLength(50),
},
})}
label="Tax id"
name="tax_id"
type="text"
required
placeholder="National tax identification number"
/>
{errors.tax_id && (
<p className="mt-1 text-sm text-red-500">{errors.tax_id.message}</p>
)}
<InputGroup <InputGroup
{...register("industry", { {...register("industry", {
required: { message: FormErrorMessages.required, value: true }, required: { message: FormErrorMessages.required, value: true },
@@ -172,36 +139,45 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
message: FormErrorMessages.maxLength(100), message: FormErrorMessages.maxLength(100),
}, },
})} })}
errors={errors}
label="Industry" label="Industry"
name="industry" name="industry"
type="text" type="text"
required required
placeholder="e.g. construction, software, logistics" placeholder="e.g. construction, software, logistics"
/> />
{errors.industry && ( <InputGroup
<p className="mt-1 text-sm text-red-500"> {...register("registration_number", {
{errors.industry.message} required: { message: FormErrorMessages.required, value: true },
</p> minLength: { value: 5, message: FormErrorMessages.minLength(5) },
)}
<TextAreaGroup
label="Description"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: { maxLength: {
value: 1000, value: 50,
message: FormErrorMessages.maxLength(1000), message: FormErrorMessages.maxLength(50),
}, },
})} })}
name="description" errors={errors}
label="Registration number"
name="registration_number"
type="text"
required
placeholder="Registry or license number"
/>
<InputGroup
{...register("tax_id", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: {
value: 50,
message: FormErrorMessages.maxLength(50),
},
})}
errors={errors}
label="Tax id"
name="tax_id"
type="text"
required
placeholder="National tax identification number"
/> />
{errors.description && (
<p className="mt-1 text-sm text-red-500">
{errors.description.message}
</p>
)}
<InputGroup <InputGroup
{...register("website", { {...register("website", {
required: { message: FormErrorMessages.required, value: true }, required: { message: FormErrorMessages.required, value: true },
@@ -215,22 +191,79 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
message: FormErrorMessages.invalidPattern, message: FormErrorMessages.invalidPattern,
}, },
})} })}
errors={errors}
label="Website" label="Website"
required required
name="website" name="website"
type="text" type="text"
placeholder="https://www.example.com" placeholder="https://www.example.com"
className="sm:col-span-2"
/>
<TextAreaGroup
label="Description"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 1000,
message: FormErrorMessages.maxLength(1000),
},
})}
errors={errors}
name="description"
className="sm:col-span-2"
/> />
{errors.website && (
<p className="mt-1 text-sm text-red-500">
{errors.website.message}
</p>
)}
</ShowcaseSection> </ShowcaseSection>
<ShowcaseSection title="Settings" className="grid grid-cols-1 gap-5"> <ShowcaseSection
title="Business information"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<InputGroup
{...register("employee_count", {
min: { value: 1, message: FormErrorMessages.min(1) },
max: { value: 100000, message: FormErrorMessages.max(100000) },
})}
errors={errors}
label="Employee count"
name="employee_count"
type="number"
/>
<InputGroup
{...register("annual_revenue", {
min: { value: 0, message: FormErrorMessages.min(0) },
max: {
value: 999999999999,
message: FormErrorMessages.max(999999999999),
},
})}
errors={errors}
label="Annual revenue"
name="annual_revenue"
type="number"
/>
<InputGroup
{...register("founded_year", {
min: { value: 1800, message: FormErrorMessages.min(1800) },
max: { value: 2100, message: FormErrorMessages.max(2100) },
})}
errors={errors}
label="Founded year"
name="founded_year"
type="number"
className="sm:col-span-2"
/>
</ShowcaseSection>
<ShowcaseSection
title="Settings"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<Select <Select
{...register("language")} {...register("language")}
errors={errors}
name="language" name="language"
label="Language" label="Language"
items={[ items={[
@@ -246,13 +279,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
defaultValue="en" defaultValue="en"
prefixIcon={<GlobeIcon />} prefixIcon={<GlobeIcon />}
/> />
{errors.language && (
<p className="mt-1 text-sm text-red-500">
{errors.language.message}
</p>
)}
<Select <Select
{...register("currency")} {...register("currency")}
errors={errors}
name="currency" name="currency"
label="Currency" label="Currency"
items={[ items={[
@@ -270,11 +299,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
defaultValue="en" defaultValue="en"
prefixIcon={<MoneyIcon />} prefixIcon={<MoneyIcon />}
/> />
{errors.currency && (
<p className="mt-1 text-sm text-red-500">
{errors.currency.message}
</p>
)}
<InputGroup <InputGroup
{...register("timezone", { {...register("timezone", {
minLength: { value: 3, message: FormErrorMessages.minLength(3) }, minLength: { value: 3, message: FormErrorMessages.minLength(3) },
@@ -283,189 +307,129 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
message: FormErrorMessages.maxLength(50), message: FormErrorMessages.maxLength(50),
}, },
})} })}
errors={errors}
label="Timezone" label="Timezone"
name="timezone" name="timezone"
type="text" type="text"
className="sm:col-span-2"
/> />
{errors.timezone && (
<p className="mt-1 text-sm text-red-500">
{errors.timezone.message}
</p>
)}
</ShowcaseSection> </ShowcaseSection>
</div>
<div className="flex flex-col gap-9">
<ShowcaseSection <ShowcaseSection
title="Business Information" title="Address"
className="grid grid-cols-1 gap-5" className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
> >
<InputGroup <div className="sm:col-span-2">
{...register("employee_count", { <InputGroup
min: { value: 1, message: FormErrorMessages.min(1) }, {...register("address.street", {
max: { value: 100000, message: FormErrorMessages.max(100000) }, required: { message: FormErrorMessages.required, value: true },
})} minLength: {
label="Employee Count" value: 5,
name="employee_count" message: FormErrorMessages.minLength(5),
type="number" },
/> maxLength: {
{errors.employee_count && ( value: 200,
<p className="mt-1 text-sm text-red-500"> message: FormErrorMessages.maxLength(200),
{errors.employee_count.message} },
</p> })}
)} label="Street"
<InputGroup name="address.street"
{...register("annual_revenue", { required
min: { value: 0, message: FormErrorMessages.min(0) }, type="text"
max: { placeholder="Building, street, suite or unit"
value: 999999999999, />
message: FormErrorMessages.max(999999999999), <FieldError message={errors.address?.street?.message} />
}, </div>
})} <div>
label="Annual Revenue" <InputGroup
name="annual_revenue" {...register("address.city", {
type="number" required: { message: FormErrorMessages.required, value: true },
/> minLength: {
{errors.annual_revenue && ( value: 2,
<p className="mt-1 text-sm text-red-500"> message: FormErrorMessages.minLength(2),
{errors.annual_revenue.message} },
</p> maxLength: {
)} value: 100,
<InputGroup message: FormErrorMessages.maxLength(100),
{...register("founded_year", { },
min: { value: 1800, message: FormErrorMessages.min(1800) }, })}
max: { label="City"
value: 2100, required
message: FormErrorMessages.max(2100), name="address.city"
}, type="text"
})} placeholder="City or town"
label="Founded Year" />
name="founded_year" <FieldError message={errors.address?.city?.message} />
type="number" </div>
/> <div>
{errors.founded_year && ( <InputGroup
<p className="mt-1 text-sm text-red-500"> {...register("address.state", {
{errors.founded_year.message} required: { message: FormErrorMessages.required, value: true },
</p> minLength: {
)} value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="State"
name="address.state"
type="text"
required
placeholder="State, province, or region"
/>
<FieldError message={errors.address?.state?.message} />
</div>
<div>
<InputGroup
{...register("address.postal_code", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 3,
message: FormErrorMessages.minLength(3),
},
maxLength: {
value: 20,
message: FormErrorMessages.maxLength(20),
},
})}
label="Postal code"
name="address.postal_code"
type="text"
required
placeholder="Postal or ZIP code"
/>
<FieldError message={errors.address?.postal_code?.message} />
</div>
<div>
<InputGroup
{...register("address.country", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Country"
name="address.country"
type="text"
required
placeholder="Full country name"
/>
<FieldError message={errors.address?.country?.message} />
</div>
</ShowcaseSection> </ShowcaseSection>
<ShowcaseSection title="Address" className="grid grid-cols-1 gap-5">
<InputGroup <ShowcaseSection
{...register("address.street", { title="Tags"
required: { message: FormErrorMessages.required, value: true }, className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
minLength: { >
value: 5,
message: FormErrorMessages.minLength(5),
},
maxLength: {
value: 200,
message: FormErrorMessages.maxLength(200),
},
})}
label="Street"
name="address.street"
required
type="text"
placeholder="Building, street, suite or unit"
/>
{errors.address?.street && (
<p className="mt-1 text-sm text-red-500">
{errors.address.street.message}
</p>
)}
<InputGroup
{...register("address.city", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="City"
required
name="address.city"
type="text"
placeholder="City or town"
/>
{errors.address?.city && (
<p className="mt-1 text-sm text-red-500">
{errors.address.city.message}
</p>
)}
<InputGroup
{...register("address.state", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="State"
name="address.state"
type="text"
required
placeholder="State, province, or region"
/>
{errors.address?.state && (
<p className="mt-1 text-sm text-red-500">
{errors.address.state.message}
</p>
)}
<InputGroup
{...register("address.postal_code", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 3,
message: FormErrorMessages.minLength(3),
},
maxLength: {
value: 20,
message: FormErrorMessages.maxLength(20),
},
})}
label="Postal code"
name="address.postal_code"
type="text"
required
placeholder="Postal or ZIP code"
/>
{errors.address?.postal_code && (
<p className="mt-1 text-sm text-red-500">
{errors.address.postal_code.message}
</p>
)}
<InputGroup
{...register("address.country", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Country"
name="address.country"
type="text"
required
placeholder="Full country name"
/>
{errors.address?.country && (
<p className="mt-1 text-sm text-red-500">
{errors.address.country.message}
</p>
)}
</ShowcaseSection>
<ShowcaseSection title="Tags" className="grid grid-cols-1 gap-5">
<TagInput <TagInput
{...register("tags.keywords")} {...register("tags.keywords")}
name="tags.keywords" name="tags.keywords"
@@ -482,12 +446,10 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
label="Categories" label="Categories"
items={ items={
companyCategories companyCategories
? companyCategories?.data?.categories?.map((c) => { ? companyCategories?.data?.categories?.map((c) => ({
return { label: c.name,
label: c.name, value: c.id,
value: c.id, }))
};
})
: [] : []
} }
placeholder="" placeholder=""
@@ -501,7 +463,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
setValue={setValue} setValue={setValue}
placeholder="" placeholder=""
/> />
<TagInput <TagInput
{...register("tags.certifications")} {...register("tags.certifications")}
label="Certifications" label="Certifications"
@@ -510,20 +471,25 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
setValue={setValue} setValue={setValue}
placeholder="" placeholder=""
/> />
<div className="sm:col-span-2">
<TagInput
{...register("tags.specializations")}
label="Specializations"
name="tags.specializations"
watch={watch}
setValue={setValue}
placeholder=""
/>
<FieldError message={errors.tags?.specializations?.message} />
</div>
</ShowcaseSection>
<TagInput <ShowcaseSection title="Documents" rootClassName="lg:col-span-2">
{...register("tags.specializations")} <CompanyDocuments
label="Specializations" companyId={editMode ? id : undefined}
name="tags.specializations" value={watch("document_file_ids") ?? []}
watch={watch} onChange={(ids) => setValue("document_file_ids", ids)}
setValue={setValue}
placeholder=""
/> />
{errors.tags?.specializations && (
<p className="mt-1 text-sm text-red-500">
{errors.tags.specializations.message}
</p>
)}
</ShowcaseSection> </ShowcaseSection>
</div> </div>
@@ -0,0 +1,114 @@
"use client";
import { DOCUMENT_FILE_ACCEPT, MAX_DOCUMENT_FILES } from "@/utils/shared";
import DocumentItem from "./DocumentItem";
import useCompanyDocumentsPresenter from "./useCompanyDocumentsPresenter";
interface IProps {
/** Present in edit mode; switches uploads to the company-documents endpoint. */
companyId?: string;
value: string[];
onChange: (ids: string[]) => void;
}
const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
const {
isUploading,
uploadProgress,
failed,
errorMessage,
onSelectFiles,
onRemove,
} = useCompanyDocumentsPresenter({ companyId, value, onChange });
const inputId = "company-documents-input";
return (
<div className="flex flex-col gap-4">
<div className="relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-white to-primary/[0.04] p-4 shadow-theme-xs dark:border-dark-3 dark:from-dark-2 dark:via-dark-2 dark:to-primary/[0.12]">
<input
id={inputId}
type="file"
multiple
accept={DOCUMENT_FILE_ACCEPT}
className="sr-only"
onChange={onSelectFiles}
disabled={isUploading}
/>
<label
htmlFor={inputId}
className="group flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-stroke/80 bg-white/70 px-4 py-9 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-primary hover:bg-primary/[0.03] dark:border-dark-3 dark:bg-dark/20 dark:hover:bg-primary/[0.08]"
>
<div className="mb-3 rounded-2xl bg-primary/[0.12] p-3 text-primary shadow-theme-xs transition-transform duration-300 group-hover:scale-105">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<p className="text-sm font-semibold text-dark dark:text-white">
Click to upload documents
</p>
<p className="mt-1 text-xs text-dark-5">
PDF, Word, Excel, images, archives up to 100MB each
</p>
<div className="mt-4 inline-flex items-center rounded-full border border-primary/20 bg-primary/[0.08] px-3 py-1 text-[11px] font-medium text-primary dark:border-primary/40 dark:bg-primary/[0.18]">
Max {MAX_DOCUMENT_FILES} files per upload
</div>
</label>
{isUploading ? (
<div className="mt-3">
<p className="text-xs font-medium text-primary">
Uploading documents {companyId ? "" : `${uploadProgress}%`}
</p>
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3">
<div
className="h-full rounded-full bg-gradient-to-r from-primary to-purple-500 transition-all duration-300"
style={{ width: companyId ? "100%" : `${uploadProgress}%` }}
/>
</div>
</div>
) : null}
</div>
{errorMessage ? (
<p className="text-sm font-medium text-red-500">{errorMessage}</p>
) : null}
{value.length > 0 ? (
<div className="flex flex-col gap-2">
{value.map((fileId) => (
<DocumentItem
key={fileId}
fileId={fileId}
onRemove={onRemove}
disabled={isUploading}
/>
))}
</div>
) : (
<p className="text-sm text-dark-5">No documents attached yet.</p>
)}
{failed.length > 0 ? (
<div className="rounded-xl border border-error/35 bg-error/5 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-error">
Failed uploads
</p>
<ul className="space-y-1">
{failed.map((item, index) => (
<li key={`${item.filename}-${index}`} className="text-sm text-error">
<span className="font-medium">{item.filename}</span>: {item.error}
</li>
))}
</ul>
</div>
) : null}
</div>
);
};
export default CompanyDocuments;
@@ -0,0 +1,62 @@
"use client";
import { formatFileSize } from "@/utils/shared";
import useDocumentItemPresenter from "./useDocumentItemPresenter";
interface IProps {
fileId: string;
/** When provided, a remove button is shown and invoked with the file id. */
onRemove?: (fileId: string) => void;
disabled?: boolean;
}
const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
const { info, isLoading, isDownloading, download } =
useDocumentItemPresenter(fileId);
return (
<div className="flex items-center gap-3 rounded-xl border border-stroke/70 bg-white/70 p-3 shadow-theme-xs dark:border-dark-3 dark:bg-dark/30">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.12] text-primary">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-dark dark:text-white">
{isLoading ? "Loading…" : info?.filename || "Document"}
</p>
{info?.size ? (
<p className="mt-0.5 text-xs text-dark-5">{formatFileSize(info.size)}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={download}
disabled={isDownloading}
className="rounded-lg border border-stroke/80 bg-white px-3 py-1.5 text-xs font-semibold text-dark shadow-theme-xs transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-3 dark:bg-dark-2 dark:text-white"
>
{isDownloading ? "Downloading…" : "Download"}
</button>
{onRemove ? (
<button
type="button"
onClick={() => onRemove(fileId)}
disabled={disabled}
className="rounded-lg border border-error/35 bg-error/5 px-3 py-1.5 text-xs font-semibold text-error transition-all hover:-translate-y-0.5 hover:bg-error/10 disabled:cursor-not-allowed disabled:opacity-60"
>
Remove
</button>
) : null}
</div>
</div>
);
};
export default DocumentItem;
@@ -0,0 +1,121 @@
"use client";
import { useUploadCompanyDocuments } from "@/hooks/queries";
import { fileService, FailedFile } from "@/lib/api";
import {
MAX_DOCUMENT_FILES,
validateDocumentFile,
} from "@/utils/shared";
import { ChangeEvent, useState } from "react";
interface IProps {
/** Present in edit mode (company exists); drives the upload strategy. */
companyId?: string;
/** Current document file ids held by the form. */
value: string[];
/** Persists the updated document file id list back to the form. */
onChange: (ids: string[]) => void;
}
/**
* Owns the multi-file document upload lifecycle for the company form.
*
* - Edit mode (companyId set): uploads via `POST /companies/:id/documents`, which
* appends + links files server-side and reports per-file failures.
* - Create mode (no company yet): uploads via `POST /files/upload/batch` and
* appends the returned ids to the form so they're sent with the create call.
*/
const useCompanyDocumentsPresenter = ({
companyId,
value,
onChange,
}: IProps) => {
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [failed, setFailed] = useState<FailedFile[]>([]);
const [errorMessage, setErrorMessage] = useState<string | undefined>();
const { mutateAsync: uploadToCompany } = useUploadCompanyDocuments(
companyId ?? "",
);
const validateSelection = (files: File[]): string | null => {
if (value.length + files.length > MAX_DOCUMENT_FILES) {
return `You can attach at most ${MAX_DOCUMENT_FILES} documents.`;
}
for (const file of files) {
const error = validateDocumentFile({ file });
if (error) return `${file.name}: ${error}`;
}
return null;
};
const uploadFiles = async (files: File[]) => {
if (companyId) {
const response = await uploadToCompany(files);
onChange(response.data.document_file_ids ?? value);
return response.data.failed ?? [];
}
const result = await fileService.uploadBatch({
files,
category: "company_document",
onProgress: setUploadProgress,
});
const newIds = (result.uploaded ?? []).map((item) => item.file_id);
onChange([...value, ...newIds]);
return result.failed ?? [];
};
const onSelectFiles = async (event: ChangeEvent<HTMLInputElement>) => {
const input = event.target;
const files = input.files ? Array.from(input.files) : [];
// Reset so selecting the same file again re-triggers change.
input.value = "";
if (!files.length) return;
const validationError = validateSelection(files);
if (validationError) {
setErrorMessage(validationError);
return;
}
setErrorMessage(undefined);
setFailed([]);
setIsUploading(true);
setUploadProgress(0);
try {
setFailed(await uploadFiles(files));
} catch (error) {
console.error("ERROR caught while uploading company documents:", error);
setErrorMessage("Failed to upload documents. Please try again.");
} finally {
setIsUploading(false);
}
};
const onRemove = async (fileId: string) => {
onChange(value.filter((id) => id !== fileId));
// In create mode the file is an orphan in storage (not linked to any
// company), so clean it up. In edit mode the removal is persisted by the
// form's update (PUT replaces the full document list).
if (!companyId) {
try {
await fileService.deleteFile(fileId);
} catch (error) {
console.error("ERROR caught while deleting orphan document:", error);
}
}
};
return {
isUploading,
uploadProgress,
failed,
errorMessage,
onSelectFiles,
onRemove,
};
};
export default useCompanyDocumentsPresenter;
@@ -0,0 +1,30 @@
"use client";
import { useFileInfo } from "@/hooks/queries";
import { fileService } from "@/lib/api";
import { saveBlobFromAxiosResponse } from "@/utils/shared";
import { useState } from "react";
/**
* Loads metadata for a single document file id and exposes a download action.
* Keeps the `DocumentItem` view free of data-fetching / side effects.
*/
const useDocumentItemPresenter = (fileId: string) => {
const { data: info, isLoading } = useFileInfo(fileId);
const [isDownloading, setIsDownloading] = useState(false);
const download = async () => {
setIsDownloading(true);
try {
const response = await fileService.downloadFile(fileId);
saveBlobFromAxiosResponse(response, info?.filename ?? fileId);
} catch (error) {
console.error("ERROR caught while downloading document:", error);
} finally {
setIsDownloading(false);
}
};
return { info, isLoading, isDownloading, download };
};
export default useDocumentItemPresenter;
@@ -52,6 +52,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
founded_year: data.founded_year ? +data.founded_year : 0, founded_year: data.founded_year ? +data.founded_year : 0,
employee_count: data.employee_count ? +data.employee_count : 0, employee_count: data.employee_count ? +data.employee_count : 0,
annual_revenue: data.annual_revenue ? +data.annual_revenue : 0, annual_revenue: data.annual_revenue ? +data.annual_revenue : 0,
document_file_ids: data.document_file_ids ?? [],
tags: { tags: {
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [], keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
categories: data?.tags?.categories?.length categories: data?.tags?.categories?.length
+1
View File
@@ -16,3 +16,4 @@ export * from "./useUsersQueries";
export * from "./useAdminListQuery"; export * from "./useAdminListQuery";
export * from "./useSelectSearchQuery"; export * from "./useSelectSearchQuery";
export * from "./useDashboardQueries"; export * from "./useDashboardQueries";
export * from "./useFilesQueries";
+18
View File
@@ -64,6 +64,24 @@ export const useCompanyDetails = (id: string) => {
queryFn: () => companiesService.companyDetails(id), queryFn: () => companiesService.companyDetails(id),
}); });
}; };
export const useUploadCompanyDocuments = (id: string) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(
() => [API_ENDPOINTS.COMPANIES.DOCUMENTS(id), "upload-documents"],
[id],
);
return useMutation({
mutationKey,
mutationFn: (files: File[]) =>
companiesService.uploadDocuments({ id, files }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"],
});
},
});
};
export const useDeleteCompanyQuery = (successCallback?: () => void) => { export const useDeleteCompanyQuery = (successCallback?: () => void) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutationKey = useMemo( const mutationKey = useMemo(
+14
View File
@@ -0,0 +1,14 @@
import { API_ENDPOINTS, fileService } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
/** Fetches (and caches) metadata for a single stored file. */
export const useFileInfo = (fileId: string, options?: { enabled?: boolean }) => {
const queryKey = useMemo(() => [API_ENDPOINTS.FILES.INFO(fileId)], [fileId]);
return useQuery({
queryKey,
queryFn: () => fileService.getFileInfo(fileId),
enabled: (options?.enabled ?? true) && Boolean(fileId),
staleTime: 5 * 60 * 1000,
});
};
+4
View File
@@ -17,7 +17,10 @@ export const API_ENDPOINTS = {
}, },
FILES: { FILES: {
UPLOAD: "files/upload", UPLOAD: "files/upload",
UPLOAD_BATCH: "files/upload/batch",
DOWNLOAD: (fileId: string) => `files/${fileId}/download`, DOWNLOAD: (fileId: string) => `files/${fileId}/download`,
INFO: (fileId: string) => `files/${fileId}/info`,
DELETE: (fileId: string) => `files/${fileId}`,
}, },
PROFILE: { PROFILE: {
READ: "profile", READ: "profile",
@@ -29,6 +32,7 @@ export const API_ENDPOINTS = {
UPDATE: (id: string) => `companies/${id}`, UPDATE: (id: string) => `companies/${id}`,
DELETE: (id: string) => `companies/${id}`, DELETE: (id: string) => `companies/${id}`,
DETAILS: (id: string) => `companies/${id}`, DETAILS: (id: string) => `companies/${id}`,
DOCUMENTS: (id: string) => `companies/${id}/documents`,
CATEGORIES: { CATEGORIES: {
READ_ALL: "company-categories", READ_ALL: "company-categories",
CREATE: "company-categories", CREATE: "company-categories",
+40
View File
@@ -6,6 +6,7 @@ import {
TCompaniesResponse, TCompaniesResponse,
TCompanyCategoryApiResponse, TCompanyCategoryApiResponse,
TCreateCompanyCategoryCredentials, TCreateCompanyCategoryCredentials,
UploadDocumentsData,
} from "../types"; } from "../types";
import { ICreateCompanyCredentials } from "../types"; import { ICreateCompanyCredentials } from "../types";
@@ -51,6 +52,45 @@ export const companiesService = {
const response = await api.get(API_ENDPOINTS.COMPANIES.DETAILS(id)); const response = await api.get(API_ENDPOINTS.COMPANIES.DETAILS(id));
return response.data; return response.data;
}, },
/**
* Uploads documents and links them to a company in one step. New file IDs are
* appended to the company's existing `document_file_ids` (server-side), and
* any per-file failures are returned in `data.failed`.
*/
uploadDocuments: async ({
id,
files,
onProgress,
}: {
id: string;
files: File[];
onProgress?: (percent: number) => void;
}): Promise<ApiResponse<UploadDocumentsData>> => {
try {
const formData = new FormData();
files.forEach((file) => formData.append("files", file, file.name));
const response = await api.post(
API_ENDPOINTS.COMPANIES.DOCUMENTS(id),
formData,
{
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (progressEvent) => {
if (!progressEvent.total) return;
onProgress?.(
Math.round((progressEvent.loaded * 100) / progressEvent.total),
);
},
},
);
return response.data;
} catch (error) {
console.error("ERROR caught in Companies Services Upload Documents:", error);
throw error;
}
},
deleteCompany: async (id: string) => { deleteCompany: async (id: string) => {
try { try {
return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data; return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data;
+87 -7
View File
@@ -1,6 +1,7 @@
import api from "../axios"; import api from "../axios";
import { buildFileDownloadUrl } from "../config"; import { buildFileDownloadUrl } from "../config";
import { API_ENDPOINTS } from "../endpoints"; import { API_ENDPOINTS } from "../endpoints";
import { BatchUploadResult, FileInfo } from "../types";
interface IUploadFileParams { interface IUploadFileParams {
file: File; file: File;
@@ -10,6 +11,23 @@ interface IUploadFileParams {
onProgress?: (percent: number) => void; onProgress?: (percent: number) => void;
} }
interface IUploadBatchParams {
files: File[];
category?: string;
tags?: string[];
description?: string;
onProgress?: (percent: number) => void;
}
const trackUploadProgress =
(onProgress?: (percent: number) => void) =>
(progressEvent: { loaded: number; total?: number }) => {
if (!progressEvent.total) return;
onProgress?.(
Math.round((progressEvent.loaded * 100) / progressEvent.total),
);
};
export const fileService = { export const fileService = {
/** /**
* Uploads a binary file to the shared filestore endpoint and returns the * Uploads a binary file to the shared filestore endpoint and returns the
@@ -36,13 +54,7 @@ export const fileService = {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
onUploadProgress: (progressEvent) => { onUploadProgress: trackUploadProgress(onProgress),
if (!progressEvent.total) return;
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
);
onProgress?.(percent);
},
}); });
const fileId = response?.data?.file_id as string | undefined; const fileId = response?.data?.file_id as string | undefined;
@@ -56,4 +68,72 @@ export const fileService = {
throw error; throw error;
} }
}, },
/**
* Uploads multiple files in one request. Returns the batch body directly
* (this endpoint is not wrapped in the `success`/`data` envelope).
*/
uploadBatch: async ({
files,
category,
tags = [],
description,
onProgress,
}: IUploadBatchParams): Promise<BatchUploadResult> => {
try {
const formData = new FormData();
files.forEach((file) => formData.append("files", file, file.name));
if (category) formData.append("category", category);
tags.forEach((tag) => formData.append("tags", tag));
if (description) formData.append("description", description);
const response = await api.post(
API_ENDPOINTS.FILES.UPLOAD_BATCH,
formData,
{
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: trackUploadProgress(onProgress),
},
);
return response.data as BatchUploadResult;
} catch (error) {
console.error("ERROR caught in File Service Upload Batch", error);
throw error;
}
},
/** Normalized metadata for a stored file, tolerant of envelope/key variations. */
getFileInfo: async (fileId: string): Promise<FileInfo> => {
try {
const response = await api.get(API_ENDPOINTS.FILES.INFO(fileId));
const info = response.data?.data ?? response.data ?? {};
return {
file_id: fileId,
filename: info.filename ?? info.name ?? "Document",
content_type: info.content_type ?? info.contentType ?? "",
size: Number(info.size ?? info.length ?? 0),
};
} catch (error) {
console.error("ERROR caught in File Service Get File Info", error);
throw error;
}
},
/** Downloads a stored file as a blob (caller saves it). */
downloadFile: (fileId: string) =>
api.get<Blob>(API_ENDPOINTS.FILES.DOWNLOAD(fileId), {
responseType: "blob",
}),
/** Permanently removes a file from storage. */
deleteFile: async (fileId: string) => {
try {
return (await api.delete(API_ENDPOINTS.FILES.DELETE(fileId))).data;
} catch (error) {
console.error("ERROR caught in File Service Delete File", error);
throw error;
}
},
}; };
+2
View File
@@ -47,6 +47,7 @@ export interface ICreateCompanyCredentials {
address: IAddress; address: IAddress;
contact_person: IContactPerson; contact_person: IContactPerson;
tags: ITags; tags: ITags;
document_file_ids?: string[];
} }
export const companySchema = z.object({ export const companySchema = z.object({
@@ -90,6 +91,7 @@ export const companySchema = z.object({
certifications: z.array(z.string()).optional(), certifications: z.array(z.string()).optional(),
cpv_codes: z.array(z.string()).optional(), cpv_codes: z.array(z.string()).optional(),
}), }),
document_file_ids: z.array(z.string()).optional(),
}); });
export const companiesResponseSchema = z.object({ export const companiesResponseSchema = z.object({
+37
View File
@@ -0,0 +1,37 @@
/** A file that uploaded successfully via a batch / company-documents endpoint. */
export interface UploadedFile {
file_id: string;
filename: string;
content_type: string;
size: number;
message?: string;
}
/** A file that failed to upload, surfaced so the user can retry it. */
export interface FailedFile {
filename: string;
error: string;
code?: string;
}
/** `data` payload of `POST /companies/:id/documents` (wrapped in ApiResponse). */
export interface UploadDocumentsData {
document_file_ids: string[];
uploaded: UploadedFile[];
failed?: FailedFile[];
}
/** Body of `POST /files/upload/batch` — returned directly, not wrapped. */
export interface BatchUploadResult {
uploaded: UploadedFile[];
failed?: FailedFile[];
message?: string;
}
/** Normalized metadata for a stored file (`GET /files/:id/info`). */
export interface FileInfo {
file_id: string;
filename: string;
content_type: string;
size: number;
}
+2
View File
@@ -47,6 +47,7 @@ export interface ICreateCompanyCredentials {
address: IAddress; address: IAddress;
contact_person?: IContactPerson; contact_person?: IContactPerson;
tags: ITags; tags: ITags;
document_file_ids?: string[];
} }
export const companySchema = z.object({ export const companySchema = z.object({
@@ -92,6 +93,7 @@ export const companySchema = z.object({
certifications: z.array(z.string()).or(z.null()).optional(), certifications: z.array(z.string()).or(z.null()).optional(),
cpv_codes: z.array(z.string()).or(z.null()).optional(), cpv_codes: z.array(z.string()).or(z.null()).optional(),
}), }),
document_file_ids: z.array(z.string()).or(z.null()).optional(),
}); });
export const companiesResponseSchema = z.object({ export const companiesResponseSchema = z.object({
+1
View File
@@ -1,4 +1,5 @@
export * from "./CompanyCategories"; export * from "./CompanyCategories";
export * from "./CompanyDocuments";
export * from "./Customers"; export * from "./Customers";
export * from "./Factory"; export * from "./Factory";
export * from "./Posts"; export * from "./Posts";
+179 -131
View File
@@ -1,160 +1,208 @@
import { AxiosError, AxiosResponse } from "axios"; import {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { getLogoutCallback } from "../contexts/User.ctx"; import { getLogoutCallback } from "../contexts/User.ctx";
import { API_ENDPOINTS, api } from "../lib/api"; import { API_ENDPOINTS, api } from "../lib/api";
import { COOKIE_KEYS } from "../lib/shared/cookies"; import { COOKIE_KEYS } from "../lib/shared/cookies";
let isRefreshing = false; const SIGN_IN_PATH = "/auth/sign-in";
let failedQueue: any[] = [];
const processQueue = (error: any, token: string | null = null) => { type RetriableRequest = InternalAxiosRequestConfig & { _retry?: boolean };
failedQueue.forEach((prom) => {
interface TokenPair {
access_token: string;
refresh_token: string;
}
interface ApiErrorData {
message?: string;
error?: {
code?: string;
message?: string;
details?: string;
};
}
interface QueuedRequest {
resolve: (token: string) => void;
reject: (reason?: unknown) => void;
}
// ---------------------------------------------------------------------------
// Refresh-token queue: requests that arrive mid-refresh wait here and replay
// once a fresh access token is available (or fail together if refresh fails).
// ---------------------------------------------------------------------------
let isRefreshing = false;
let failedQueue: QueuedRequest[] = [];
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) { if (error) {
prom.reject(error); reject(error);
} else { } else if (token) {
prom.resolve(token); resolve(token);
} }
}); });
failedQueue = []; failedQueue = [];
}; };
export const responseInterceptor = (response: AxiosResponse) => { const queueRequest = (
return response; originalRequest: RetriableRequest,
): Promise<AxiosResponse> =>
new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
// ---------------------------------------------------------------------------
// Session / auth helpers
// ---------------------------------------------------------------------------
const clearLocalSession = () => {
Cookies.remove(COOKIE_KEYS.access_token);
Cookies.remove(COOKIE_KEYS.refresh_token);
Cookies.remove(COOKIE_KEYS.user);
if (typeof window !== "undefined") {
localStorage.removeItem("auth-token");
localStorage.removeItem("user-data");
}
}; };
export const responseErrorHandler = async (error: AxiosError) => { const redirectToSignIn = () => {
const originalRequest = error.config as any; if (typeof window !== "undefined") {
window.location.href = SIGN_IN_PATH;
}
};
// Skip token refresh for login endpoint - let login errors pass through /** Logs the user out via the registered callback, falling back to a manual reset. */
const isLoginRequest = originalRequest?.url?.includes( const forceLogout = () => {
API_ENDPOINTS.USER.LOGIN, const logout = getLogoutCallback();
); if (logout) {
logout();
return;
}
const isRefreshTokenRequest = originalRequest?.url?.includes( clearLocalSession();
API_ENDPOINTS.USER.REFRESH_TOKEN, redirectToSignIn();
); };
// If we get a 401 on refresh token endpoint, logout immediately (token refresh failed) // ---------------------------------------------------------------------------
if (error.response?.status === 401 && isRefreshTokenRequest) { // Error reporting
const logout = getLogoutCallback(); // ---------------------------------------------------------------------------
if (logout) { /**
logout(); * Builds the message shown in the error toast, appending the API-provided
} else { * `details` (e.g. field-level validation hints) to the main error message
// Fallback if logout callback is not registered * when available.
Cookies.remove(COOKIE_KEYS.access_token); */
Cookies.remove(COOKIE_KEYS.refresh_token); const buildErrorMessage = (data: unknown, fallback: string): string => {
Cookies.remove(COOKIE_KEYS.user); const payload = data as ApiErrorData | undefined;
if (typeof window !== "undefined") { const message = payload?.error?.message || payload?.message || fallback;
window.location.href = "/auth/sign-in"; const details = payload?.error?.details;
}
return details ? `${message}: ${details}` : message;
};
const notifyError = (error: unknown) => {
const data = (error as AxiosError).response?.data;
toast.error(buildErrorMessage(data, (error as Error).message));
};
// ---------------------------------------------------------------------------
// Token refresh
// ---------------------------------------------------------------------------
const requestNewAccessToken = async (refreshToken: string): Promise<string> => {
const response = await api.post(API_ENDPOINTS.USER.REFRESH_TOKEN, {
refresh_token: refreshToken,
});
const { access_token, refresh_token } = response.data.data as TokenPair;
if (!access_token || !refresh_token) {
throw new Error("Invalid token received from refresh endpoint.");
}
Cookies.set(COOKIE_KEYS.access_token, access_token);
Cookies.set(COOKIE_KEYS.refresh_token, refresh_token);
return access_token;
};
/**
* Handles a 401 by refreshing the access token and replaying the request.
* Concurrent requests are queued until the in-flight refresh settles.
*/
const handleUnauthorized = async (originalRequest: RetriableRequest) => {
if (isRefreshing) {
return queueRequest(originalRequest);
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = Cookies.get(COOKIE_KEYS.refresh_token);
if (!refreshToken) {
forceLogout();
return Promise.reject(new Error("Refresh token not found."));
} }
const accessToken = await requestNewAccessToken(refreshToken);
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
forceLogout();
notifyError(refreshError);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
};
// ---------------------------------------------------------------------------
// Request predicates
// ---------------------------------------------------------------------------
const matchesEndpoint = (request: RetriableRequest | undefined, endpoint: string) =>
request?.url?.includes(endpoint) ?? false;
// Login errors must surface to the caller instead of triggering a refresh.
const isLoginRequest = (request?: RetriableRequest) =>
matchesEndpoint(request, API_ENDPOINTS.USER.LOGIN);
const isRefreshTokenRequest = (request?: RetriableRequest) =>
matchesEndpoint(request, API_ENDPOINTS.USER.REFRESH_TOKEN);
// ---------------------------------------------------------------------------
// Interceptors
// ---------------------------------------------------------------------------
export const responseInterceptor = (response: AxiosResponse) => response;
export const responseErrorHandler = (error: AxiosError) => {
const originalRequest = error.config as RetriableRequest | undefined;
const isUnauthorized = error.response?.status === 401;
// A 401 from the refresh endpoint itself means the session is unrecoverable.
if (isUnauthorized && isRefreshTokenRequest(originalRequest)) {
forceLogout();
return Promise.reject(error); return Promise.reject(error);
} }
if ( if (
error.response?.status === 401 && isUnauthorized &&
originalRequest &&
!originalRequest._retry && !originalRequest._retry &&
!isLoginRequest !isLoginRequest(originalRequest)
) { ) {
if (isRefreshing) { return handleUnauthorized(originalRequest);
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token: string) => {
originalRequest.headers["Authorization"] = "Bearer " + token;
return api(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = Cookies.get(COOKIE_KEYS.refresh_token);
if (!refreshToken) {
// No refresh token available, logout immediately
const logout = getLogoutCallback();
if (logout) {
logout();
} else {
// Fallback if logout callback is not registered
Cookies.remove(COOKIE_KEYS.access_token);
Cookies.remove(COOKIE_KEYS.refresh_token);
Cookies.remove(COOKIE_KEYS.user);
if (typeof window !== "undefined") {
window.location.href = "/auth/sign-in";
}
}
return Promise.reject(new Error("Refresh token not found."));
}
const response = await api.post(API_ENDPOINTS.USER.REFRESH_TOKEN, {
refresh_token: refreshToken,
});
const { access_token, refresh_token } = response.data.data as {
access_token: string;
refresh_token: string;
};
if (!access_token || !refresh_token) {
throw new Error("Invalid token received from refresh endpoint.");
}
Cookies.set(COOKIE_KEYS.access_token, access_token);
Cookies.set(COOKIE_KEYS.refresh_token, refresh_token);
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${access_token}`;
}
processQueue(null, access_token);
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
// Use logout callback from User context
const logout = getLogoutCallback();
if (logout) {
logout();
} else {
// Fallback if logout callback is not registered
Cookies.remove(COOKIE_KEYS.access_token);
Cookies.remove(COOKIE_KEYS.refresh_token);
Cookies.remove(COOKIE_KEYS.user);
if (typeof window !== "undefined") {
localStorage.removeItem("auth-token");
localStorage.removeItem("user-data");
window.location.href = "/auth/sign-in";
}
}
const errorMessage =
(
(refreshError as AxiosError).response?.data as {
message: string;
error: { code: string; message: string };
}
)?.error?.message || (refreshError as Error).message;
toast.error(errorMessage);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
} }
const errorMessage = notifyError(error);
(
error.response?.data as {
message: string;
error: { code: string; message: string };
}
)?.error?.message || error.message;
toast.error(errorMessage);
return Promise.reject(error); return Promise.reject(error);
}; };
+53
View File
@@ -193,6 +193,59 @@ export const validateImageFile = ({
return null; return null;
}; };
/** Company document upload limits (see company-documents API guide). */
export const ALLOWED_DOCUMENT_FILE_TYPES = [
// Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
// Documents
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
// Text / data
"text/plain",
"text/csv",
"application/json",
// Archives
"application/zip",
"application/x-rar-compressed",
"application/x-7z-compressed",
];
export const MAX_DOCUMENT_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
export const MAX_DOCUMENT_FILES = 20;
/** `accept` attribute mirroring the allowed document MIME types. */
export const DOCUMENT_FILE_ACCEPT =
".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp,.svg,.txt,.csv,.json,.zip,.rar,.7z";
export const validateDocumentFile = ({
file,
allowedTypes = ALLOWED_DOCUMENT_FILE_TYPES,
maxSize = MAX_DOCUMENT_FILE_SIZE,
}: {
file: File;
allowedTypes?: string[];
maxSize?: number;
}): string | null => {
// Browsers occasionally send an empty type; the server then infers it from
// the file extension, so only reject explicitly disallowed types.
if (file.type && !allowedTypes.includes(file.type)) {
return `Unsupported file type: ${file.type}`;
}
if (file.size > maxSize) {
return "File size must be 100MB or less";
}
return null;
};
export const formatFileSize = (bytes: number) => { export const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;