diff --git a/.env b/.env deleted file mode 100644 index aef7f2b..0000000 --- a/.env +++ /dev/null @@ -1,4 +0,0 @@ -# VITE_FRONTEND_BASE_URL=http://localhost:5173 -# VITE_API_BASE_URL=http://localhost:3000/api/v1 -VITE_FRONTEND_BASE_URL=https://qasure.tech4bizsolutions.com -VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1 diff --git a/.gitignore b/.gitignore index a547bf3..1859401 100644 --- a/.gitignore +++ b/.gitignore @@ -6,12 +6,14 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +.env node_modules dist dist-ssr *.local + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/src/pages/tenant/CreateDocument.tsx b/src/pages/tenant/CreateDocument.tsx index 121dcca..61e0574 100644 --- a/src/pages/tenant/CreateDocument.tsx +++ b/src/pages/tenant/CreateDocument.tsx @@ -2,10 +2,10 @@ import { useEffect, useState, type ReactElement } from "react"; import { useNavigate } from "react-router-dom"; import { Layout } from "@/components/layout/Layout"; import { FormField, FormSelect, FormTextArea, PrimaryButton, RichTextEditor } from "@/components/shared"; -import { documentService } from "@/services/document-service"; +import { documentService, type FileAttachmentItem } from "@/services/document-service"; import type { DocumentCategory } from "@/types/document"; import { showToast } from "@/utils/toast"; -import { ArrowLeft, FileText, Info } from "lucide-react"; +import { ArrowLeft, FileText, Info, Paperclip } from "lucide-react"; const CreateDocument = (): ReactElement => { const navigate = useNavigate(); @@ -22,6 +22,16 @@ const CreateDocument = (): ReactElement => { const [types, setTypes] = useState>([]); const [categories, setCategories] = useState([]); + // File attachment fields + const [files, setFiles] = useState([]); + const [selectedFileId, setSelectedFileId] = useState(""); + const [fileName, setFileName] = useState(""); + const [filePath, setFilePath] = useState(""); + const [fileSize, setFileSize] = useState(undefined); + const [mimeType, setMimeType] = useState(""); + const [fileHash, setFileHash] = useState(""); + const [isLoadingFiles, setIsLoadingFiles] = useState(false); + useEffect(() => { const loadLookups = async (): Promise => { try { @@ -36,8 +46,66 @@ const CreateDocument = (): ReactElement => { } }; void loadLookups(); + void loadFiles(); }, []); + const loadFiles = async (): Promise => { + setIsLoadingFiles(true); + try { + const res = await documentService.listFiles(); + setFiles(res.data || []); + } catch { + showToast.error("Failed to load files"); + } finally { + setIsLoadingFiles(false); + } + }; + + const handleFileSelect = async (fileId: string): Promise => { + setSelectedFileId(fileId); + if (!fileId) { + // Clear file fields when deselected + setFileName(""); + setFilePath(""); + setFileSize(undefined); + setMimeType(""); + setFileHash(""); + setContentHtml(""); + setContent(""); + return; + } + + const selected = files.find((f) => f.id === fileId); + if (!selected) return; + + // Populate file metadata fields + setFileName(selected.original_name); + setFilePath(selected.file_path); + setFileSize(selected.file_size); + setMimeType(selected.mime_type); + setFileHash(selected.checksum); + + // Extract actual file content from backend + try { + showToast.success(`Extracting content from "${selected.original_name}"...`); + const res = await documentService.getFileContent(fileId); + if (res.success && res.data) { + setContentHtml(res.data.html || ""); + setContent(res.data.text || ""); + showToast.success(`Content loaded from "${selected.original_name}"`); + } else { + showToast.error("Failed to extract file content"); + } + } catch (err: any) { + const msg = err?.response?.data?.error?.message || "Failed to extract file content"; + showToast.error(msg); + // Fallback: set a placeholder + const html = `

Document sourced from file: ${selected.original_name}

