Compare commits

...

4 Commits

7 changed files with 78 additions and 88 deletions

View File

@@ -1,3 +1,3 @@
831167 833504
831168 833505
831182 833519

View File

@@ -5,7 +5,7 @@ Supports CSV and Excel formats.
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
@@ -70,20 +70,16 @@ async def export_inventory_snapshot(
# Return file response # Return file response
if format_type == "csv": if format_type == "csv":
# For CSV, use FileResponse with bytes return StreamingResponse(
import io
return FileResponse(
io.BytesIO(content.encode("utf-8")), io.BytesIO(content.encode("utf-8")),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
else: else:
# For Excel, content is already bytes return StreamingResponse(
import io
return FileResponse(
io.BytesIO(content), io.BytesIO(content),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
@@ -126,22 +122,20 @@ async def export_audit_trail(
# Return file response # Return file response
if format_type == "csv": if format_type == "csv":
import io return StreamingResponse(
return FileResponse(
io.BytesIO(content.encode("utf-8")), io.BytesIO(content.encode("utf-8")),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
else: else:
import io return StreamingResponse(
return FileResponse(
io.BytesIO(content), io.BytesIO(content),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
@router.get("/db/export") @router.get("/reports/export")
async def export_db( async def export_db(
format: str = Query("csv", description="Export format: csv or xlsx"), format: str = Query("csv", description="Export format: csv or xlsx"),
type: str = Query("inventory", description="Export type: inventory, audit, or combined"), type: str = Query("inventory", description="Export type: inventory, audit, or combined"),
@@ -226,14 +220,14 @@ async def export_db(
# Return file response # Return file response
if format_type == "csv": if format_type == "csv":
return FileResponse( return StreamingResponse(
io.BytesIO(content.encode("utf-8")), io.BytesIO(content.encode("utf-8")),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
else: else:
return FileResponse( return StreamingResponse(
io.BytesIO(content), io.BytesIO(content),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{ {
"version": "1.14.13", "version": "1.14.15",
"last_build": "2026-04-23-1451", "last_build": "2026-04-23-1514",
"codename": "ConfigCore", "codename": "ConfigCore",
"commit": "827dfcdf" "commit": "76f0e83a"
} }

View File

@@ -1,136 +1,132 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { FileDown, Loader2 } from "lucide-react"; import { FileDown, Loader2, FileSpreadsheet, FileText } from "lucide-react";
import { useExport } from "@/hooks/useExport"; import { useExport } from "@/hooks/useExport";
import { Toast } from "@/components/Toast"; import { toast } from "react-hot-toast";
export function ExportPanel() { export function ExportPanel() {
const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport(); const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const handleExportSnapshot = async (format: "csv" | "xlsx") => { const handleExportSnapshot = async (format: "csv" | "xlsx") => {
try { try {
setSuccessMessage(null);
await exportSnapshot(format); await exportSnapshot(format);
const formatName = format === "csv" ? "CSV" : "Excel"; const formatName = format === "csv" ? "CSV" : "Excel";
setSuccessMessage(`Inventory snapshot exported as ${formatName}`); toast.success(`Inventory snapshot exported as ${formatName}`);
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) { } catch (err) {
// Error handled by useExport hook // Error handled by hook
} }
}; };
const handleExportAuditTrail = async (format: "csv" | "xlsx") => { const handleExportAuditTrail = async (format: "csv" | "xlsx") => {
try { try {
setSuccessMessage(null);
await exportAuditTrail(format); await exportAuditTrail(format);
const formatName = format === "csv" ? "CSV" : "Excel"; const formatName = format === "csv" ? "CSV" : "Excel";
setSuccessMessage(`Audit trail exported as ${formatName}`); toast.success(`Audit trail exported as ${formatName}`);
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) { } catch (err) {
// Error handled by useExport hook // Error handled by hook
} }
}; };
return ( return (
<div className="space-y-6"> <div data-testid="admin-tab-exports" className="p-4 md:p-6 bg-surface border border-slate-800 rounded-3xl space-y-4 md:space-y-6">
<div className="rounded-lg border border-slate-200 bg-white p-6"> <header className="flex items-center gap-3">
<div className="mb-6 flex items-center gap-3"> <div className="p-3 md:p-4 bg-indigo-500/10 rounded-2xl text-indigo-400 border border-indigo-500/20 shadow-xl shadow-indigo-500/5">
<div className="rounded-lg bg-primary/10 p-4 border border-primary/20"> <FileDown size={28} className="md:w-8 md:h-8" />
<FileDown className="text-primary" size={24} /> </div>
</div> <div>
<h2 className="text-xl md:text-2xl font-normal text-white">Export & Reports</h2>
<p className="text-[10px] md:text-xs text-muted font-normal tracking-tight mt-0.5">
Download inventory snapshots and audit trails
</p>
</div>
</header>
<div className="grid md:grid-cols-2 gap-4">
{/* Inventory Snapshot Section */}
<div className="p-4 bg-slate-800/40 border border-slate-800 rounded-2xl space-y-4">
<div> <div>
<h2 className="text-2xl font-normal">Export & Reports</h2> <h3 className="text-sm font-normal text-secondary">Inventory Snapshot</h3>
<p className="text-xs text-slate-500"> <p className="text-xs text-muted mt-0.5">
Download inventory snapshots and audit trails in CSV or Excel formats Export current inventory state with all item details
</p> </p>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-2">
{/* Inventory Snapshot Section */}
<div className="mb-8 space-y-3">
<h3 className="font-normal text-slate-700">Inventory Snapshot</h3>
<p className="text-sm text-slate-500">
Export current inventory state with all item details
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<button <button
onClick={() => handleExportSnapshot("csv")} onClick={() => handleExportSnapshot("csv")}
disabled={isLoading} disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors" className="bg-surface border border-slate-800 hover:border-primary/50 hover:bg-slate-800/50 text-white font-normal py-2.5 px-3 rounded-xl transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center gap-2 text-xs focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none group"
aria-label="Export inventory snapshot as CSV" aria-label="Export inventory snapshot as CSV"
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin text-primary" />
) : ( ) : (
<FileDown size={16} /> <FileText size={16} className="text-blue-400 group-hover:text-blue-300 transition-colors" />
)} )}
{isLoading ? "Exporting..." : "Export as CSV"} <span>CSV</span>
</button> </button>
<button <button
onClick={() => handleExportSnapshot("xlsx")} onClick={() => handleExportSnapshot("xlsx")}
disabled={isLoading} disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors" className="bg-surface border border-slate-800 hover:border-primary/50 hover:bg-slate-800/50 text-white font-normal py-2.5 px-3 rounded-xl transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center gap-2 text-xs focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none group"
aria-label="Export inventory snapshot as Excel" aria-label="Export inventory snapshot as Excel"
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin text-primary" />
) : ( ) : (
<FileDown size={16} /> <FileSpreadsheet size={16} className="text-emerald-400 group-hover:text-emerald-300 transition-colors" />
)} )}
{isLoading ? "Exporting..." : "Export as Excel"} <span>Excel</span>
</button> </button>
</div> </div>
</div> </div>
{/* Audit Trail Section */} {/* Audit Trail Section */}
<div className="space-y-3"> <div className="p-4 bg-slate-800/40 border border-slate-800 rounded-2xl space-y-4">
<h3 className="font-normal text-slate-700">Audit Trail</h3> <div>
<p className="text-sm text-slate-500"> <h3 className="text-sm font-normal text-secondary">Audit Trail</h3>
Export complete audit log of all system actions and changes <p className="text-xs text-muted mt-0.5">
</p> Complete log of system actions and changes
<div className="flex flex-col gap-2 sm:flex-row"> </p>
</div>
<div className="grid grid-cols-2 gap-2">
<button <button
onClick={() => handleExportAuditTrail("csv")} onClick={() => handleExportAuditTrail("csv")}
disabled={isLoading} disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors" className="bg-surface border border-slate-800 hover:border-primary/50 hover:bg-slate-800/50 text-white font-normal py-2.5 px-3 rounded-xl transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center gap-2 text-xs focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none group"
aria-label="Export audit trail as CSV" aria-label="Export audit trail as CSV"
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin text-primary" />
) : ( ) : (
<FileDown size={16} /> <FileText size={16} className="text-blue-400 group-hover:text-blue-300 transition-colors" />
)} )}
{isLoading ? "Exporting..." : "Export as CSV"} <span>CSV</span>
</button> </button>
<button <button
onClick={() => handleExportAuditTrail("xlsx")} onClick={() => handleExportAuditTrail("xlsx")}
disabled={isLoading} disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors" className="bg-surface border border-slate-800 hover:border-primary/50 hover:bg-slate-800/50 text-white font-normal py-2.5 px-3 rounded-xl transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center gap-2 text-xs focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none group"
aria-label="Export audit trail as Excel" aria-label="Export audit trail as Excel"
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin text-primary" />
) : ( ) : (
<FileDown size={16} /> <FileSpreadsheet size={16} className="text-emerald-400 group-hover:text-emerald-300 transition-colors" />
)} )}
{isLoading ? "Exporting..." : "Export as Excel"} <span>Excel</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Status Messages */}
{error && ( {error && (
<Toast <div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-[10px] flex items-center gap-2 animate-in fade-in zoom-in duration-300">
type="error" <div className="w-1.5 h-1.5 bg-rose-500 rounded-full animate-pulse" />
message={`Export failed: ${error}`} <span className="font-normal">Error: {error}</span>
onClose={() => {}} </div>
/>
)}
{successMessage && (
<Toast type="success" message={successMessage} onClose={() => {}} />
)} )}
</div> </div>
); );

View File

@@ -50,7 +50,7 @@ export function useExport(): UseExportReturn {
try { try {
const response = await axiosInstance.get( const response = await axiosInstance.get(
`/admin/db/export?format=${format}&type=inventory`, `/admin/reports/export?format=${format}&type=inventory`,
{ {
responseType: "blob" responseType: "blob"
} }
@@ -83,7 +83,7 @@ export function useExport(): UseExportReturn {
try { try {
const response = await axiosInstance.get( const response = await axiosInstance.get(
`/admin/db/export?format=${format}&type=audit`, `/admin/reports/export?format=${format}&type=audit`,
{ {
responseType: "blob" responseType: "blob"
} }