478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
import { useEffect, useState, type ReactElement } from "react";
|
|
import {
|
|
Database,
|
|
ShieldCheck,
|
|
Building2,
|
|
Package,
|
|
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,
|
|
GradientStatCard,
|
|
// 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 [maxStorageGB, setMaxStorageGB] = useState(
|
|
Math.floor(
|
|
(typeof quota.max_storage_bytes === "string"
|
|
? parseInt(quota.max_storage_bytes)
|
|
: quota.max_storage_bytes) /
|
|
1024 /
|
|
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: maxStorageGB * 1024 * 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 (GB)"
|
|
type="number"
|
|
value={maxStorageGB}
|
|
onChange={(e) => setMaxStorageGB(parseInt(e.target.value) || 0)}
|
|
placeholder="e.g. 10 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: any) {
|
|
setError(
|
|
err?.response?.data?.error?.message || "Failed to load dashboard data",
|
|
);
|
|
console.log("Failed to load dashboard data", err);
|
|
} 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-red-500 mt-1">{error}</p>
|
|
{/* <PrimaryButton onClick={loadData} className="mt-4">Retry</PrimaryButton> */}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="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">
|
|
<GradientStatCard
|
|
icon={HardDrive}
|
|
value={`${stats.quota.usage_percent}%`}
|
|
label="Storage Usage"
|
|
badge={{ text: "Capacity", variant: "info" }}
|
|
/>
|
|
<GradientStatCard
|
|
icon={Files}
|
|
value={stats.files.total}
|
|
label="Total Files"
|
|
/>
|
|
<GradientStatCard
|
|
icon={ImageIcon}
|
|
value={stats.files.images}
|
|
label="Images"
|
|
/>
|
|
<GradientStatCard
|
|
icon={FileText}
|
|
value={stats.files.pdfs + stats.files.documents}
|
|
label="DOCs / PDFs"
|
|
/>
|
|
</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;
|