`; + setContentHtml(html); + setContent(`Document sourced from file: ${selected.original_name}`); + } + }; + const onSubmit = async (event: React.FormEvent): Promise => { event.preventDefault(); if (!title.trim() || !documentType) { @@ -60,6 +128,11 @@ const CreateDocument = (): ReactElement => { .filter(Boolean), content: content.trim() || undefined, content_html: contentHtml.trim() || undefined, + file_name: fileName || undefined, + file_path: filePath || undefined, + file_size: fileSize, + mime_type: mimeType || undefined, + file_hash: fileHash || undefined, }); showToast.success("Document created successfully"); navigate(`/tenant/documents/${response.data.id}`); @@ -178,6 +251,39 @@ const CreateDocument = (): ReactElement => { + {/* File Attachment Selection */} +
+
+ +

Attach File (Optional)

+
+

+ Select a previously uploaded file to automatically populate content and file metadata. +

+ ({ + value: f.id, + label: `${f.original_name} (${f.file_size_formatted})`, + })), + ]} + placeholder={isLoadingFiles ? "Loading files..." : "Select a file to attach"} + /> + + {selectedFileId && ( +
+

File: {fileName}

+

Type: {mimeType}

+

Size: {fileSize ? `${(fileSize / 1024 / 1024).toFixed(2)} MB` : "-"}

+

Hash: {fileHash ? fileHash.substring(0, 16) + "..." : "-"}

+
+ )} +
+

Initial Content

@@ -231,4 +337,3 @@ const CreateDocument = (): ReactElement => { }; export default CreateDocument; - diff --git a/src/pages/tenant/TenantLogin.tsx b/src/pages/tenant/TenantLogin.tsx index 57cd7a5..3d00777 100644 --- a/src/pages/tenant/TenantLogin.tsx +++ b/src/pages/tenant/TenantLogin.tsx @@ -186,7 +186,8 @@ const TenantLogin = (): ReactElement => { className="text-[18px] font-bold tracking-[-0.36px]" style={{ color: theme?.secondary_color || '#23dce1' }} > - {logoUrl ? '' : 'QAssure'} + {/* {logoUrl ? '' : 'QAssure'} */} + QAssure
-
Tenant
diff --git a/src/pages/tenant/ViewDocument.tsx b/src/pages/tenant/ViewDocument.tsx index 45a2e17..bb32c25 100644 --- a/src/pages/tenant/ViewDocument.tsx +++ b/src/pages/tenant/ViewDocument.tsx @@ -10,11 +10,11 @@ import { SecondaryButton, type Column, } from "@/components/shared"; -import { documentService } from "@/services/document-service"; +import { documentService, type FileAttachmentItem } from "@/services/document-service"; import { workflowService } from "@/services/workflow-service"; import type { DocumentDetail, DocumentVersion } from "@/types/document"; import { showToast } from "@/utils/toast"; -import { ChevronDown, Plus } from "lucide-react"; +import { ChevronDown, Paperclip, Plus } from "lucide-react"; const formatDateTime = (value?: string | null): string => { if (!value) return "-"; @@ -74,6 +74,16 @@ const ViewDocument = (): ReactElement => { const [isMajorVersion, setIsMajorVersion] = useState(false); const [isVersionSaving, setIsVersionSaving] = useState(false); + // File attachment fields for new version + const [versionFiles, setVersionFiles] = useState([]); + const [versionSelectedFileId, setVersionSelectedFileId] = useState(""); + const [versionFileName, setVersionFileName] = useState(""); + const [versionFilePath, setVersionFilePath] = useState(""); + const [versionFileSize, setVersionFileSize] = useState(undefined); + const [versionMimeType, setVersionMimeType] = useState(""); + const [versionFileHash, setVersionFileHash] = useState(""); + const [isLoadingVersionFiles, setIsLoadingVersionFiles] = useState(false); + useEffect(() => { if (!id) return; @@ -98,8 +108,60 @@ const ViewDocument = (): ReactElement => { }; void loadDocument(); + void loadVersionFiles(); }, [id]); + const loadVersionFiles = async (): Promise => { + setIsLoadingVersionFiles(true); + try { + const res = await documentService.listFiles(); + setVersionFiles(res.data || []); + } catch { + // silent + } finally { + setIsLoadingVersionFiles(false); + } + }; + + const handleVersionFileSelect = async (fileId: string): Promise => { + setVersionSelectedFileId(fileId); + if (!fileId) { + setVersionFileName(""); + setVersionFilePath(""); + setVersionFileSize(undefined); + setVersionMimeType(""); + setVersionFileHash(""); + return; + } + + const selected = versionFiles.find((f) => f.id === fileId); + if (!selected) return; + + setVersionFileName(selected.original_name); + setVersionFilePath(selected.file_path); + setVersionFileSize(selected.file_size); + setVersionMimeType(selected.mime_type); + setVersionFileHash(selected.checksum); + + try { + showToast.success(`Extracting content from "${selected.original_name}"...`); + const res = await documentService.getFileContent(fileId); + if (res.success && res.data) { + setNewVersionContentHtml(res.data.html || ""); + setNewVersionContent(res.data.text || ""); + showToast.success(`Content loaded from "${selected.original_name}"`); + } else { + showToast.error("Failed to extract file content"); + } + } catch (err: any) { + const msg = err?.response?.data?.error?.message || "Failed to extract file content"; + showToast.error(msg); + const html = `

