diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 655eac9..c875552 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -16,6 +16,7 @@ import { ChevronDown, ChevronRight, Bell, + Paperclip, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -119,6 +120,15 @@ const tenantAdminPlatformMenu: MenuItem[] = [ ], requiredPermission: { resource: "document" }, }, + { + icon: Paperclip, + label: "File Attachment Services", + isGroup: true, + children: [ + { label: "Files List", path: "/tenant/files", requiredPermission: { resource: "files" } }, + ], + requiredPermission: { resource: "files" }, + }, { icon: Package, label: "Modules", path: "/tenant/modules" }, ]; diff --git a/src/components/shared/FileShareModal.tsx b/src/components/shared/FileShareModal.tsx new file mode 100644 index 0000000..fc34e59 --- /dev/null +++ b/src/components/shared/FileShareModal.tsx @@ -0,0 +1,234 @@ +import React, { useState } from "react"; +import { + Share2, + Calendar, + Download, + Copy, + Check, + Loader2, + Clock, + Shield, + ExternalLink, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Modal } from "./Modal"; +import fileAttachmentService, { type FileAttachment } from "@/services/file-attachment-service"; + +interface FileShareModalProps { + isOpen: boolean; + onClose: () => void; + file: FileAttachment; +} + +export const FileShareModal: React.FC = ({ + isOpen, + onClose, + file, +}) => { + const [expiresInHours, setExpiresInHours] = useState(24); + const [maxDownloads, setMaxDownloads] = useState(""); + const [permissions, setPermissions] = useState<"view" | "download">("download"); + + const [isSharing, setIsSharing] = useState(false); + const [shareData, setShareData] = useState<{ url: string; token: string } | null>(null); + const [copied, setCopied] = useState(false); + + const handleCreateShare = async () => { + setIsSharing(true); + try { + const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000/api/v1"; + const res = await fileAttachmentService.createShare(file.id, { + share_type: "link", + permissions, + expires_in_hours: expiresInHours || undefined, + max_downloads: maxDownloads === "" ? null : Number(maxDownloads), + }); + + const fullUrl = `${baseUrl}/files/shared/${res.data.share_token}`; + setShareData({ url: fullUrl, token: res.data.share_token }); + } catch (error) { + console.error("Failed to share:", error); + } finally { + setIsSharing(false); + } + }; + + const copyToClipboard = async () => { + if (!shareData) return; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(shareData.url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } else { + throw new Error("Clipboard API unavailable"); + } + } catch (err) { + const textArea = document.createElement("textarea"); + textArea.value = shareData.url; + textArea.style.position = "fixed"; + textArea.style.left = "-9999px"; + textArea.style.top = "0"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand("copy"); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (copyErr) { + console.error("Fallback copy failed:", copyErr); + } + document.body.removeChild(textArea); + } + }; + + return ( + +
+ {!shareData ? ( + <> + {/* Expiry */} +
+ +
+ {[1, 24, 72, 168].map((h) => ( + + ))} +
+
+ +
+ {/* Max Downloads */} +
+ + setMaxDownloads(e.target.value === "" ? "" : Number(e.target.value))} + placeholder="Unlimited" + className="w-full h-10 border border-[rgba(0,0,0,0.12)] rounded-lg px-3 text-sm focus:outline-none focus:ring-1 focus:ring-[#084cc8] focus:border-[#084cc8]" + /> +
+ + {/* Permissions */} +
+ + +
+
+ + + + ) : ( +
+
+
+
+ +
+

Share Link Generated

+
+

+ Anyone with this link can {permissions === 'download' ? 'download' : 'view'} the file + until {new Date(Date.now() + expiresInHours * 3600000).toLocaleString()}. +

+
+ +
+ +
+
+ + {shareData.url} + +
+ +
+
+ +
+ + +
+
+ )} + +
+ +

+ Links are automatically revoked after expiration or reaching max downloads for security. +

+
+
+
+ ); +}; diff --git a/src/components/shared/FileUploadModal.tsx b/src/components/shared/FileUploadModal.tsx new file mode 100644 index 0000000..952a616 --- /dev/null +++ b/src/components/shared/FileUploadModal.tsx @@ -0,0 +1,675 @@ +/** + * FileUploadModal + * Upload New File modal — drag & drop or click to select, up to 10 files + * Fields: entity_type, entity_id, category_id (select), tags, description + * Matches backend UploadFileSchema exactly + */ + +import { + useCallback, + useRef, + useState, + type ChangeEvent, + type DragEvent, + type ReactElement, + useEffect, +} from "react"; +import { createPortal } from "react-dom"; +import { + X, + Upload, + FileText, + Image, + FileArchive, + AlertCircle, + Table, + CheckCircle2, + Loader2, + RefreshCw, + ChevronDown as ChevronDownIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import fileAttachmentService, { + type CategoriesFilterOptions, +} from "@/services/file-attachment-service"; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants from backend blocked extensions +// ───────────────────────────────────────────────────────────────────────────── +const BLOCKED_EXTENSIONS = [ + ".exe",".bat",".cmd",".sh",".ps1",".msi",".dll",".com",".scr",".vbs",".js", +]; +const MAX_FILES = 10; +const MAX_SIZE_MB = 50; // UI shows 50 MB as the soft warning (backend: 100 MB) + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── +function getExt(name: string) { + return name.slice(((name.lastIndexOf(".") - 1) >>> 0) + 1).toLowerCase(); +} + +function isBlocked(name: string) { + return BLOCKED_EXTENSIONS.includes(getExt(name) ? `.${getExt(name)}` : ""); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +function generateUUID(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // Fallback for non-secure contexts or older browsers + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +function getFileIcon(mime: string, name: string): ReactElement { + if (mime.startsWith("image/")) return ; + if (mime === "application/pdf") return ; + if (mime.includes("spreadsheet") || name.endsWith(".csv") || name.endsWith(".xlsx")) + return ; + if (mime.includes("zip") || mime.includes("archive")) + return ; + return ; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-file state +// ───────────────────────────────────────────────────────────────────────────── +type FileStatus = "idle" | "uploading" | "done" | "error" | "blocked"; + +interface FileEntry { + file: File; + id: string; + status: FileStatus; + progress: number; + error?: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Props +// ───────────────────────────────────────────────────────────────────────────── +export interface FileUploadModalProps { + isOpen: boolean; + onClose: () => void; + onUploaded?: () => void; + /** Pre-fill entity type (e.g. "document") */ + defaultEntityType?: string; + /** Pre-fill entity id */ + defaultEntityId?: string; + categories?: CategoriesFilterOptions["categories"]; + /** For tenant-admin: show all fields; for tenant-user: same but with permission gating done by parent */ + isTenantAdmin?: boolean; +} + +// ───────────────────────────────────────────────────────────────────────────── +// ENTITY TYPE OPTIONS (common entity types in the platform) +// ───────────────────────────────────────────────────────────────────────────── +const ENTITY_TYPES = [ + "document", + "capa", + "training", + "supplier", + "audit", + "project", + "workflow", + "tenant", + "user", + "other", +]; + +// ───────────────────────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────────────────────── +export const FileUploadModal = ({ + isOpen, + onClose, + onUploaded, + defaultEntityType = "", + defaultEntityId = "", + categories = [], + isTenantAdmin = true, +}: FileUploadModalProps): ReactElement | null => { + // ── Form state ── + const [entityType, setEntityType] = useState(defaultEntityType); + const [entityId, setEntityId] = useState(defaultEntityId); + const [categoryInput, setCategoryInput] = useState(""); + const [tagInput, setTagInput] = useState(""); + const [tags, setTags] = useState([]); + const [description, setDescription] = useState(""); + + // ── File state ── + const [files, setFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [uploadSuccess, setUploadSuccess] = useState(false); + const [showCategoryDropdown, setShowCategoryDropdown] = useState(false); + const [categorySearch, setCategorySearch] = useState(""); + const categoryDropdownRef = useRef(null); + + const inputRef = useRef(null); + + // ── Auto-generate Entity ID ── + useEffect(() => { + if (isOpen && !entityId && !defaultEntityId) { + setEntityId(generateUUID()); + } + }, [isOpen, defaultEntityId]); + + // ── Close dropdown on click outside ── + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (categoryDropdownRef.current && !categoryDropdownRef.current.contains(event.target as Node)) { + setShowCategoryDropdown(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // ── Reset on close ── + const handleClose = () => { + if (isUploading) return; + setFiles([]); + setEntityType(defaultEntityType); + setEntityId(defaultEntityId); + setCategoryInput(""); + setTags([]); + setTagInput(""); + setDescription(""); + setUploadError(null); + setUploadSuccess(false); + onClose(); + }; + + // ── Add files ── + const addFiles = useCallback((incoming: File[]) => { + const newEntries: FileEntry[] = incoming + .slice(0, MAX_FILES) + .map((file) => ({ + file, + id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`, + status: isBlocked(file.name) ? "blocked" : "idle", + progress: 0, + error: isBlocked(file.name) ? "Blocked file type" : undefined, + })); + + setFiles((prev) => { + const combined = [...prev, ...newEntries]; + return combined.slice(0, MAX_FILES); + }); + }, []); + + const onDrop = useCallback( + (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const dropped = Array.from(e.dataTransfer.files); + addFiles(dropped); + }, + [addFiles] + ); + + const onInputChange = (e: ChangeEvent) => { + if (e.target.files) addFiles(Array.from(e.target.files)); + e.target.value = ""; + }; + + const removeFile = (id: string) => { + setFiles((prev) => prev.filter((f) => f.id !== id)); + }; + + // ── Tags ── + const addTag = () => { + const t = tagInput.trim(); + if (t && !tags.includes(t)) setTags((prev) => [...prev, t]); + setTagInput(""); + }; + + const removeTag = (t: string) => setTags((prev) => prev.filter((x) => x !== t)); + + // ── Upload ── + const handleUpload = async () => { + if (!entityType.trim()) { setUploadError("Entity Type is required"); return; } + if (!entityId.trim()) { setUploadError("Entity ID is required"); return; } + + const validFiles = files.filter((f) => f.status !== "blocked"); + if (validFiles.length === 0) { setUploadError("No valid files to upload"); return; } + + setIsUploading(true); + setUploadError(null); + + // Mark all valid as uploading + setFiles((prev) => + prev.map((f) => (f.status === "idle" ? { ...f, status: "uploading", progress: 10 } : f)) + ); + + try { + // Find matching category ID if it exists in the list + const matchedCategory = categories.find( + (c) => c.category.toLowerCase() === categoryInput.toLowerCase() + ); + + if (validFiles.length === 1) { + // Single upload + await fileAttachmentService.upload({ + files: [validFiles[0].file], + entity_type: entityType, + entity_id: entityId, + category: matchedCategory ? matchedCategory.category : categoryInput, + category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined, + description: description.trim() || undefined, + tags: tags.length ? tags : undefined, + }); + setFiles((prev) => + prev.map((f) => + f.id === validFiles[0].id ? { ...f, status: "done", progress: 100 } : f + ) + ); + } else { + // Multiple upload + const result = await fileAttachmentService.uploadMultiple( + { + files: validFiles.map((f) => f.file), + entity_type: entityType, + entity_id: entityId, + category: matchedCategory ? matchedCategory.category : categoryInput, + category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined, + description: description.trim() || undefined, + tags: tags.length ? tags : undefined, + }, + (_, percent) => { + setFiles((prev) => + prev.map((f) => + f.status === "uploading" ? { ...f, progress: Math.max(f.progress, percent) } : f + ) + ); + } + ); + + setFiles((prev) => + prev.map((f) => { + const errEntry = result.data.errors.find((e) => e.file === f.file.name); + if (errEntry) return { ...f, status: "error", error: errEntry.error, progress: 0 }; + if (f.status === "uploading") return { ...f, status: "done", progress: 100 }; + return f; + }) + ); + } + + setUploadSuccess(true); + onUploaded?.(); + } catch (err: any) { + const msg = err?.response?.data?.error?.message || err?.message || "Upload failed"; + setUploadError(msg); + setFiles((prev) => + prev.map((f) => + f.status === "uploading" ? { ...f, status: "error", error: msg, progress: 0 } : f + ) + ); + } finally { + setIsUploading(false); + } + }; + + if (!isOpen) return null; + + const validCount = files.filter((f) => f.status !== "blocked").length; + const doneCount = files.filter((f) => f.status === "done").length; + + const content = ( +
+
+ {/* Header */} +
+
+

Upload New File

+

Attach files via File Attachment Service

+
+ +
+ + {/* Scrollable body */} +
+ + {/* Drop Zone */} +
+

Attach Files

+ {files.length === 0 ? ( +
{ e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={onDrop} + onClick={() => inputRef.current?.click()} + className={cn( + "border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all", + isDragging + ? "border-[#084cc8] bg-[#084cc8]/5" + : "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50" + )} + > +
+ +
+
+

Click to upload or drag and drop

+

+ Attach supporting source files via File Attachment Service +

+

PDF, DOCX, XLSX up to {MAX_SIZE_MB}MB

+
+
+ ) : ( +
{ e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={onDrop} + className={cn( + "border-2 border-dashed rounded-xl transition-all", + isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.08)]" + )} + > + {/* Add files button */} +
inputRef.current?.click()} + className="flex items-center justify-center gap-2 py-3 cursor-pointer border-b border-[rgba(0,0,0,0.06)] hover:bg-gray-50 transition-all rounded-t-xl" + > +
+ +
+ Add Files +
+ + {/* File list */} +
+ {files.map((entry) => ( +
+
{getFileIcon(entry.file.type, entry.file.name)}
+
+
+ + {entry.file.name} + + + {formatBytes(entry.file.size)} + + {entry.status === "uploading" && ( + + {entry.progress}% + + )} + {entry.status === "done" && ( + + Complete + + )} + {(entry.status === "blocked" || entry.status === "error") && ( + + {entry.error} + + )} +
+ {entry.status === "uploading" && ( +
+
+
+ )} + {entry.status === "done" && ( +
+
+
+ )} + {(entry.status === "blocked" || entry.status === "error") && ( +
+ )} +
+ {entry.status !== "uploading" && entry.status !== "done" && ( + + )} +
+ ))} +
+
+ )} +

Up to {MAX_FILES} files allowed

+ + {uploadSuccess && ( +
+ + Files upload successfully. +
+ )} +
+ + + + {/* Fields: Entity Type + Entity ID */} +
+
+ + +
+
+ +
+ setEntityId(e.target.value)} + disabled={!!defaultEntityId || isUploading} + placeholder="e.g. PRJ-1204 (UUID)" + className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8] disabled:bg-gray-50 disabled:text-[#9aa6b2]" + /> + {!defaultEntityId && !isUploading && isTenantAdmin && ( + + )} +
+
+
+ + {/* Category Name (Editable Combobox) */} +
+ +
+ { + setCategoryInput(e.target.value); + setCategorySearch(e.target.value); + setShowCategoryDropdown(true); + }} + onFocus={() => setShowCategoryDropdown(true)} + disabled={isUploading} + placeholder="Type or select a category..." + className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]" + /> +
+ {categoryInput && ( + + )} + +
+
+ + {showCategoryDropdown && !isUploading && ( +
+ {categories.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())).length > 0 ? ( + categories + .filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())) + .map((cat) => ( + + )) + ) : ( +
+ {categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"} +
+ )} +
+ )} +
+ + {/* Tags */} +
+ +
+ {tags.map((tag) => ( + + {tag} + + + ))} + setTagInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTag(); + } + }} + placeholder={tags.length === 0 ? "Add a tag..." : ""} + disabled={isUploading} + className="flex-1 min-w-[80px] text-sm outline-none bg-transparent placeholder:text-[#c4cbd6]" + /> +
+
+ + {/* Description */} +
+ +