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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user