Document sourced from file: ${selected.original_name}

`; + setNewVersionContentHtml(html); + setNewVersionContent(`Document sourced from file: ${selected.original_name}`); + } + }; + const refreshData = async (): Promise => { if (!id) return; const [documentRes, versionsRes] = await Promise.all([ @@ -224,11 +286,22 @@ const ViewDocument = (): ReactElement => { change_reason: newVersionChangeReason, change_summary: newVersionChangeSummary.trim() || undefined, is_major_version: isMajorVersion, + file_name: versionFileName || undefined, + file_path: versionFilePath || undefined, + file_size: versionFileSize, + mime_type: versionMimeType || undefined, + file_hash: versionFileHash || undefined, }); showToast.success("New version created successfully"); setShowNewVersionForm(false); setNewVersionChangeSummary(""); setIsMajorVersion(false); + setVersionSelectedFileId(""); + setVersionFileName(""); + setVersionFilePath(""); + setVersionFileSize(undefined); + setVersionMimeType(""); + setVersionFileHash(""); await refreshData(); } catch (err: any) { showToast.error( @@ -432,6 +505,39 @@ const ViewDocument = (): ReactElement => { {showNewVersionForm && (

Create New Version

+ + {/* File Attachment Selection */} +
+
+ + Load Content From File (Optional) +
+

+ Select a file to extract and auto-fill content below. +

+ void handleVersionFileSelect(val)} + options={[ + { value: "", label: "— None —" }, + ...versionFiles.map((f) => ({ + value: f.id, + label: `${f.original_name} (${f.file_size_formatted})`, + })), + ]} + placeholder={isLoadingVersionFiles ? "Loading files..." : "Select a file to attach"} + /> + {versionSelectedFileId && ( +
+

File: {versionFileName}

+

Type: {versionMimeType}

+

Size: {versionFileSize ? `${(versionFileSize / 1024 / 1024).toFixed(2)} MB` : "-"}

+

Hash: {versionFileHash ? versionFileHash.substring(0, 16) + "..." : "-"}

+
+ )} +
+ ; content?: string; content_html?: string; + file_name?: string; + file_path?: string; + file_size?: number; + mime_type?: string; + file_hash?: string; +} + +export interface FileAttachmentItem { + id: string; + original_name: string; + stored_name: string; + file_path: string; + mime_type: string; + file_size: number; + checksum: string; + file_size_formatted: string; + category: string; + description: string; + uploaded_by_email: string; + created_at: string; } export interface CreateCategoryPayload { @@ -47,9 +67,24 @@ export interface CreateDocumentVersionPayload { change_summary?: string; change_reason?: string; is_major_version?: boolean; + file_name?: string; + file_path?: string; + file_size?: number; + mime_type?: string; + file_hash?: string; } export const documentService = { + listFiles: async (): Promise<{ success: boolean; data: FileAttachmentItem[]; pagination: { total: number; limit: number; offset: number } }> => { + const response = await apiClient.get<{ success: boolean; data: FileAttachmentItem[]; pagination: { total: number; limit: number; offset: number } }>("/files"); + return response.data; + }, + + getFileContent: async (fileId: string): Promise<{ success: boolean; data: { html: string; text: string; original_name: string; file_path: string; file_size: number; mime_type: string; checksum: string } }> => { + const response = await apiClient.get<{ success: boolean; data: { html: string; text: string; original_name: string; file_path: string; file_size: number; mime_type: string; checksum: string } }>(`/files/${fileId}/content`); + return response.data; + }, + list: async (params: ListDocumentsParams): Promise => { const queryParams = new URLSearchParams(); if (params.status) queryParams.append("status", params.status);