379 lines
16 KiB
TypeScript
379 lines
16 KiB
TypeScript
import { useEffect, useState, type ReactElement } from "react";
|
|
import {
|
|
Database,
|
|
ShieldCheck,
|
|
Building2,
|
|
Package,
|
|
AlertCircle,
|
|
Pencil,
|
|
HardDrive,
|
|
Files,
|
|
FileText,
|
|
Image as ImageIcon,
|
|
CheckCircle2,
|
|
Save,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { Layout } from "@/components/layout/Layout";
|
|
import { cn } from "@/lib/utils";
|
|
import fileAttachmentService, {
|
|
type StorageStats,
|
|
type StorageQuota,
|
|
} from "@/services/file-attachment-service";
|
|
import {
|
|
Modal,
|
|
FormField,
|
|
PrimaryButton,
|
|
// SecondaryButton,
|
|
} from "@/components/shared";
|
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
function formatBytes(bytes: number | string): string {
|
|
const b = typeof bytes === "string" ? parseInt(bytes) : bytes;
|
|
if (!b || b === 0) return "0 B";
|
|
const k = 1024;
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
const i = Math.floor(Math.log(b) / Math.log(k));
|
|
return `${parseFloat((b / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Components
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface QuotaEditModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
quota: StorageQuota;
|
|
onUpdated: () => void;
|
|
}
|
|
|
|
const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalProps) => {
|
|
const [maxStorageMB, setMaxStorageMB] = useState(
|
|
Math.floor((typeof quota.max_storage_bytes === 'string' ? parseInt(quota.max_storage_bytes) : quota.max_storage_bytes) / 1024 / 1024)
|
|
);
|
|
const [maxFileMB, setMaxFileMB] = useState(
|
|
Math.floor(quota.max_file_size_bytes / 1024 / 1024)
|
|
);
|
|
const [isUpdating, setIsUpdating] = useState(false);
|
|
|
|
const handleSubmit = async () => {
|
|
setIsUpdating(true);
|
|
try {
|
|
await fileAttachmentService.updateQuota({
|
|
max_storage_bytes: maxStorageMB * 1024 * 1024,
|
|
max_file_size_bytes: maxFileMB * 1024 * 1024,
|
|
});
|
|
onUpdated();
|
|
onClose();
|
|
} catch (err) {
|
|
alert("Failed to update quota");
|
|
} finally {
|
|
setIsUpdating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title="Edit Storage Quota"
|
|
maxWidth="sm"
|
|
footer={
|
|
<>
|
|
{/* <SecondaryButton onClick={onClose} disabled={isUpdating}>Cancel</SecondaryButton> */}
|
|
<PrimaryButton onClick={handleSubmit} disabled={isUpdating} className="flex items-center gap-2">
|
|
{isUpdating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
Save Changes
|
|
</PrimaryButton>
|
|
</>
|
|
}
|
|
>
|
|
<div className="p-6 space-y-5">
|
|
<FormField
|
|
label="Max Total Storage (MB)"
|
|
type="number"
|
|
value={maxStorageMB}
|
|
onChange={(e) => setMaxStorageMB(parseInt(e.target.value) || 0)}
|
|
placeholder="e.g. 10240 for 10GB"
|
|
/>
|
|
<FormField
|
|
label="Max Per-File Size (MB)"
|
|
type="number"
|
|
value={maxFileMB}
|
|
onChange={(e) => setMaxFileMB(parseInt(e.target.value) || 0)}
|
|
placeholder="e.g. 50 for 50MB"
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
const StorageDashboard = (): ReactElement => {
|
|
const { primaryColor } = useAppTheme();
|
|
|
|
const [activeTab, setActiveTab] = useState<'stats' | 'quota'>('stats');
|
|
const [stats, setStats] = useState<StorageStats | null>(null);
|
|
const [quota, setQuota] = useState<StorageQuota | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [statsRes, quotaRes] = await Promise.all([
|
|
fileAttachmentService.getStorageStats(),
|
|
fileAttachmentService.getQuota(),
|
|
]);
|
|
setStats(statsRes.data);
|
|
setQuota(quotaRes.data);
|
|
} catch (err) {
|
|
setError("Failed to load dashboard data");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void loadData();
|
|
}, []);
|
|
|
|
if (loading) {
|
|
return (
|
|
<Layout currentPage="Storage Dashboard"
|
|
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
|
>
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<Loader2 className="w-8 h-8 animate-spin" style={{ color: primaryColor }} />
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
if (error || !stats || !quota) {
|
|
return (
|
|
<Layout currentPage="Storage Dashboard"
|
|
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
|
>
|
|
<div className="max-w-md mx-auto mt-20 text-center">
|
|
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
|
<h2 className="text-lg font-bold">Error</h2>
|
|
<p className="text-sm text-[#9aa6b2] mt-1">{error}</p>
|
|
<PrimaryButton onClick={loadData} className="mt-4">Retry</PrimaryButton>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="Storage Dashboard"
|
|
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
|
pageHeader={{
|
|
title: "Storage Dashboard",
|
|
description: "Overview of storage consumption, file counts, and quota limits.",
|
|
}}
|
|
>
|
|
<div className="space-y-6">
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-[rgba(0,0,0,0.08)]">
|
|
<button
|
|
onClick={() => setActiveTab('stats')}
|
|
className={cn(
|
|
"px-6 py-3 text-sm font-bold transition-all border-b-2",
|
|
activeTab === 'stats'
|
|
? "text-[#0e1b2a]"
|
|
: "border-transparent text-[#9aa6b2] hover:text-[#475569]"
|
|
)}
|
|
style={activeTab === 'stats' ? { borderBottomColor: primaryColor, color: primaryColor } : {}}
|
|
>
|
|
Usage Statistics
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('quota')}
|
|
className={cn(
|
|
"px-6 py-3 text-sm font-bold transition-all border-b-2",
|
|
activeTab === 'quota'
|
|
? "text-[#0e1b2a]"
|
|
: "border-transparent text-[#9aa6b2] hover:text-[#475569]"
|
|
)}
|
|
style={activeTab === 'quota' ? { borderBottomColor: primaryColor, color: primaryColor } : {}}
|
|
>
|
|
Quota Details
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'stats' && (
|
|
<div className="space-y-8 animate-in fade-in duration-300">
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 rounded-lg" style={{ backgroundColor: `${primaryColor}10` }}>
|
|
<HardDrive className="w-4 h-4" style={{ color: primaryColor }} />
|
|
</div>
|
|
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Usage</span>
|
|
</div>
|
|
<p className="text-xl font-black text-[#0e1b2a]">{stats.quota.usage_percent}% <span className="text-[10px] text-[#9aa6b2] font-medium uppercase">capacity</span></p>
|
|
<div className="mt-2 w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
<div className="h-full" style={{ width: `${stats.quota.usage_percent}%`, backgroundColor: primaryColor }} />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-emerald-50 rounded-lg"><Files className="w-4 h-4 text-emerald-600" /></div>
|
|
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Total Files</span>
|
|
</div>
|
|
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.total}</p>
|
|
</div>
|
|
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-orange-50 rounded-lg"><ImageIcon className="w-4 h-4 text-orange-500" /></div>
|
|
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Images</span>
|
|
</div>
|
|
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.images}</p>
|
|
</div>
|
|
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-red-50 rounded-lg"><FileText className="w-4 h-4 text-red-500" /></div>
|
|
<span className="text-xs font-bold text-[#9aa6b2] uppercase">DOCs / PDFs</span>
|
|
</div>
|
|
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.pdfs + stats.files.documents}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tables Grid */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Entity Table */}
|
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
|
|
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
|
|
<Building2 className="w-4 h-4" style={{ color: primaryColor }} />
|
|
<h3 className="text-sm font-bold text-[#0e1b2a]">By Entity Type</h3>
|
|
</div>
|
|
<table className="w-full text-left">
|
|
<thead>
|
|
<tr className="bg-gray-50/50 text-[10px] font-bold text-[#9aa6b2] uppercase">
|
|
<th className="px-5 py-3">Entity</th>
|
|
<th className="px-5 py-3">File Count</th>
|
|
<th className="px-5 py-3">Total Size</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
|
{Object.entries(stats.by_entity).map(([name, data]) => (
|
|
<tr key={name} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">{name}</td>
|
|
<td className="px-5 py-3 text-sm text-[#475569]">{data.count}</td>
|
|
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">{formatBytes(data.size)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Module Table */}
|
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
|
|
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
|
|
<Package className="w-4 h-4 text-[#10b981]" />
|
|
<h3 className="text-sm font-bold text-[#0e1b2a]">By Source Module</h3>
|
|
</div>
|
|
<table className="w-full text-left">
|
|
<thead>
|
|
<tr className="bg-gray-50/50 text-[10px] font-bold text-[#9aa6b2] uppercase">
|
|
<th className="px-5 py-3">Module</th>
|
|
<th className="px-5 py-3">File Count</th>
|
|
<th className="px-5 py-3">Total Size</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
|
{Object.entries(stats.by_module).map(([name, data]) => (
|
|
<tr key={name} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">{name}</td>
|
|
<td className="px-5 py-3 text-sm text-[#475569]">{data.count}</td>
|
|
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">{formatBytes(data.size)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'quota' && (
|
|
<div className="space-y-6 animate-in slide-in-from-bottom-2 duration-300">
|
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
|
|
<div className="px-6 py-5 border-b border-[rgba(0,0,0,0.08)] flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="w-5 h-5" style={{ color: primaryColor }} />
|
|
<h3 className="text-base font-black text-[#0e1b2a]">Quota Profile</h3>
|
|
</div>
|
|
<PrimaryButton
|
|
onClick={() => setIsEditModalOpen(true)}
|
|
size="default"
|
|
className="px-4 py-2.5 text-sm"
|
|
>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
Edit Quota
|
|
</PrimaryButton>
|
|
</div>
|
|
<div className="p-0">
|
|
<table className="w-full">
|
|
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
|
{[
|
|
{ label: "Max Total Storage", value: quota.max_storage_formatted || formatBytes(quota.max_storage_bytes), icon: HardDrive },
|
|
{ label: "Max Per-File Size", value: quota.max_file_size_formatted || formatBytes(quota.max_file_size_bytes), icon: FileText },
|
|
{ label: "Currently Used", value: quota.used_storage_formatted || formatBytes(quota.used_storage_bytes), icon: Save },
|
|
{ label: "File Count", value: `${quota.file_count} items`, icon: Files },
|
|
{ label: "Last Updated", value: new Date(quota.updated_at).toLocaleString(), icon: CheckCircle2 },
|
|
].map((row) => (
|
|
<tr key={row.label}>
|
|
<td className="px-6 py-4 flex items-center gap-3 w-1/3">
|
|
<row.icon className="w-4 h-4 text-[#9aa6b2]" />
|
|
<span className="text-sm font-medium text-[#475569]">{row.label}</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm font-bold text-[#0e1b2a]">{row.value}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 border rounded-2xl flex items-start gap-4" style={{ backgroundColor: `${primaryColor}05`, borderColor: `${primaryColor}10` }}>
|
|
<ShieldCheck className="w-6 h-6 shrink-0" style={{ color: primaryColor }} />
|
|
<div>
|
|
<h4 className="text-sm font-bold text-[#0e1b2a]">System Security Policy</h4>
|
|
<p className="text-sm text-[#475569] mt-1 leading-relaxed">
|
|
The following extensions are strictly blocked to prevent malicious execution:
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5 mt-3">
|
|
{quota.blocked_extensions?.map(ext => (
|
|
<span key={ext} className="px-2 py-0.5 bg-red-100/50 text-red-700 text-[10px] font-black rounded border border-red-200 uppercase">
|
|
{ext}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isEditModalOpen && (
|
|
<QuotaEditModal
|
|
isOpen={isEditModalOpen}
|
|
onClose={() => setIsEditModalOpen(false)}
|
|
quota={quota}
|
|
onUpdated={loadData}
|
|
/>
|
|
)}
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default StorageDashboard;
|