feat: implement file attachment management system including listing, viewing, uploading, and secure link sharing
This commit is contained in:
parent
26566f6620
commit
873d5af758
@ -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" },
|
||||
];
|
||||
|
||||
|
||||
234
src/components/shared/FileShareModal.tsx
Normal file
234
src/components/shared/FileShareModal.tsx
Normal file
@ -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<FileShareModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
file,
|
||||
}) => {
|
||||
const [expiresInHours, setExpiresInHours] = useState<number>(24);
|
||||
const [maxDownloads, setMaxDownloads] = useState<number | "">("");
|
||||
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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Share File"
|
||||
description={file.original_name}
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{!shareData ? (
|
||||
<>
|
||||
{/* Expiry */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-[#9aa6b2] uppercase tracking-wider flex items-center gap-2">
|
||||
<Clock className="w-3 h-3" />
|
||||
Expires In
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[1, 24, 72, 168].map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setExpiresInHours(h)}
|
||||
className={cn(
|
||||
"py-2 text-xs font-medium rounded-lg border transition-all",
|
||||
expiresInHours === h
|
||||
? "bg-[#084cc8] border-[#084cc8] text-white shadow-sm"
|
||||
: "border-[rgba(0,0,0,0.08)] text-[#475569] hover:border-[#084cc8]/30 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
{h === 1 ? "1 Hr" : h === 24 ? "1 Day" : h === 72 ? "3 Days" : "7 Days"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Max Downloads */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-[#9aa6b2] uppercase tracking-wider flex items-center gap-2">
|
||||
<Download className="w-3 h-3" />
|
||||
Max Downloads
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxDownloads}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-[#9aa6b2] uppercase tracking-wider flex items-center gap-2">
|
||||
<Shield className="w-3 h-3" />
|
||||
Permission
|
||||
</label>
|
||||
<select
|
||||
value={permissions}
|
||||
onChange={(e) => setPermissions(e.target.value as any)}
|
||||
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]"
|
||||
>
|
||||
<option value="view">View Only</option>
|
||||
<option value="download">Download</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreateShare}
|
||||
disabled={isSharing}
|
||||
className="w-full h-11 bg-[#084cc8] hover:bg-[#0640aa] text-white rounded-xl text-sm font-bold transition-all shadow-lg shadow-[#084cc8]/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSharing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating Link...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share2 className="w-4 h-4" />
|
||||
Generate Secure Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Check className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<p className="text-sm font-bold text-emerald-800">Share Link Generated</p>
|
||||
</div>
|
||||
<p className="text-xs text-emerald-600/80 leading-relaxed">
|
||||
Anyone with this link can {permissions === 'download' ? 'download' : 'view'} the file
|
||||
until {new Date(Date.now() + expiresInHours * 3600000).toLocaleString()}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-bold text-[#9aa6b2] uppercase tracking-widest">Share URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-11 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-xl px-3 flex items-center overflow-hidden">
|
||||
<code className="text-[11px] text-[#084cc8] truncate font-mono">
|
||||
{shareData.url}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className={cn(
|
||||
"w-11 h-11 rounded-xl flex items-center justify-center transition-all",
|
||||
copied ? "bg-emerald-500 text-white shadow-emerald-500/30" : "bg-[#084cc8] text-white shadow-[#084cc8]/30 hover:scale-105"
|
||||
)}
|
||||
>
|
||||
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => setShareData(null)}
|
||||
className="flex-1 h-10 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
|
||||
>
|
||||
Create Another
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(shareData.url, '_blank')}
|
||||
className="h-10 px-4 flex items-center gap-2 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Test Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 mt-6 border-t border-[rgba(0,0,0,0.06)] flex items-center gap-2">
|
||||
<Calendar className="w-3.5 h-3.5 text-[#9aa6b2]" />
|
||||
<p className="text-[10px] text-[#9aa6b2] leading-tight">
|
||||
Links are automatically revoked after expiration or reaching max downloads for security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
675
src/components/shared/FileUploadModal.tsx
Normal file
675
src/components/shared/FileUploadModal.tsx
Normal file
@ -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 <Image className="w-4 h-4 text-emerald-500" />;
|
||||
if (mime === "application/pdf") return <FileText className="w-4 h-4 text-red-500" />;
|
||||
if (mime.includes("spreadsheet") || name.endsWith(".csv") || name.endsWith(".xlsx"))
|
||||
return <Table className="w-4 h-4 text-green-600" />;
|
||||
if (mime.includes("zip") || mime.includes("archive"))
|
||||
return <FileArchive className="w-4 h-4 text-yellow-500" />;
|
||||
return <FileText className="w-4 h-4 text-blue-500" />;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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<string[]>([]);
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// ── File state ──
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
|
||||
const [categorySearch, setCategorySearch] = useState("");
|
||||
const categoryDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const dropped = Array.from(e.dataTransfer.files);
|
||||
addFiles(dropped);
|
||||
},
|
||||
[addFiles]
|
||||
);
|
||||
|
||||
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
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 = (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-[rgba(15,23,42,0.55)] backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-[0px_25px_50px_-12px_rgba(0,0,0,0.25)] w-full max-w-[500px] max-h-[92vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4 border-b border-[rgba(0,0,0,0.08)] shrink-0">
|
||||
<div>
|
||||
<h2 className="text-[17px] font-semibold text-[#0e1b2a]">Upload New File</h2>
|
||||
<p className="text-sm text-[#9aa6b2] mt-0.5">Attach files via File Attachment Service</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isUploading}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-[#6b7280] disabled:opacity-40"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
|
||||
{files.length === 0 ? (
|
||||
<div
|
||||
onDragOver={(e) => { 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"
|
||||
)}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center">
|
||||
<Upload className="w-5 h-5 text-[#084cc8]" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-[#0e1b2a]">Click to upload or drag and drop</p>
|
||||
<p className="text-xs text-[#9aa6b2] mt-0.5">
|
||||
Attach supporting source files via File Attachment Service
|
||||
</p>
|
||||
<p className="text-xs text-[#9aa6b2]">PDF, DOCX, XLSX up to {MAX_SIZE_MB}MB</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => { 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 */}
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-[#084cc8] flex items-center justify-center">
|
||||
<Upload className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-[#084cc8]">Add Files</span>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className="divide-y divide-[rgba(0,0,0,0.06)]">
|
||||
{files.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="shrink-0">{getFileIcon(entry.file.type, entry.file.name)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-[#0e1b2a] truncate max-w-[180px]">
|
||||
{entry.file.name}
|
||||
</span>
|
||||
<span className="text-xs text-[#9aa6b2] shrink-0">
|
||||
{formatBytes(entry.file.size)}
|
||||
</span>
|
||||
{entry.status === "uploading" && (
|
||||
<span className="text-xs font-semibold text-[#084cc8] shrink-0">
|
||||
{entry.progress}%
|
||||
</span>
|
||||
)}
|
||||
{entry.status === "done" && (
|
||||
<span className="text-xs font-semibold text-emerald-600 shrink-0 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Complete
|
||||
</span>
|
||||
)}
|
||||
{(entry.status === "blocked" || entry.status === "error") && (
|
||||
<span className="text-xs font-semibold text-red-500 shrink-0 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" /> {entry.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.status === "uploading" && (
|
||||
<div className="mt-1.5 h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#084cc8] rounded-full transition-all duration-300"
|
||||
style={{ width: `${entry.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{entry.status === "done" && (
|
||||
<div className="mt-1.5 h-1 bg-emerald-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
{(entry.status === "blocked" || entry.status === "error") && (
|
||||
<div className="mt-1.5 h-1 bg-red-100 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
{entry.status !== "uploading" && entry.status !== "done" && (
|
||||
<button
|
||||
onClick={() => removeFile(entry.id)}
|
||||
className="shrink-0 p-1 rounded hover:bg-gray-100 text-[#9aa6b2]"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-[#9aa6b2] mt-2">Up to {MAX_FILES} files allowed</p>
|
||||
|
||||
{uploadSuccess && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-emerald-600">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-semibold">Files upload successfully.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
|
||||
{/* Fields: Entity Type + Entity ID */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
|
||||
Entity Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={entityType}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
disabled={!!defaultEntityType || isUploading}
|
||||
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg px-3 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8] disabled:bg-gray-50 disabled:text-[#9aa6b2]"
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
|
||||
Entity ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative group/id">
|
||||
<input
|
||||
type="text"
|
||||
value={entityId}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={() => setEntityId(generateUUID())}
|
||||
title="Regenerate ID"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Name (Editable Combobox) */}
|
||||
<div className="relative" ref={categoryDropdownRef}>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
|
||||
Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={categoryInput}
|
||||
onChange={(e) => {
|
||||
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]"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-[#9aa6b2]">
|
||||
{categoryInput && (
|
||||
<button
|
||||
onClick={() => { setCategoryInput(""); setCategorySearch(""); }}
|
||||
className="p-1 hover:text-red-500"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDownIcon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCategoryDropdown && !isUploading && (
|
||||
<div className="absolute top-full left-0 right-0 z-[210] mt-1 bg-white border border-[rgba(0,0,0,0.1)] shadow-xl rounded-xl py-1 max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
{categories.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())).length > 0 ? (
|
||||
categories
|
||||
.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase()))
|
||||
.map((cat) => (
|
||||
<button
|
||||
key={cat.category_id ?? cat.category}
|
||||
onClick={() => {
|
||||
setCategoryInput(cat.category);
|
||||
setShowCategoryDropdown(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm hover:bg-[#084cc8]/5 text-[#475569] hover:text-[#084cc8] transition-colors flex items-center justify-between"
|
||||
>
|
||||
{cat.category}
|
||||
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-xs text-[#9aa6b2] italic">
|
||||
{categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-h-9 border border-[rgba(0,0,0,0.12)] rounded-lg px-2 py-1.5 focus-within:ring-2 focus-within:ring-[#084cc8]/20 focus-within:border-[#084cc8]">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeTag(tag)}
|
||||
disabled={isUploading}
|
||||
className="text-[#9aa6b2] hover:text-red-500"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isUploading}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
placeholder="Description of this file..."
|
||||
className="w-full border border-[rgba(0,0,0,0.12)] rounded-lg px-3 py-2 text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] resize-none focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload error */}
|
||||
{uploadError && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-sm text-red-600">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{uploadError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[rgba(0,0,0,0.08)] shrink-0">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isUploading}
|
||||
className="h-9 px-4 border border-[rgba(0,0,0,0.12)] rounded-lg text-sm font-medium text-[#0e1b2a] hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || files.length === 0 || validCount === 0}
|
||||
className="h-9 px-4 bg-[#112868] hover:bg-[#0c1e52] text-white rounded-lg text-sm font-semibold flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Uploading ({doneCount}/{validCount})
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload {validCount > 0 ? `(${validCount})` : ""}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default FileUploadModal;
|
||||
@ -35,3 +35,6 @@ export { SupplierContactsModal } from './SupplierContactsModal';
|
||||
export { SupplierScorecardsModal } from './SupplierScorecardsModal';
|
||||
export { FormTextArea } from './FormTextArea';
|
||||
export { RichTextEditor } from './RichTextEditor';
|
||||
export { FileUploadModal } from './FileUploadModal';
|
||||
export type { FileUploadModalProps } from './FileUploadModal';
|
||||
export { FileShareModal } from './FileShareModal';
|
||||
615
src/pages/tenant/FileView.tsx
Normal file
615
src/pages/tenant/FileView.tsx
Normal file
@ -0,0 +1,615 @@
|
||||
/**
|
||||
* FileView — File Attachment Services › File Detail
|
||||
*
|
||||
* Matches the design image (second screen):
|
||||
* - Left: file preview (iframe for PDF/HTML, image for images, fallback for others)
|
||||
* - Right panel: File Details (description, category, tags, Properties, Metadata, Technical/Admin section)
|
||||
* - Bottom: Version History tab + Access Log tab
|
||||
* - Share button, Download button
|
||||
* - Admin sees stored path, SHA-256 checksum, edit metadata
|
||||
* - User only sees basic info
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactElement } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
Download,
|
||||
Share2,
|
||||
ChevronLeft,
|
||||
FileText,
|
||||
Copy,
|
||||
X,
|
||||
Check,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import { FileShareModal } from "@/components/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import fileAttachmentService, {
|
||||
type FileAttachment,
|
||||
} from "@/services/file-attachment-service";
|
||||
import type { RootState } from "@/store/store";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) 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]}`;
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
finance: "bg-purple-100 text-purple-700",
|
||||
marketing: "bg-orange-100 text-orange-700",
|
||||
hr: "bg-blue-100 text-blue-700",
|
||||
data: "bg-teal-100 text-teal-700",
|
||||
other: "bg-gray-100 text-gray-600",
|
||||
};
|
||||
|
||||
function getCategoryStyle(cat?: string) {
|
||||
return categoryColors[(cat || "").toLowerCase()] || "bg-gray-100 text-gray-600";
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string): void {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Preview component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setErr(false);
|
||||
fileAttachmentService
|
||||
.getPreviewUrl(file.id)
|
||||
.then((url) => setPreviewUrl(url))
|
||||
.catch(() => setErr(true))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [file.id]);
|
||||
|
||||
const isImage = file.mime_type?.startsWith("image/");
|
||||
const isPdf = file.mime_type === "application/pdf";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-[#084cc8] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (err || !previewUrl) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2]">
|
||||
<FileText className="w-16 h-16 text-gray-200" />
|
||||
<p className="text-sm">Preview not available</p>
|
||||
<p className="text-xs">{file.mime_type}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{/* Zoom controls */}
|
||||
{isImage && (
|
||||
<div className="absolute top-3 right-3 z-10 flex gap-1">
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.max(0.5, z - 0.25))}
|
||||
className="w-7 h-7 flex items-center justify-center bg-white/90 border border-[rgba(0,0,0,0.1)] rounded-md hover:bg-white shadow-sm"
|
||||
>
|
||||
<ZoomOut className="w-3.5 h-3.5 text-[#475569]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.min(3, z + 0.25))}
|
||||
className="w-7 h-7 flex items-center justify-center bg-white/90 border border-[rgba(0,0,0,0.1)] rounded-md hover:bg-white shadow-sm"
|
||||
>
|
||||
<ZoomIn className="w-3.5 h-3.5 text-[#475569]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(1)}
|
||||
className="w-7 h-7 flex items-center justify-center bg-white/90 border border-[rgba(0,0,0,0.1)] rounded-md hover:bg-white shadow-sm"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-[#475569]" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isImage ? (
|
||||
<div className="flex-1 overflow-auto flex items-center justify-center bg-gray-50 p-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.original_name}
|
||||
style={{ transform: `scale(${zoom})`, transformOrigin: "center" }}
|
||||
className="max-w-full rounded-lg shadow transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
) : isPdf || file.mime_type?.startsWith("text/") ? (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
title={file.original_name}
|
||||
className="flex-1 w-full border-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2] bg-gray-50">
|
||||
<FileText className="w-16 h-16 text-gray-200" />
|
||||
<p className="text-sm">No visual preview available</p>
|
||||
<p className="text-xs text-[#c4cbd6]">{file.mime_type}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Version Row
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function VersionRow({ ver, onDownload }: { ver: FileAttachment; onDownload: () => void }): ReactElement {
|
||||
return (
|
||||
<tr className="border-b border-[rgba(0,0,0,0.05)] hover:bg-gray-50/50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[#0e1b2a]">v{ver.version}</span>
|
||||
{ver.is_current_version && (
|
||||
<span className="inline-flex items-center text-[10px] font-semibold bg-emerald-100 text-emerald-700 rounded px-1.5 py-0.5">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[#6b7280] whitespace-nowrap">{formatDate(ver.created_at)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-[10px] font-bold text-blue-700">
|
||||
{(ver.uploaded_by_email || "U")[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm text-[#0e1b2a]">
|
||||
{ver.uploaded_by_email?.split("@")[0] || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[#6b7280]">
|
||||
{ver.file_size_formatted || formatBytes(ver.file_size)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={onDownload}
|
||||
className="text-sm font-medium text-[#084cc8] hover:underline"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main Component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const FileView = (): ReactElement => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const permissions = useSelector((state: RootState) => state.auth.permissions);
|
||||
|
||||
const isTenantAdmin = permissions.some(
|
||||
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "update" || p.action === "*")
|
||||
);
|
||||
|
||||
// ── State ──
|
||||
const [file, setFile] = useState<FileAttachment | null>(null);
|
||||
const [versions, setVersions] = useState<FileAttachment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingMetadata, setEditingMetadata] = useState(false);
|
||||
const [copiedChecksum, setCopiedChecksum] = useState(false);
|
||||
const [copiedPath, setCopiedPath] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
|
||||
// Metadata edit form
|
||||
const [draftDescription, setDraftDescription] = useState("");
|
||||
const [draftTags, setDraftTags] = useState<string[]>([]);
|
||||
const [draftTagInput, setDraftTagInput] = useState("");
|
||||
const [savingMeta, setSavingMeta] = useState(false);
|
||||
|
||||
// ── Load ──
|
||||
const loadFile = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [fileRes, versionsRes] = await Promise.all([
|
||||
fileAttachmentService.getById(id),
|
||||
fileAttachmentService.getVersionHistory(id),
|
||||
]);
|
||||
setFile(fileRes.data);
|
||||
setVersions(versionsRes.data);
|
||||
setDraftDescription(fileRes.data.description || "");
|
||||
setDraftTags(fileRes.data.tags || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || "File not found");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void loadFile(); }, [loadFile]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (file) fileAttachmentService.download(file.id, file.original_name).catch(() => {});
|
||||
};
|
||||
|
||||
const handleSaveMetadata = async () => {
|
||||
if (!file) return;
|
||||
setSavingMeta(true);
|
||||
try {
|
||||
await fileAttachmentService.updateMetadata(file.id, {
|
||||
description: draftDescription,
|
||||
tags: draftTags,
|
||||
});
|
||||
await loadFile();
|
||||
setEditingMetadata(false);
|
||||
} catch {
|
||||
// silence
|
||||
} finally {
|
||||
setSavingMeta(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyChecksum = () => {
|
||||
if (file) { copyToClipboard(file.checksum); setCopiedChecksum(true); setTimeout(() => setCopiedChecksum(false), 1500); }
|
||||
};
|
||||
const copyPath = () => {
|
||||
if (file) { copyToClipboard(file.file_path); setCopiedPath(true); setTimeout(() => setCopiedPath(false), 1500); }
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout currentPage="File Attachment Services">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-[#084cc8] animate-spin" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !file) {
|
||||
return (
|
||||
<Layout currentPage="File Attachment Services">
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-3">
|
||||
<AlertCircle className="w-10 h-10 text-red-400" />
|
||||
<p className="text-[#475569]">{error || "File not found"}</p>
|
||||
<button
|
||||
onClick={() => navigate("/tenant/files")}
|
||||
className="text-sm text-[#084cc8] hover:underline font-medium flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Back to Files
|
||||
</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="File Attachment Services"
|
||||
breadcrumbs={[
|
||||
{ label: "File Attachment Services", path: "/tenant/files" },
|
||||
{ label: "File List", path: "/tenant/files" },
|
||||
{ label: file.original_name },
|
||||
]}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate("/tenant/files")}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 text-[#6b7280] transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-md bg-red-50 border border-red-100 flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[#0e1b2a]">{file.original_name}</h1>
|
||||
<p className="text-xs text-[#9aa6b2]">
|
||||
{file.mime_type} · {file.file_size_formatted || formatBytes(file.file_size)} · Uploaded {formatDate(file.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowShareModal(true)}
|
||||
className="inline-flex items-center gap-2 h-9 px-3 border border-[rgba(0,0,0,0.1)] rounded-lg text-sm font-medium text-[#475569] hover:bg-gray-50 hover:border-[#084cc8]/30 transition-all font-semibold"
|
||||
>
|
||||
<Share2 className="w-3.5 h-3.5" />
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 bg-[#084cc8] hover:bg-[#0640aa] text-white rounded-lg text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main layout: preview + side panel */}
|
||||
<div className="flex gap-4 h-[calc(100vh-15rem)] min-h-0">
|
||||
{/* Preview panel */}
|
||||
<div className="flex-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-xl overflow-hidden flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[rgba(0,0,0,0.06)] shrink-0 bg-gray-50/30">
|
||||
<p className="text-xs font-semibold text-[#9aa6b2]">
|
||||
Preview
|
||||
</p>
|
||||
</div>
|
||||
<FilePreviewPanel file={file} />
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="w-[320px] shrink-0 flex flex-col gap-3 overflow-y-auto custom-scrollbar">
|
||||
{/* File Details */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">File Details</h3>
|
||||
{isTenantAdmin && !editingMetadata && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingMetadata(true);
|
||||
setDraftDescription(file.description || "");
|
||||
setDraftTags(file.tags || []);
|
||||
}}
|
||||
className="text-xs font-medium text-[#084cc8] hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{editingMetadata && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveMetadata}
|
||||
disabled={savingMeta}
|
||||
className="text-xs font-semibold text-emerald-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{savingMeta ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingMetadata(false)}
|
||||
className="text-xs text-[#9aa6b2] hover:text-[#0e1b2a]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3">
|
||||
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1">Description</p>
|
||||
{editingMetadata ? (
|
||||
<textarea
|
||||
value={draftDescription}
|
||||
onChange={(e) => setDraftDescription(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="w-full border border-[rgba(0,0,0,0.12)] rounded-lg px-2.5 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-[#475569] leading-relaxed">
|
||||
{file.description || <span className="text-[#c4cbd6]">No description</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mb-3">
|
||||
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1">Category</p>
|
||||
{file.category ? (
|
||||
<span className={cn("inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize", getCategoryStyle(file.category))}>
|
||||
{file.category}
|
||||
</span>
|
||||
) : <span className="text-sm text-[#c4cbd6]">—</span>}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-3">
|
||||
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1.5">Tags</p>
|
||||
{editingMetadata ? (
|
||||
<div className="flex flex-wrap gap-1.5 border border-[rgba(0,0,0,0.12)] rounded-lg p-2 min-h-9 focus-within:ring-2 focus-within:ring-[#084cc8]/20">
|
||||
{draftTags.map((t) => (
|
||||
<span key={t} className="inline-flex items-center gap-1 bg-gray-100 text-xs font-medium rounded px-1.5 py-0.5">
|
||||
{t}
|
||||
<button onClick={() => setDraftTags((prev) => prev.filter((x) => x !== t))}>
|
||||
<X className="w-3 h-3 text-[#9aa6b2]" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={draftTagInput}
|
||||
onChange={(e) => setDraftTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
const t = draftTagInput.trim();
|
||||
if (t && !draftTags.includes(t)) setDraftTags((prev) => [...prev, t]);
|
||||
setDraftTagInput("");
|
||||
}
|
||||
}}
|
||||
placeholder="Add..."
|
||||
className="flex-1 min-w-12 text-sm outline-none bg-transparent placeholder:text-[#c4cbd6]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(file.tags || []).length > 0 ? (
|
||||
file.tags.map((tag) => (
|
||||
<span key={tag} className="inline-block bg-gray-100 text-[#475569] text-xs font-medium rounded px-2 py-0.5">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-[#c4cbd6]">No tags</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Properties */}
|
||||
<div className="border-t border-[rgba(0,0,0,0.06)] pt-3 mb-3">
|
||||
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">Properties</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ label: "Entity Type", value: file.entity_type },
|
||||
{ label: "Entity ID", value: file.entity_id?.substring(0, 8) + "…" },
|
||||
{ label: "Source Module", value: file.source_module },
|
||||
{ label: "Version", value: `v${file.version}` },
|
||||
{ label: "Downloads", value: String(file.download_count) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex justify-between items-center">
|
||||
<span className="text-xs text-[#9aa6b2]">{label}</span>
|
||||
<span className="text-xs font-medium text-[#0e1b2a]">{value || "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{file.metadata && Object.keys(file.metadata).length > 0 && (
|
||||
<div className="border-t border-[rgba(0,0,0,0.06)] pt-3 mb-3">
|
||||
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">Metadata</p>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(file.metadata).map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#9aa6b2] font-mono">{k}:</span>
|
||||
<span className="text-xs font-medium text-[#0e1b2a] font-mono max-w-[140px] truncate">
|
||||
{typeof v === "object" ? JSON.stringify(v) : String(v)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical — Admin Only */}
|
||||
{isTenantAdmin && (
|
||||
<div className="border-t border-[rgba(0,0,0,0.06)] pt-3">
|
||||
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">Technical (Admin)</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-[10px] text-[#9aa6b2] mb-1 uppercase font-bold tracking-widest">Stored Path</p>
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5 border border-[rgba(0,0,0,0.04)]">
|
||||
<code className="text-[10px] text-[#475569] flex-1 truncate font-mono">
|
||||
{file.file_path}
|
||||
</code>
|
||||
<button onClick={copyPath} className="shrink-0 text-[#9aa6b2] hover:text-[#084cc8]">
|
||||
{copiedPath ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{file.checksum && (
|
||||
<div>
|
||||
<p className="text-[10px] text-[#9aa6b2] mb-1 uppercase font-bold tracking-widest text">SHA-256 Checksum</p>
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5 border border-[rgba(0,0,0,0.04)]">
|
||||
<code className="text-[10px] text-[#475569] flex-1 truncate font-mono">
|
||||
{file.checksum}
|
||||
</code>
|
||||
<button onClick={copyChecksum} className="shrink-0 text-[#9aa6b2] hover:text-[#084cc8]">
|
||||
{copiedChecksum ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version History */}
|
||||
<div className="mt-4 bg-white border border-[rgba(0,0,0,0.08)] rounded-xl overflow-hidden">
|
||||
<div className="flex border-b border-[rgba(0,0,0,0.08)] bg-gray-50/20">
|
||||
<div className="flex items-center gap-2 px-5 py-3 text-sm font-semibold border-b-2 border-[#084cc8] text-[#084cc8]">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Version History
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[rgba(0,0,0,0.06)]">
|
||||
{["Version", "Date", "Uploader", "Size", "Action"].map((h) => (
|
||||
<th key={h} className="px-5 py-3 text-left text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-8 text-center text-sm text-[#9aa6b2]">
|
||||
No version history available
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
versions.map((ver) => (
|
||||
<VersionRow
|
||||
key={ver.id}
|
||||
ver={ver}
|
||||
onDownload={() =>
|
||||
fileAttachmentService.download(ver.id, ver.original_name).catch(() => {})
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileShareModal
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileView;
|
||||
642
src/pages/tenant/FilesList.tsx
Normal file
642
src/pages/tenant/FilesList.tsx
Normal file
@ -0,0 +1,642 @@
|
||||
/**
|
||||
* FilesList — File Attachment Services › Files List
|
||||
*
|
||||
* Matches the design image:
|
||||
* - Data table with File Name, Size, Category, Source Module, Uploaded By, Upload Date, Version, Actions
|
||||
* - Search, Category filter, More Filters dropdown (source_module, tags)
|
||||
* - Upload New File modal
|
||||
* - Pagination
|
||||
* - Tenant-admin sees all admin actions; tenant-user sees limited view (conditional render)
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
Search,
|
||||
Upload,
|
||||
FileText,
|
||||
Image,
|
||||
FileArchive,
|
||||
Table as TableIcon,
|
||||
MoreHorizontal,
|
||||
Download,
|
||||
Eye,
|
||||
Pencil,
|
||||
Trash2,
|
||||
SlidersHorizontal,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import { Pagination } from "@/components/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import fileAttachmentService, {
|
||||
type FileAttachment,
|
||||
type CategoriesFilterOptions,
|
||||
} from "@/services/file-attachment-service";
|
||||
import { FileUploadModal } from "@/components/shared/FileUploadModal";
|
||||
import type { RootState } from "@/store/store";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) 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 getFileIcon(mime: string, name: string): ReactElement {
|
||||
if (mime?.startsWith("image/")) return <Image className="w-4 h-4 text-emerald-500" />;
|
||||
if (mime === "application/pdf") return <FileText className="w-4 h-4 text-red-500" />;
|
||||
if (
|
||||
mime?.includes("spreadsheet") ||
|
||||
name?.endsWith(".csv") ||
|
||||
name?.endsWith(".xlsx") ||
|
||||
name?.endsWith(".xls")
|
||||
)
|
||||
return <TableIcon className="w-4 h-4 text-green-600" />;
|
||||
if (mime?.includes("zip") || mime?.includes("archive"))
|
||||
return <FileArchive className="w-4 h-4 text-yellow-500" />;
|
||||
return <FileText className="w-4 h-4 text-[#084cc8]" />;
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
finance: "bg-purple-100 text-purple-700",
|
||||
marketing: "bg-orange-100 text-orange-700",
|
||||
hr: "bg-blue-100 text-blue-700",
|
||||
data: "bg-teal-100 text-teal-700",
|
||||
legal: "bg-red-100 text-red-700",
|
||||
training: "bg-yellow-100 text-yellow-700",
|
||||
other: "bg-gray-100 text-gray-600",
|
||||
};
|
||||
|
||||
function getCategoryStyle(cat: string) {
|
||||
return categoryColors[cat?.toLowerCase()] || "bg-gray-100 text-gray-600";
|
||||
}
|
||||
|
||||
const moduleColors: Record<string, string> = {
|
||||
platform: "bg-indigo-100 text-indigo-700",
|
||||
document: "bg-blue-100 text-blue-700",
|
||||
capa: "bg-orange-100 text-orange-700",
|
||||
training: "bg-yellow-100 text-yellow-700",
|
||||
supplier: "bg-green-100 text-green-700",
|
||||
audit: "bg-red-100 text-red-700",
|
||||
api_key: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
function getModuleStyle(mod: string) {
|
||||
return moduleColors[mod?.toLowerCase()] || "bg-gray-100 text-gray-600";
|
||||
}
|
||||
|
||||
function getInitials(email: string): string {
|
||||
if (!email) return "?";
|
||||
const parts = email.split("@")[0].split(".");
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() || "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
const avatarColors = [
|
||||
"bg-violet-100 text-violet-700",
|
||||
"bg-blue-100 text-blue-700",
|
||||
"bg-emerald-100 text-emerald-700",
|
||||
"bg-orange-100 text-orange-700",
|
||||
];
|
||||
|
||||
function getAvatarColor(email: string) {
|
||||
const idx = (email?.charCodeAt(0) || 0) % avatarColors.length;
|
||||
return avatarColors[idx];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FilterDropdown (local inline, no shared dependency)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface DropOption { value: string; label: string }
|
||||
|
||||
function FilterPill({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: DropOption[];
|
||||
value: string | null;
|
||||
onChange: (v: string | null) => void;
|
||||
}): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border transition-colors",
|
||||
value
|
||||
? "border-[#084cc8] bg-[#084cc8]/5 text-[#084cc8]"
|
||||
: "border-[rgba(0,0,0,0.1)] bg-white text-[#475569] hover:border-[#084cc8]/30"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{selected && <span className="text-[#084cc8]">: {selected.label}</span>}
|
||||
<ChevronDown className="w-3.5 h-3.5 opacity-60" />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div className="absolute top-full mt-1 left-0 z-20 bg-white border border-[rgba(0,0,0,0.1)] shadow-lg rounded-xl py-1 min-w-[160px]">
|
||||
<button
|
||||
onClick={() => { onChange(null); setOpen(false); }}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 text-sm hover:bg-gray-50",
|
||||
!value ? "font-semibold text-[#0e1b2a]" : "text-[#475569]"
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 text-sm hover:bg-gray-50",
|
||||
value === opt.value ? "font-semibold text-[#0e1b2a]" : "text-[#475569]"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Row action menu
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function ActionMenu({
|
||||
onView,
|
||||
onDownload,
|
||||
onEdit,
|
||||
onDelete,
|
||||
canEdit,
|
||||
canDelete,
|
||||
}: {
|
||||
onView: () => void;
|
||||
onDownload: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-gray-100 text-[#6b7280] transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-20 bg-white rounded-xl border border-[rgba(0,0,0,0.08)] shadow-lg py-1 min-w-[140px]">
|
||||
<button
|
||||
onClick={() => { onView(); setOpen(false); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[#0e1b2a] hover:bg-gray-50"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 text-[#6b7280]" /> View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onDownload(); setOpen(false); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[#0e1b2a] hover:bg-gray-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-[#6b7280]" /> Download
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => { onEdit(); setOpen(false); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[#0e1b2a] hover:bg-gray-50"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 text-[#6b7280]" /> Edit
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => { onDelete(); setOpen(false); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const FilesList = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const permissions = useSelector((state: RootState) => state.auth.permissions);
|
||||
|
||||
// Permission checks
|
||||
const canCreate = permissions.some(
|
||||
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "create" || p.action === "*")
|
||||
);
|
||||
const canUpdate = permissions.some(
|
||||
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "update" || p.action === "*")
|
||||
);
|
||||
const canDelete = permissions.some(
|
||||
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "delete" || p.action === "*")
|
||||
);
|
||||
|
||||
// ── State ──
|
||||
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
|
||||
const [moduleFilter, setModuleFilter] = useState<string | null>(null);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [limit] = useState(10);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
// Filter option data
|
||||
const [categories, setCategories] = useState<CategoriesFilterOptions["categories"]>([]);
|
||||
|
||||
// Upload modal
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
// Deleting
|
||||
const [, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// ── Load categories ──
|
||||
useEffect(() => {
|
||||
fileAttachmentService.getCategoriesFilterOptions().then((res) => {
|
||||
setCategories(res.data.categories);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Load files ──
|
||||
const loadFiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fileAttachmentService.list({
|
||||
search: search.trim() || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
source_module: moduleFilter || undefined,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
setFiles(res.data);
|
||||
setTotal(res.pagination.total);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || "Failed to load files");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [search, categoryFilter, moduleFilter, limit, offset]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
// ── Unique module values for filter ──
|
||||
const moduleOptions = useMemo<DropOption[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const opts: DropOption[] = [];
|
||||
files.forEach((f) => {
|
||||
if (f.source_module && !seen.has(f.source_module)) {
|
||||
seen.add(f.source_module);
|
||||
opts.push({ value: f.source_module, label: f.source_module });
|
||||
}
|
||||
});
|
||||
return opts;
|
||||
}, [files]);
|
||||
|
||||
const categoryOptions = useMemo<DropOption[]>(() =>
|
||||
categories.map((c) => ({ value: c.category, label: c.category })),
|
||||
[categories]);
|
||||
|
||||
// ── Actions ──
|
||||
const handleDownload = (file: FileAttachment) => {
|
||||
fileAttachmentService.download(file.id, file.original_name).catch(() => {});
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("Delete this file?")) return;
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await fileAttachmentService.delete(id);
|
||||
await loadFiles();
|
||||
} catch {
|
||||
// silence
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch("");
|
||||
setCategoryFilter(null);
|
||||
setModuleFilter(null);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Render
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Layout
|
||||
currentPage="File Attachment Services"
|
||||
breadcrumbs={[
|
||||
{ label: "File Attachment Services" },
|
||||
{ label: "File List" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: "Files List",
|
||||
description: "Manage controlled documents across their entire lifecycle.",
|
||||
action: canCreate ? (
|
||||
<button
|
||||
id="upload-new-file-btn"
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 bg-[#112868] hover:bg-[#0c1e52] text-white rounded-lg text-sm font-semibold transition-colors shadow-sm"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
Upload New File
|
||||
</button>
|
||||
) : null,
|
||||
}}
|
||||
>
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||
{/* Filter bar */}
|
||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-5 py-3.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
|
||||
<input
|
||||
id="files-search"
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||
placeholder="Search by name, ID..."
|
||||
className="h-9 w-[240px] pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.1)] rounded-lg text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<FilterPill
|
||||
label="Category"
|
||||
options={categoryOptions}
|
||||
value={categoryFilter}
|
||||
onChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}
|
||||
/>
|
||||
|
||||
{/* More Filters (Source Module) */}
|
||||
<FilterPill
|
||||
label="Source Module"
|
||||
options={moduleOptions}
|
||||
value={moduleFilter}
|
||||
onChange={(v) => { setModuleFilter(v); setCurrentPage(1); }}
|
||||
/>
|
||||
|
||||
{/* More Filters label pill */}
|
||||
{/* <button className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border border-[rgba(0,0,0,0.1)] bg-white text-[#475569] hover:border-[#084cc8]/30 transition-colors">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||
More Filters
|
||||
<ChevronDown className="w-3.5 h-3.5 opacity-60" />
|
||||
</button> */}
|
||||
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm font-medium text-[#6b7280] hover:text-[#0f1724] transition-colors"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[rgba(0,0,0,0.06)]">
|
||||
{["File Name", "Size", "Category", "Source Module", "Uploaded By", "Upload Date", "Version", "Actions"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 8 }).map((__, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-4 bg-gray-100 rounded animate-pulse" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-12 text-center text-sm text-red-500">
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
) : files.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="w-10 h-10 text-gray-200" />
|
||||
<p className="text-sm text-[#9aa6b2]">No files found</p>
|
||||
{canCreate && (
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="mt-2 text-sm font-medium text-[#084cc8] hover:underline"
|
||||
>
|
||||
Upload your first file
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<tr
|
||||
key={file.id}
|
||||
className="hover:bg-[#f6f9ff]/60 transition-colors group"
|
||||
>
|
||||
{/* File Name */}
|
||||
<td className="px-4 py-3 min-w-[200px]">
|
||||
<button
|
||||
onClick={() => navigate(`/tenant/files/${file.id}`)}
|
||||
className="flex items-center gap-2.5 hover:text-[#084cc8] transition-colors text-left"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-md bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
|
||||
{getFileIcon(file.mime_type, file.original_name)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[#0e1b2a] group-hover:text-[#084cc8] truncate max-w-[200px]">
|
||||
{file.original_name}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Size */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className="text-sm text-[#6b7280]">
|
||||
{file.file_size_formatted || formatBytes(file.file_size)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Category */}
|
||||
<td className="px-4 py-3">
|
||||
{file.category ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
|
||||
getCategoryStyle(file.category)
|
||||
)}
|
||||
>
|
||||
{file.category}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[#c4cbd6] text-sm">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Source Module */}
|
||||
<td className="px-4 py-3">
|
||||
{file.source_module ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
|
||||
getModuleStyle(file.source_module)
|
||||
)}
|
||||
>
|
||||
{file.source_module}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[#c4cbd6] text-sm">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Uploaded By */}
|
||||
<td className="px-4 py-3 min-w-[140px]">
|
||||
{file.uploaded_by_email ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
|
||||
getAvatarColor(file.uploaded_by_email)
|
||||
)}
|
||||
>
|
||||
{getInitials(file.uploaded_by_email)}
|
||||
</div>
|
||||
<span className="text-sm text-[#0e1b2a] truncate max-w-[130px]">
|
||||
{file.uploaded_by_email.split("@")[0]}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-[#9aa6b2]">Unknown</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Upload Date */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className="text-sm text-[#6b7280]">{formatDate(file.created_at)}</span>
|
||||
</td>
|
||||
|
||||
{/* Version */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className="text-sm text-[#0e1b2a] font-medium">v{file.version}</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<ActionMenu
|
||||
onView={() => navigate(`/tenant/files/${file.id}`)}
|
||||
onDownload={() => handleDownload(file)}
|
||||
onEdit={() => navigate(`/tenant/files/${file.id}`)}
|
||||
onDelete={() => handleDelete(file.id)}
|
||||
canEdit={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={total}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload modal */}
|
||||
<FileUploadModal
|
||||
isOpen={showUpload}
|
||||
onClose={() => setShowUpload(false)}
|
||||
categories={categories}
|
||||
onUploaded={() => {
|
||||
setShowUpload(false);
|
||||
void loadFiles();
|
||||
}}
|
||||
isTenantAdmin={canCreate}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilesList;
|
||||
@ -23,6 +23,8 @@ const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForR
|
||||
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
||||
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
|
||||
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
||||
const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
|
||||
const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
||||
|
||||
// Loading fallback component
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -125,4 +127,12 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
||||
path: "/tenant/notifications",
|
||||
element: <LazyRoute component={Notifications} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/files",
|
||||
element: <LazyRoute component={FilesList} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/files/:id",
|
||||
element: <LazyRoute component={FileView} />,
|
||||
},
|
||||
];
|
||||
|
||||
371
src/services/file-attachment-service.ts
Normal file
371
src/services/file-attachment-service.ts
Normal file
@ -0,0 +1,371 @@
|
||||
/**
|
||||
* File Attachment Service
|
||||
* Typed API client matching the backend file-attachment.routes.js exactly
|
||||
*/
|
||||
import apiClient from './api-client';
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────
|
||||
|
||||
export interface FileAttachment {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
original_name: string;
|
||||
stored_name: string;
|
||||
file_path: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
file_size_formatted: string;
|
||||
checksum: string;
|
||||
storage_provider: string;
|
||||
storage_bucket: string | null;
|
||||
storage_region: string | null;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
category: string;
|
||||
category_id: string | null;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
version: number;
|
||||
is_current_version: boolean;
|
||||
previous_version_id: string | null;
|
||||
is_public: boolean;
|
||||
access_level: string;
|
||||
download_count: number;
|
||||
has_thumbnail: boolean;
|
||||
thumbnail_path: string | null;
|
||||
metadata: Record<string, any>;
|
||||
scan_status: string;
|
||||
scanned_at: string | null;
|
||||
source_module: string;
|
||||
source_module_id: string | null;
|
||||
uploaded_by: string;
|
||||
uploaded_by_email: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface FileAttachmentPagination {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
success: boolean;
|
||||
data: FileAttachment[];
|
||||
pagination: FileAttachmentPagination;
|
||||
}
|
||||
|
||||
export interface FileDetailResponse {
|
||||
success: boolean;
|
||||
data: FileAttachment;
|
||||
}
|
||||
|
||||
export interface VersionHistoryResponse {
|
||||
success: boolean;
|
||||
data: FileAttachment[];
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
quota: {
|
||||
max_storage: number;
|
||||
used_storage: number;
|
||||
available: number;
|
||||
usage_percent: number;
|
||||
};
|
||||
files: {
|
||||
total: number;
|
||||
images: number;
|
||||
pdfs: number;
|
||||
documents: number;
|
||||
};
|
||||
by_entity: Record<string, { count: number; size: number }>;
|
||||
by_module: Record<string, { count: number; size: number }>;
|
||||
}
|
||||
|
||||
export interface StorageQuota {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
max_storage_bytes: number;
|
||||
max_file_size_bytes: number;
|
||||
used_storage_bytes: number;
|
||||
file_count: number;
|
||||
allowed_mime_types: string[] | null;
|
||||
blocked_extensions: string[];
|
||||
max_storage_formatted: string;
|
||||
used_storage_formatted: string;
|
||||
max_file_size_formatted: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
tags: string[];
|
||||
metadata: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface CategoriesFilterOptions {
|
||||
categories: Array<{ category: string; category_id: string | null }>;
|
||||
}
|
||||
|
||||
export interface ShareResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
file_id: string;
|
||||
share_token: string;
|
||||
share_type: 'link' | 'user';
|
||||
shared_with_user_id: string | null;
|
||||
shared_with_email: string | null;
|
||||
permissions: 'view' | 'download';
|
||||
expires_at: string | null;
|
||||
max_downloads: number | null;
|
||||
current_downloads: number;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
is_active: boolean;
|
||||
share_url: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Upload Params (matches UploadFileSchema)
|
||||
// ─────────────────────────────────────────
|
||||
|
||||
export interface UploadFilesParams {
|
||||
files: File[];
|
||||
entity_type: string; // required
|
||||
entity_id: string; // required — must be valid UUID
|
||||
category?: string; // optional string label
|
||||
category_id?: string; // optional
|
||||
description?: string; // max 500 chars
|
||||
tags?: string[]; // array of tag strings
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// List Params
|
||||
// ─────────────────────────────────────────
|
||||
|
||||
export interface FileListParams {
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
mime_type?: string;
|
||||
category?: string;
|
||||
category_id?: string;
|
||||
source_module?: string;
|
||||
search?: string;
|
||||
tags?: string;
|
||||
uploaded_by?: string;
|
||||
sort_by?: 'created_at' | 'original_name' | 'file_size' | 'version' | 'category' | 'source_module';
|
||||
sort_order?: 'ASC' | 'DESC';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Update Params (matches UpdateFileMetadataSchema)
|
||||
// ─────────────────────────────────────────
|
||||
|
||||
export interface UpdateFileMetadataParams {
|
||||
category?: string;
|
||||
category_id?: string | null;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Share Params (matches CreateShareSchema)
|
||||
// ─────────────────────────────────────────
|
||||
|
||||
export interface CreateShareParams {
|
||||
share_type?: 'link' | 'user';
|
||||
shared_with_user_id?: string | null;
|
||||
shared_with_email?: string | null;
|
||||
permissions?: 'view' | 'download';
|
||||
expires_at?: Date | null;
|
||||
expires_in_hours?: number;
|
||||
max_downloads?: number | null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Service
|
||||
// ─────────────────────────────────────────
|
||||
|
||||
export const fileAttachmentService = {
|
||||
/** GET /files — list with filters */
|
||||
list: async (params: FileListParams = {}): Promise<FileListResponse> => {
|
||||
const response = await apiClient.get<FileListResponse>('/files', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /files/:id — single file detail */
|
||||
getById: async (id: string): Promise<FileDetailResponse> => {
|
||||
const response = await apiClient.get<FileDetailResponse>(`/files/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /files/entity/:entity_type/:entity_id */
|
||||
getByEntity: async (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
params: { category?: string; limit?: number; offset?: number; current_only?: boolean } = {}
|
||||
): Promise<FileListResponse> => {
|
||||
const response = await apiClient.get<FileListResponse>(
|
||||
`/files/entity/${entityType}/${entityId}`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /files/upload — single file */
|
||||
upload: async (params: UploadFilesParams): Promise<FileDetailResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', params.files[0]);
|
||||
formData.append('entity_type', params.entity_type);
|
||||
formData.append('entity_id', params.entity_id);
|
||||
if (params.category) formData.append('category', params.category);
|
||||
if (params.category_id) formData.append('category_id', params.category_id);
|
||||
if (params.description) formData.append('description', params.description);
|
||||
if (params.tags?.length) formData.append('tags', JSON.stringify(params.tags));
|
||||
if (params.metadata) formData.append('metadata', JSON.stringify(params.metadata));
|
||||
|
||||
const response = await apiClient.post<FileDetailResponse>('/files/upload', formData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /files/upload/multiple — up to 10 files */
|
||||
uploadMultiple: async (
|
||||
params: UploadFilesParams,
|
||||
onProgress?: (fileIndex: number, percent: number) => void
|
||||
): Promise<{ success: boolean; data: { uploaded: FileAttachment[]; errors: Array<{ file: string; error: string }>; total: number; success_count: number; error_count: number } }> => {
|
||||
const formData = new FormData();
|
||||
params.files.forEach((file) => formData.append('files', file));
|
||||
formData.append('entity_type', params.entity_type);
|
||||
formData.append('entity_id', params.entity_id);
|
||||
if (params.category) formData.append('category', params.category);
|
||||
if (params.category_id) formData.append('category_id', params.category_id);
|
||||
if (params.description) formData.append('description', params.description);
|
||||
if (params.tags?.length) formData.append('tags', JSON.stringify(params.tags));
|
||||
if (params.metadata) formData.append('metadata', JSON.stringify(params.metadata));
|
||||
|
||||
const response = await apiClient.post('/files/upload/multiple', formData, {
|
||||
onUploadProgress: (evt) => {
|
||||
if (onProgress && evt.total) {
|
||||
onProgress(0, Math.round((evt.loaded / evt.total) * 100));
|
||||
}
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** PUT /files/:id — update metadata */
|
||||
updateMetadata: async (id: string, data: UpdateFileMetadataParams): Promise<FileDetailResponse> => {
|
||||
const response = await apiClient.put<FileDetailResponse>(`/files/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** DELETE /files/:id */
|
||||
delete: async (id: string, hard = false): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.delete(`/files/${id}`, { params: { hard } });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /files/:id/download — returns blob URL */
|
||||
getDownloadUrl: (id: string): string => {
|
||||
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||
return `${baseUrl}/files/${id}/download`;
|
||||
},
|
||||
|
||||
/** GET /files/:id/download (blob) */
|
||||
download: async (id: string, filename?: string): Promise<void> => {
|
||||
const response = await apiClient.get(`/files/${id}/download`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(response.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || 'download';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
/** GET /files/:id/preview — returns blob URL for inline preview */
|
||||
getPreviewUrl: async (id: string): Promise<string> => {
|
||||
const response = await apiClient.get(`/files/${id}/preview`, { responseType: 'blob' });
|
||||
return URL.createObjectURL(response.data);
|
||||
},
|
||||
|
||||
/** GET /files/:id/versions — version history */
|
||||
getVersionHistory: async (id: string): Promise<VersionHistoryResponse> => {
|
||||
const response = await apiClient.get<VersionHistoryResponse>(`/files/${id}/versions`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /files/:id/versions — upload new version */
|
||||
uploadVersion: async (
|
||||
id: string,
|
||||
file: File,
|
||||
options: { entity_id?: string; category?: string; description?: string; tags?: string[]; metadata?: Record<string, any> } = {}
|
||||
): Promise<FileDetailResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (options.entity_id) formData.append('entity_id', options.entity_id);
|
||||
if (options.category) formData.append('category', options.category);
|
||||
if (options.description) formData.append('description', options.description);
|
||||
if (options.tags?.length) formData.append('tags', JSON.stringify(options.tags));
|
||||
if (options.metadata) formData.append('metadata', JSON.stringify(options.metadata));
|
||||
|
||||
const response = await apiClient.post<FileDetailResponse>(`/files/${id}/versions`, formData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /files/:id/share — create share link */
|
||||
createShare: async (id: string, params: CreateShareParams): Promise<{ success: boolean; data: ShareResponse }> => {
|
||||
const response = await apiClient.post(`/files/${id}/share`, params);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** DELETE /files/shares/:shareId — revoke a share */
|
||||
revokeShare: async (shareId: string): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.delete(`/files/shares/${shareId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /files/stats */
|
||||
getStorageStats: async (): Promise<{ success: boolean; data: StorageStats }> => {
|
||||
const response = await apiClient.get('/files/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /files/quota */
|
||||
getQuota: async (): Promise<{ success: boolean; data: StorageQuota }> => {
|
||||
const response = await apiClient.get('/files/quota');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** PUT /files/quota — admin only */
|
||||
updateQuota: async (data: Partial<Pick<StorageQuota, 'max_storage_bytes' | 'max_file_size_bytes' | 'allowed_mime_types' | 'blocked_extensions'>>): Promise<{ success: boolean; data: StorageQuota }> => {
|
||||
const response = await apiClient.put('/files/quota', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /files/filter-options */
|
||||
getFilterOptions: async (params?: { source_module?: string; uploaded_by?: string }): Promise<{ success: boolean; data: FilterOptions }> => {
|
||||
const response = await apiClient.get('/files/filter-options', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /files/categories-filter-options */
|
||||
getCategoriesFilterOptions: async (params?: { source_module?: string }): Promise<{ success: boolean; data: CategoriesFilterOptions }> => {
|
||||
const response = await apiClient.get('/files/categories-filter-options', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /files/:id/content — extract text/html */
|
||||
extractContent: async (id: string): Promise<{ success: boolean; data: { html: string; text: string; original_name: string; file_size: number; mime_type: string; checksum: string } }> => {
|
||||
const response = await apiClient.get(`/files/${id}/content`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default fileAttachmentService;
|
||||
Loading…
Reference in New Issue
Block a user