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:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import Loading from "@/components/loading";
|
||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||
import DocumentItem from "@/components/forms/companies/documents/DocumentItem";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { useCompanyDetails } from "@/hooks/queries";
|
||||
@@ -72,6 +73,9 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
if (isLoading) return <Loading />;
|
||||
const company = data.data;
|
||||
const website = company.website?.trim();
|
||||
const documentFileIds: string[] = Array.isArray(company.document_file_ids)
|
||||
? company.document_file_ids
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -215,6 +219,17 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
)}
|
||||
</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>
|
||||
</ShowcaseSection>
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,7 @@ import FormFooter from "@/components/ui/FormFooter";
|
||||
import { REGEX } from "@/constants/regex";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import { ICreateCompanyCredentials } from "@/lib/api/types/Company";
|
||||
import CompanyDocuments from "./documents/CompanyDocuments";
|
||||
import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
|
||||
|
||||
interface IProps {
|
||||
@@ -18,6 +19,12 @@ interface IProps {
|
||||
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 {
|
||||
errors,
|
||||
@@ -32,37 +39,41 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
defaultValues,
|
||||
id,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-cols-1 items-start gap-9 rounded-xl bg-gray-1 p-10 dark:bg-gray-7 md:grid-cols-2"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex flex-col gap-9">
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-bold text-dark dark:text-white">
|
||||
{editMode ? "Edit company" : "New company"}
|
||||
</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
|
||||
title="Company Information"
|
||||
className="flex flex-col gap-5"
|
||||
title="Company information"
|
||||
rootClassName="lg:col-span-2"
|
||||
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
|
||||
>
|
||||
<InputGroup
|
||||
{...register("name", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: FormErrorMessages.minLength(2),
|
||||
},
|
||||
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||
maxLength: {
|
||||
value: 200,
|
||||
message: FormErrorMessages.maxLength(200),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
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
|
||||
{...register("email", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -71,15 +82,13 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.invalidEmail,
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("phone", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -96,19 +105,18 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.invalidPattern,
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Phone"
|
||||
name="phone"
|
||||
required
|
||||
type="tel"
|
||||
placeholder="+971 50 123 4567"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.phone.message}</p>
|
||||
)}
|
||||
<Select
|
||||
{...register("type", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
})}
|
||||
errors={errors}
|
||||
name="type"
|
||||
label="Company type"
|
||||
required
|
||||
@@ -122,47 +130,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
defaultValue="Private"
|
||||
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
|
||||
{...register("industry", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -172,36 +139,45 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.maxLength(100),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Industry"
|
||||
name="industry"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. construction, software, logistics"
|
||||
/>
|
||||
{errors.industry && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.industry.message}
|
||||
</p>
|
||||
)}
|
||||
<TextAreaGroup
|
||||
label="Description"
|
||||
{...register("description", {
|
||||
minLength: {
|
||||
value: 10,
|
||||
message: FormErrorMessages.minLength(10),
|
||||
},
|
||||
<InputGroup
|
||||
{...register("registration_number", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
||||
maxLength: {
|
||||
value: 1000,
|
||||
message: FormErrorMessages.maxLength(1000),
|
||||
value: 50,
|
||||
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
|
||||
{...register("website", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -215,22 +191,79 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.invalidPattern,
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Website"
|
||||
required
|
||||
name="website"
|
||||
type="text"
|
||||
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 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
|
||||
{...register("language")}
|
||||
errors={errors}
|
||||
name="language"
|
||||
label="Language"
|
||||
items={[
|
||||
@@ -246,13 +279,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
defaultValue="en"
|
||||
prefixIcon={<GlobeIcon />}
|
||||
/>
|
||||
{errors.language && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.language.message}
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
{...register("currency")}
|
||||
errors={errors}
|
||||
name="currency"
|
||||
label="Currency"
|
||||
items={[
|
||||
@@ -270,11 +299,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
defaultValue="en"
|
||||
prefixIcon={<MoneyIcon />}
|
||||
/>
|
||||
{errors.currency && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.currency.message}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("timezone", {
|
||||
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
|
||||
@@ -283,72 +307,19 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.maxLength(50),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Timezone"
|
||||
name="timezone"
|
||||
type="text"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
{errors.timezone && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.timezone.message}
|
||||
</p>
|
||||
)}
|
||||
</ShowcaseSection>
|
||||
</div>
|
||||
<div className="flex flex-col gap-9">
|
||||
|
||||
<ShowcaseSection
|
||||
title="Business Information"
|
||||
className="grid grid-cols-1 gap-5"
|
||||
title="Address"
|
||||
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) },
|
||||
})}
|
||||
label="Employee Count"
|
||||
name="employee_count"
|
||||
type="number"
|
||||
/>
|
||||
{errors.employee_count && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.employee_count.message}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("annual_revenue", {
|
||||
min: { value: 0, message: FormErrorMessages.min(0) },
|
||||
max: {
|
||||
value: 999999999999,
|
||||
message: FormErrorMessages.max(999999999999),
|
||||
},
|
||||
})}
|
||||
label="Annual Revenue"
|
||||
name="annual_revenue"
|
||||
type="number"
|
||||
/>
|
||||
{errors.annual_revenue && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.annual_revenue.message}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("founded_year", {
|
||||
min: { value: 1800, message: FormErrorMessages.min(1800) },
|
||||
max: {
|
||||
value: 2100,
|
||||
message: FormErrorMessages.max(2100),
|
||||
},
|
||||
})}
|
||||
label="Founded Year"
|
||||
name="founded_year"
|
||||
type="number"
|
||||
/>
|
||||
{errors.founded_year && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.founded_year.message}
|
||||
</p>
|
||||
)}
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Address" className="grid grid-cols-1 gap-5">
|
||||
<div className="sm:col-span-2">
|
||||
<InputGroup
|
||||
{...register("address.street", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -367,11 +338,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
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>
|
||||
)}
|
||||
<FieldError message={errors.address?.street?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<InputGroup
|
||||
{...register("address.city", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -390,11 +359,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
type="text"
|
||||
placeholder="City or town"
|
||||
/>
|
||||
{errors.address?.city && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.address.city.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.address?.city?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<InputGroup
|
||||
{...register("address.state", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -413,11 +380,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
required
|
||||
placeholder="State, province, or region"
|
||||
/>
|
||||
{errors.address?.state && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.address.state.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.address?.state?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<InputGroup
|
||||
{...register("address.postal_code", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -436,11 +401,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
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>
|
||||
)}
|
||||
<FieldError message={errors.address?.postal_code?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<InputGroup
|
||||
{...register("address.country", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -459,13 +422,14 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
required
|
||||
placeholder="Full country name"
|
||||
/>
|
||||
{errors.address?.country && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.address.country.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.address?.country?.message} />
|
||||
</div>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Tags" className="grid grid-cols-1 gap-5">
|
||||
|
||||
<ShowcaseSection
|
||||
title="Tags"
|
||||
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
|
||||
>
|
||||
<TagInput
|
||||
{...register("tags.keywords")}
|
||||
name="tags.keywords"
|
||||
@@ -482,12 +446,10 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
label="Categories"
|
||||
items={
|
||||
companyCategories
|
||||
? companyCategories?.data?.categories?.map((c) => {
|
||||
return {
|
||||
? companyCategories?.data?.categories?.map((c) => ({
|
||||
label: c.name,
|
||||
value: c.id,
|
||||
};
|
||||
})
|
||||
}))
|
||||
: []
|
||||
}
|
||||
placeholder=""
|
||||
@@ -501,7 +463,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
setValue={setValue}
|
||||
placeholder=""
|
||||
/>
|
||||
|
||||
<TagInput
|
||||
{...register("tags.certifications")}
|
||||
label="Certifications"
|
||||
@@ -510,7 +471,7 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
setValue={setValue}
|
||||
placeholder=""
|
||||
/>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<TagInput
|
||||
{...register("tags.specializations")}
|
||||
label="Specializations"
|
||||
@@ -519,11 +480,16 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
setValue={setValue}
|
||||
placeholder=""
|
||||
/>
|
||||
{errors.tags?.specializations && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.tags.specializations.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.tags?.specializations?.message} />
|
||||
</div>
|
||||
</ShowcaseSection>
|
||||
|
||||
<ShowcaseSection title="Documents" rootClassName="lg:col-span-2">
|
||||
<CompanyDocuments
|
||||
companyId={editMode ? id : undefined}
|
||||
value={watch("document_file_ids") ?? []}
|
||||
onChange={(ids) => setValue("document_file_ids", ids)}
|
||||
/>
|
||||
</ShowcaseSection>
|
||||
</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,
|
||||
employee_count: data.employee_count ? +data.employee_count : 0,
|
||||
annual_revenue: data.annual_revenue ? +data.annual_revenue : 0,
|
||||
document_file_ids: data.document_file_ids ?? [],
|
||||
tags: {
|
||||
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
|
||||
categories: data?.tags?.categories?.length
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from "./useUsersQueries";
|
||||
export * from "./useAdminListQuery";
|
||||
export * from "./useSelectSearchQuery";
|
||||
export * from "./useDashboardQueries";
|
||||
export * from "./useFilesQueries";
|
||||
|
||||
@@ -64,6 +64,24 @@ export const useCompanyDetails = (id: string) => {
|
||||
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) => {
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useMemo(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -17,7 +17,10 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
FILES: {
|
||||
UPLOAD: "files/upload",
|
||||
UPLOAD_BATCH: "files/upload/batch",
|
||||
DOWNLOAD: (fileId: string) => `files/${fileId}/download`,
|
||||
INFO: (fileId: string) => `files/${fileId}/info`,
|
||||
DELETE: (fileId: string) => `files/${fileId}`,
|
||||
},
|
||||
PROFILE: {
|
||||
READ: "profile",
|
||||
@@ -29,6 +32,7 @@ export const API_ENDPOINTS = {
|
||||
UPDATE: (id: string) => `companies/${id}`,
|
||||
DELETE: (id: string) => `companies/${id}`,
|
||||
DETAILS: (id: string) => `companies/${id}`,
|
||||
DOCUMENTS: (id: string) => `companies/${id}/documents`,
|
||||
CATEGORIES: {
|
||||
READ_ALL: "company-categories",
|
||||
CREATE: "company-categories",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TCompaniesResponse,
|
||||
TCompanyCategoryApiResponse,
|
||||
TCreateCompanyCategoryCredentials,
|
||||
UploadDocumentsData,
|
||||
} from "../types";
|
||||
|
||||
import { ICreateCompanyCredentials } from "../types";
|
||||
@@ -51,6 +52,45 @@ export const companiesService = {
|
||||
const response = await api.get(API_ENDPOINTS.COMPANIES.DETAILS(id));
|
||||
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) => {
|
||||
try {
|
||||
return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import api from "../axios";
|
||||
import { buildFileDownloadUrl } from "../config";
|
||||
import { API_ENDPOINTS } from "../endpoints";
|
||||
import { BatchUploadResult, FileInfo } from "../types";
|
||||
|
||||
interface IUploadFileParams {
|
||||
file: File;
|
||||
@@ -10,6 +11,23 @@ interface IUploadFileParams {
|
||||
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 = {
|
||||
/**
|
||||
* Uploads a binary file to the shared filestore endpoint and returns the
|
||||
@@ -36,13 +54,7 @@ export const fileService = {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (!progressEvent.total) return;
|
||||
const percent = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total,
|
||||
);
|
||||
onProgress?.(percent);
|
||||
},
|
||||
onUploadProgress: trackUploadProgress(onProgress),
|
||||
});
|
||||
|
||||
const fileId = response?.data?.file_id as string | undefined;
|
||||
@@ -56,4 +68,72 @@ export const fileService = {
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface ICreateCompanyCredentials {
|
||||
address: IAddress;
|
||||
contact_person: IContactPerson;
|
||||
tags: ITags;
|
||||
document_file_ids?: string[];
|
||||
}
|
||||
|
||||
export const companySchema = z.object({
|
||||
@@ -90,6 +91,7 @@ export const companySchema = z.object({
|
||||
certifications: 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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export interface ICreateCompanyCredentials {
|
||||
address: IAddress;
|
||||
contact_person?: IContactPerson;
|
||||
tags: ITags;
|
||||
document_file_ids?: string[];
|
||||
}
|
||||
|
||||
export const companySchema = z.object({
|
||||
@@ -92,6 +93,7 @@ export const companySchema = z.object({
|
||||
certifications: 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({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./CompanyCategories";
|
||||
export * from "./CompanyDocuments";
|
||||
export * from "./Customers";
|
||||
export * from "./Factory";
|
||||
export * from "./Posts";
|
||||
|
||||
@@ -1,106 +1,127 @@
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
import {
|
||||
AxiosError,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "react-toastify";
|
||||
import { getLogoutCallback } from "../contexts/User.ctx";
|
||||
import { API_ENDPOINTS, api } from "../lib/api";
|
||||
import { COOKIE_KEYS } from "../lib/shared/cookies";
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
const SIGN_IN_PATH = "/auth/sign-in";
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
type RetriableRequest = InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
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) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
reject(error);
|
||||
} else if (token) {
|
||||
resolve(token);
|
||||
}
|
||||
});
|
||||
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
export const responseInterceptor = (response: AxiosResponse) => {
|
||||
return response;
|
||||
const queueRequest = (
|
||||
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 originalRequest = error.config as any;
|
||||
const redirectToSignIn = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = SIGN_IN_PATH;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip token refresh for login endpoint - let login errors pass through
|
||||
const isLoginRequest = originalRequest?.url?.includes(
|
||||
API_ENDPOINTS.USER.LOGIN,
|
||||
);
|
||||
|
||||
const isRefreshTokenRequest = originalRequest?.url?.includes(
|
||||
API_ENDPOINTS.USER.REFRESH_TOKEN,
|
||||
);
|
||||
|
||||
// If we get a 401 on refresh token endpoint, logout immediately (token refresh failed)
|
||||
if (error.response?.status === 401 && isRefreshTokenRequest) {
|
||||
/** Logs the user out via the registered callback, falling back to a manual reset. */
|
||||
const forceLogout = () => {
|
||||
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(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!isLoginRequest
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
clearLocalSession();
|
||||
redirectToSignIn();
|
||||
};
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error reporting
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Builds the message shown in the error toast, appending the API-provided
|
||||
* `details` (e.g. field-level validation hints) to the main error message
|
||||
* when available.
|
||||
*/
|
||||
const buildErrorMessage = (data: unknown, fallback: string): string => {
|
||||
const payload = data as ApiErrorData | undefined;
|
||||
const message = payload?.error?.message || payload?.message || fallback;
|
||||
const details = payload?.error?.details;
|
||||
|
||||
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."));
|
||||
}
|
||||
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 {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
const { access_token, refresh_token } = response.data.data as TokenPair;
|
||||
|
||||
if (!access_token || !refresh_token) {
|
||||
throw new Error("Invalid token received from refresh endpoint.");
|
||||
@@ -109,52 +130,79 @@ export const responseErrorHandler = async (error: AxiosError) => {
|
||||
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}`;
|
||||
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);
|
||||
}
|
||||
processQueue(null, access_token);
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(
|
||||
error.response?.data as {
|
||||
message: string;
|
||||
error: { code: string; message: string };
|
||||
if (
|
||||
isUnauthorized &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry &&
|
||||
!isLoginRequest(originalRequest)
|
||||
) {
|
||||
return handleUnauthorized(originalRequest);
|
||||
}
|
||||
)?.error?.message || error.message;
|
||||
toast.error(errorMessage);
|
||||
|
||||
notifyError(error);
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
@@ -193,6 +193,59 @@ export const validateImageFile = ({
|
||||
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) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
|
||||
Reference in New Issue
Block a user