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
+4
View File
@@ -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",
+40
View File
@@ -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;
+87 -7
View File
@@ -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;
}
},
};
+2
View File
@@ -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({
+37
View File
@@ -0,0 +1,37 @@
/** A file that uploaded successfully via a batch / company-documents endpoint. */
export interface UploadedFile {
file_id: string;
filename: string;
content_type: string;
size: number;
message?: string;
}
/** A file that failed to upload, surfaced so the user can retry it. */
export interface FailedFile {
filename: string;
error: string;
code?: string;
}
/** `data` payload of `POST /companies/:id/documents` (wrapped in ApiResponse). */
export interface UploadDocumentsData {
document_file_ids: string[];
uploaded: UploadedFile[];
failed?: FailedFile[];
}
/** Body of `POST /files/upload/batch` — returned directly, not wrapped. */
export interface BatchUploadResult {
uploaded: UploadedFile[];
failed?: FailedFile[];
message?: string;
}
/** Normalized metadata for a stored file (`GET /files/:id/info`). */
export interface FileInfo {
file_id: string;
filename: string;
content_type: string;
size: number;
}
+2
View File
@@ -47,6 +47,7 @@ export interface ICreateCompanyCredentials {
address: IAddress;
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
View File
@@ -1,4 +1,5 @@
export * from "./CompanyCategories";
export * from "./CompanyDocuments";
export * from "./Customers";
export * from "./Factory";
export * from "./Posts";