Qassure-frontend/src/pages/tenant/StorageDashboard.tsx

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;