Compare commits
2 Commits
26566f6620
...
421aaa1b87
| Author | SHA1 | Date | |
|---|---|---|---|
| 421aaa1b87 | |||
| 873d5af758 |
@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Search, ChevronDown, Menu, LogOut, User, ChevronRight } from 'lucide-react';
|
import { ChevronDown, Menu, LogOut, User, ChevronRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||||
import { logoutAsync } from '@/store/authSlice';
|
import { logoutAsync } from '@/store/authSlice';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Bell,
|
Bell,
|
||||||
|
Paperclip,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -119,6 +120,15 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
|||||||
],
|
],
|
||||||
requiredPermission: { resource: "document" },
|
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" },
|
{ 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 { SupplierScorecardsModal } from './SupplierScorecardsModal';
|
||||||
export { FormTextArea } from './FormTextArea';
|
export { FormTextArea } from './FormTextArea';
|
||||||
export { RichTextEditor } from './RichTextEditor';
|
export { RichTextEditor } from './RichTextEditor';
|
||||||
|
export { FileUploadModal } from './FileUploadModal';
|
||||||
|
export type { FileUploadModalProps } from './FileUploadModal';
|
||||||
|
export { FileShareModal } from './FileShareModal';
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from '@/components/shared';
|
||||||
import { ViewModuleModal, NewModuleModal } from '@/components/superadmin';
|
import { ViewModuleModal, NewModuleModal } from '@/components/superadmin';
|
||||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
import { Plus, ArrowUpDown } from 'lucide-react';
|
||||||
import { moduleService } from '@/services/module-service';
|
import { moduleService } from '@/services/module-service';
|
||||||
import type { Module } from '@/types/module';
|
import type { Module } from '@/types/module';
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from '@/components/shared';
|
||||||
// Note: NewTenantModal, ViewTenantModal, EditTenantModal are now in @/components/superadmin (commented out - using wizard/details/edit pages instead)
|
// Note: NewTenantModal, ViewTenantModal, EditTenantModal are now in @/components/superadmin (commented out - using wizard/details/edit pages instead)
|
||||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
import { Plus, ArrowUpDown } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { tenantService } from '@/services/tenant-service';
|
import { tenantService } from '@/services/tenant-service';
|
||||||
import type { Tenant } from '@/types/tenant';
|
import type { Tenant } from '@/types/tenant';
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
FileCheck,
|
FileCheck,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
FileText,
|
FileText,
|
||||||
GraduationCap,
|
|
||||||
Users,
|
Users,
|
||||||
Bell,
|
Bell,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|||||||
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;
|
||||||
641
src/pages/tenant/FilesList.tsx
Normal file
641
src/pages/tenant/FilesList.tsx
Normal file
@ -0,0 +1,641 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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;
|
||||||
@ -13,7 +13,7 @@ import {
|
|||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from '@/components/shared';
|
||||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
import { Plus, ArrowUpDown } from 'lucide-react';
|
||||||
import { roleService } from '@/services/role-service';
|
import { roleService } from '@/services/role-service';
|
||||||
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
|
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
|
||||||
import { showToast } from '@/utils/toast';
|
import { showToast } from '@/utils/toast';
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
type Column,
|
type Column,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { Plus, Download, ArrowUpDown } from "lucide-react";
|
import { Plus, ArrowUpDown } from "lucide-react";
|
||||||
import { userService } from "@/services/user-service";
|
import { userService } from "@/services/user-service";
|
||||||
import type { User } from "@/types/user";
|
import type { User } from "@/types/user";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
|
|||||||
@ -23,6 +23,8 @@ const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForR
|
|||||||
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
|
||||||
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
|
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
|
||||||
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
|
||||||
|
const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
|
||||||
|
const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -125,4 +127,12 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/notifications",
|
path: "/tenant/notifications",
|
||||||
element: <LazyRoute component={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