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
+257 -291
View File
@@ -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,189 +307,129 @@ 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>
)}
<div className="sm:col-span-2">
<InputGroup
{...register("address.street", {
required: { message: FormErrorMessages.required, value: true },
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"
/>
<FieldError message={errors.address?.street?.message} />
</div>
<div>
<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"
/>
<FieldError message={errors.address?.city?.message} />
</div>
<div>
<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"
/>
<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 title="Address" className="grid grid-cols-1 gap-5">
<InputGroup
{...register("address.street", {
required: { message: FormErrorMessages.required, value: true },
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">
<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 {
label: c.name,
value: c.id,
};
})
? 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,20 +471,25 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
setValue={setValue}
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
{...register("tags.specializations")}
label="Specializations"
name="tags.specializations"
watch={watch}
setValue={setValue}
placeholder=""
<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)}
/>
{errors.tags?.specializations && (
<p className="mt-1 text-sm text-red-500">
{errors.tags.specializations.message}
</p>
)}
</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