Files
schulos/src/components/data-import-export-view.tsx
T
Mustafa Cetin 3d0d518bb9 Round 25: Substitute Teacher, School Newsletter, Career Guidance, Styling
- Substitute Teacher Management View: pool, absences, assignments, statistics, weekly schedule
- School Newsletter View: editor, templates, distribution, analytics, open/click tracking
- Student Career & Guidance View: career exploration, interest quiz, portfolio, goals, appointments
- 6 new Prisma models: SubstituteTeacher, TeacherAbsence, SubstitutionAssignment, CareerProfile, CareerGoal, CareerAppointment
- Enhanced Newsletter model with content, scheduling, and analytics fields
- Enhanced settings-view with gradient header, glassmorphism cards, danger zone
- Enhanced student-portal-view with animated welcome banner, quick action cards
- 16+ new API route files
- 300+ i18n keys added
- All views verified working in browser
2026-07-30 06:36:34 +00:00

1527 lines
74 KiB
TypeScript
Executable File

'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Database,
Upload,
Download,
History,
Trash2,
FileText,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
XCircle,
Loader2,
ChevronRight,
ChevronDown,
MapPin,
Eye,
Play,
RefreshCw,
Archive,
Shield,
Users,
GraduationCap,
ClipboardCheck,
CalendarCheck,
BarChart3,
X,
Filter,
Calendar,
Clock,
HardDrive,
Sparkles,
Info,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { Checkbox } from '@/components/ui/checkbox';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { toast } from 'sonner';
// ── Types ──────────────────────────────────────────────────────────
interface ImportJob {
id: string;
type: string;
fileName: string;
fileSize: number;
status: string;
totalRows: number;
successRows: number;
errorRows: number;
errors: string | null;
createdAt: string;
}
interface ExportJob {
id: string;
type: string;
format: string;
fileName: string | null;
fileSize: number;
status: string;
fileData: string | null;
createdAt: string;
}
interface DbStatistics {
students: number;
teachers: number;
grades: number;
attendance: number;
competencies: number;
classes: number;
subjects: number;
assessments: number;
reports: number;
total: number;
}
interface CleanupInfo {
orphanedStudents: number;
orphanedEnrollments: number;
totalOrphans: number;
}
interface ParsedData {
headers: string[];
rows: Record<string, string>[];
}
// ── Column mapping schema per import type ──────────────────────────
const IMPORT_FIELD_SCHEMAS: Record<string, { value: string; label: string; required: boolean }[]> = {
STUDENT: [
{ value: 'firstName', label: 'First Name', required: true },
{ value: 'lastName', label: 'Last Name', required: true },
{ value: 'externalId', label: 'External ID', required: false },
{ value: 'dateOfBirth', label: 'Date of Birth', required: false },
{ value: 'email', label: 'Email', required: false },
],
TEACHER: [
{ value: 'firstName', label: 'First Name', required: true },
{ value: 'lastName', label: 'Last Name', required: true },
{ value: 'email', label: 'Email', required: true },
{ value: 'role', label: 'Role', required: false },
],
GRADE: [
{ value: 'studentId', label: 'Student ID', required: true },
{ value: 'assessmentId', label: 'Assessment ID', required: true },
{ value: 'score', label: 'Score', required: false },
{ value: 'note', label: 'Note', required: false },
],
ATTENDANCE: [
{ value: 'studentId', label: 'Student ID', required: true },
{ value: 'date', label: 'Date', required: true },
{ value: 'status', label: 'Status', required: false },
{ value: 'sessionId', label: 'Session ID', required: false },
],
COMPETENCY: [
{ value: 'studentId', label: 'Student ID', required: true },
{ value: 'competencyId', label: 'Competency ID', required: true },
{ value: 'level', label: 'Level', required: false },
{ value: 'note', label: 'Note', required: false },
],
};
// ── Animated Counter ───────────────────────────────────────────────
function AnimatedCounter({ value, duration = 1000 }: { value: number; duration?: number }) {
const [display, setDisplay] = useState(0);
const prevRef = useRef(0);
useEffect(() => {
const start = prevRef.current;
const diff = value - start;
if (diff === 0) { setDisplay(value); return; }
const startTime = performance.now();
let raf: number;
const animate = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setDisplay(Math.round(start + diff * eased));
if (progress < 1) raf = requestAnimationFrame(animate);
};
raf = requestAnimationFrame(animate);
prevRef.current = value;
return () => cancelAnimationFrame(raf);
}, [value, duration]);
return <span>{display.toLocaleString()}</span>;
}
// ── Status Badge ───────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { color: string; icon: React.ElementType; label: string }> = {
pending: { color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', icon: Clock, label: t('data_import_export.status_pending') },
processing: { color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: Loader2, label: t('data_import_export.status_processing') },
completed: { color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300', icon: CheckCircle2, label: t('data_import_export.status_completed') },
failed: { color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: XCircle, label: t('data_import_export.status_failed') },
};
const c = config[status] || config.pending;
const Icon = c.icon;
return (
<Badge variant="outline" className={`${c.color} gap-1`}>
<Icon className={`h-3 w-3 ${status === 'processing' ? 'animate-spin' : ''}`} />
{c.label}
</Badge>
);
}
// ── Format file size ──────────────────────────────────────────────
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ── Main Component ─────────────────────────────────────────────────
export default function DataImportExportView() {
const currentUser = useAppStore((s) => s.currentUser);
const [activeTab, setActiveTab] = useState('import');
const [importType, setImportType] = useState('STUDENT');
const [exportType, setExportType] = useState('STUDENT');
const [exportFormat, setExportFormat] = useState('CSV');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [classFilter, setClassFilter] = useState('');
const [subjectFilter, setSubjectFilter] = useState('');
const [includeHeaders, setIncludeHeaders] = useState(true);
const [includeMetadata, setIncludeMetadata] = useState(false);
// Import state
const [dragActive, setDragActive] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [parsedData, setParsedData] = useState<ParsedData | null>(null);
const [columnMapping, setColumnMapping] = useState<Record<string, string>>({});
const [importing, setImporting] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importResult, setImportResult] = useState<Record<string, unknown> | null>(null);
// Export state
const [exporting, setExporting] = useState(false);
const [exportResult, setExportResult] = useState<Record<string, unknown> | null>(null);
// History state
const [importJobs, setImportJobs] = useState<ImportJob[]>([]);
const [exportJobs, setExportJobs] = useState<ExportJob[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
// Management state
const [dbStats, setDbStats] = useState<DbStatistics | null>(null);
const [cleanupInfo, setCleanupInfo] = useState<CleanupInfo | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [confirmDialog, setConfirmDialog] = useState<{ open: boolean; type: string; onConfirm: () => void }>({
open: false,
type: '',
onConfirm: () => {},
});
const fileInputRef = useRef<HTMLInputElement>(null);
// Check access
const role = currentUser?.role || '';
const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN';
const isVicePrincipal = role === 'VICE_PRINCIPAL';
const isTeacher = role === 'TEACHER';
const canImport = isAdmin || isVicePrincipal || isTeacher;
const canExport = isAdmin || isVicePrincipal || isTeacher;
const canManage = isAdmin || isVicePrincipal;
const canCleanup = isAdmin;
const schoolId = currentUser?.schoolId || '';
// ── Parse CSV/JSON locally ──────────────────────────────────────
const parseLocalFile = useCallback(async (file: File) => {
const text = await file.text();
try {
if (file.name.endsWith('.json')) {
const data = JSON.parse(text);
const arr = Array.isArray(data) ? data : data.records || data.data || [];
if (arr.length === 0) {
toast.error(t('data_import_export.invalid_format'));
return;
}
const headers = Object.keys(arr[0]);
const rows = arr.slice(0, 10).map((item: Record<string, unknown>) => {
const row: Record<string, string> = {};
headers.forEach((h) => {
row[h] = item[h] !== undefined && item[h] !== null ? String(item[h]) : '';
});
return row;
});
setParsedData({ headers, rows });
// Auto-map columns
const mapping: Record<string, string> = {};
const fields = IMPORT_FIELD_SCHEMAS[importType] || [];
headers.forEach((h) => {
const lower = h.toLowerCase().replace(/[^a-z0-9]/g, '');
const match = fields.find((f) => f.value.toLowerCase() === lower);
if (match) mapping[h] = match.value;
});
setColumnMapping(mapping);
} else {
// Parse CSV
const lines = text.split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) {
toast.error(t('data_import_export.invalid_format'));
return;
}
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
const rows: Record<string, string>[] = [];
for (let i = 1; i < Math.min(lines.length, 11); i++) {
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of lines[i]) {
if (char === '"') inQuotes = !inQuotes;
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
else current += char;
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, idx) => { row[h] = values[idx] ?? ''; });
rows.push(row);
}
setParsedData({ headers, rows });
// Auto-map columns
const mapping: Record<string, string> = {};
const fields = IMPORT_FIELD_SCHEMAS[importType] || [];
headers.forEach((h) => {
const lower = h.toLowerCase().replace(/[^a-z0-9]/g, '');
const match = fields.find((f) => f.value.toLowerCase() === lower);
if (match) mapping[h] = match.value;
});
setColumnMapping(mapping);
}
} catch {
toast.error(t('data_import_export.invalid_format'));
}
}, [importType]);
// ── Handle file selection ───────────────────────────────────────
const handleFileSelect = useCallback((file: File) => {
if (file.size > 10 * 1024 * 1024) {
toast.error(t('data_import_export.file_too_large'));
return;
}
if (!file.name.endsWith('.csv') && !file.name.endsWith('.json')) {
toast.error(t('data_import_export.invalid_format'));
return;
}
setSelectedFile(file);
setImportResult(null);
setImportProgress(0);
parseLocalFile(file);
}, [parseLocalFile]);
// ── Drag & Drop handlers ────────────────────────────────────────
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragIn = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(true);
}, []);
const handleDragOut = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileSelect(e.dataTransfer.files[0]);
}
}, [handleFileSelect]);
// ── Import handler ──────────────────────────────────────────────
const handleImport = useCallback(async () => {
if (!selectedFile || !schoolId) return;
setImporting(true);
setImportProgress(0);
// Simulate progress
const progressInterval = setInterval(() => {
setImportProgress((prev) => {
if (prev >= 90) { clearInterval(progressInterval); return 90; }
return prev + Math.random() * 15;
});
}, 300);
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('type', importType);
formData.append('schoolId', schoolId);
if (Object.keys(columnMapping).length > 0) {
formData.append('columnMapping', JSON.stringify(columnMapping));
}
const res = await fetch('/api/data-import', { method: 'POST', body: formData });
const data = await res.json();
clearInterval(progressInterval);
setImportProgress(100);
if (res.ok) {
setImportResult(data);
toast.success(t('data_import_export.import_success'));
} else {
toast.error(data.error || t('data_import_export.import_error'));
}
} catch {
clearInterval(progressInterval);
toast.error(t('data_import_export.import_error'));
} finally {
setImporting(false);
}
}, [selectedFile, schoolId, importType, columnMapping]);
// ── Export handler ──────────────────────────────────────────────
const handleExport = useCallback(async () => {
if (!schoolId) return;
setExporting(true);
setExportResult(null);
try {
const filters: Record<string, unknown> = { schoolId };
if (classFilter) filters.classId = classFilter;
if (subjectFilter) filters.subjectId = subjectFilter;
if (dateFrom) filters.dateFrom = dateFrom;
if (dateTo) filters.dateTo = dateTo;
const res = await fetch('/api/data-export/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: exportType,
format: exportFormat,
filters,
}),
});
const data = await res.json();
if (res.ok) {
setExportResult(data);
toast.success(t('data_import_export.export_success'));
} else {
toast.error(data.error || t('data_import_export.export_error'));
}
} catch {
toast.error(t('data_import_export.export_error'));
} finally {
setExporting(false);
}
}, [schoolId, exportType, exportFormat, classFilter, subjectFilter, dateFrom, dateTo]);
// ── Download handler ────────────────────────────────────────────
const handleDownload = useCallback((fileData: string, fileName: string) => {
try {
const binary = atob(fileData);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error(t('data_import_export.operation_error'));
}
}, []);
// ── Load history ────────────────────────────────────────────────
const loadHistory = useCallback(async () => {
if (!schoolId) return;
setHistoryLoading(true);
try {
const [importRes, exportRes] = await Promise.all([
fetch(`/api/data-import?schoolId=${schoolId}`),
fetch(`/api/data-export/jobs?schoolId=${schoolId}`),
]);
if (importRes.ok) setImportJobs(await importRes.json());
if (exportRes.ok) setExportJobs(await exportRes.json());
} catch {
// ignore
} finally {
setHistoryLoading(false);
}
}, [schoolId]);
// ── Load statistics ─────────────────────────────────────────────
const loadStats = useCallback(async () => {
if (!schoolId) return;
setStatsLoading(true);
try {
const res = await fetch(`/api/data-cleanup?schoolId=${schoolId}`);
if (res.ok) {
const data = await res.json();
setDbStats(data.statistics);
setCleanupInfo(data.cleanup);
}
} catch {
// ignore
} finally {
setStatsLoading(false);
}
}, [schoolId]);
// ── Cleanup handler ─────────────────────────────────────────────
const handleCleanup = useCallback(async (operation: string) => {
if (!schoolId) return;
try {
const res = await fetch('/api/data-cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operation, schoolId }),
});
const data = await res.json();
if (res.ok) {
toast.success(t('data_import_export.operation_success'));
loadStats();
} else {
toast.error(data.error || t('data_import_export.operation_error'));
}
} catch {
toast.error(t('data_import_export.operation_error'));
}
setConfirmDialog({ open: false, type: '', onConfirm: () => {} });
}, [schoolId, loadStats]);
// ── Delete export job ───────────────────────────────────────────
const handleDeleteExport = useCallback(async (id: string) => {
try {
const res = await fetch(`/api/data-export/jobs/${id}`, { method: 'DELETE' });
if (res.ok) {
setExportJobs((prev) => prev.filter((j) => j.id !== id));
toast.success(t('data_import_export.operation_success'));
}
} catch {
toast.error(t('data_import_export.operation_error'));
}
}, []);
// ── Load data on tab change ─────────────────────────────────────
useEffect(() => {
if (activeTab === 'history') loadHistory();
if (activeTab === 'management') loadStats();
}, [activeTab, loadHistory, loadStats]);
// ── No access view ──────────────────────────────────────────────
if (!canImport && !canExport) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 p-8">
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} className="rounded-full bg-rose-100 dark:bg-rose-900/30 p-6">
<Shield className="h-12 w-12 text-rose-500" />
</motion.div>
<h2 className="text-xl font-semibold text-foreground">{t('data_import_export.no_access')}</h2>
<p className="text-muted-foreground text-center max-w-md">{t('data_import_export.no_access_desc')}</p>
</div>
);
}
// ── Stat cards data ─────────────────────────────────────────────
const statCards = dbStats ? [
{ label: t('data_import_export.students'), value: dbStats.students, icon: GraduationCap, color: 'from-emerald-500 to-teal-600' },
{ label: t('data_import_export.teachers'), value: dbStats.teachers, icon: Users, color: 'from-teal-500 to-cyan-600' },
{ label: t('data_import_export.grades'), value: dbStats.grades, icon: ClipboardCheck, color: 'from-cyan-500 to-sky-600' },
{ label: t('data_import_export.attendance_records'), value: dbStats.attendance, icon: CalendarCheck, color: 'from-sky-500 to-blue-600' },
{ label: t('data_import_export.competency_records'), value: dbStats.competencies, icon: BarChart3, color: 'from-violet-500 to-purple-600' },
{ label: t('data_import_export.total_records'), value: dbStats.total, icon: Database, color: 'from-emerald-500 to-emerald-700' },
] : [];
// ── Render ───────────────────────────────────────────────────────
return (
<div className="space-y-6 p-4 md:p-6">
{/* Header Banner */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-600 via-teal-600 to-cyan-700 p-6 md:p-8"
>
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHBhdHRlcm4gaWQ9ImciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgcGF0dGVyblVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PGNpcmNsZSBjeD0iMTAiIGN5PSIxMCIgcj0iMSIgZmlsbD0icmdiYSgyNTUsMjU1LDI1NSwwLjA4KSIvPjwvcGF0dGVybj48L2RlZnM+PHJlY3QgZmlsbD0idXJsKCNnKSIgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIvPjwvc3ZnPg==')] opacity-50" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="rounded-xl bg-white/20 p-2.5 backdrop-blur-sm">
<Database className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-white">{t('data_import_export.title')}</h1>
<p className="text-emerald-100 text-sm mt-1">{t('data_import_export.subtitle')}</p>
</div>
</div>
</div>
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="absolute bottom-0 right-12 w-24 h-24 bg-white/5 rounded-full translate-y-1/2" />
</motion.div>
{/* Main Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="import" className="gap-1.5">
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">{t('data_import_export.tab_import')}</span>
</TabsTrigger>
<TabsTrigger value="export" className="gap-1.5">
<Download className="h-4 w-4" />
<span className="hidden sm:inline">{t('data_import_export.tab_export')}</span>
</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5">
<History className="h-4 w-4" />
<span className="hidden sm:inline">{t('data_import_export.tab_history')}</span>
</TabsTrigger>
<TabsTrigger value="management" className="gap-1.5" disabled={!canManage}>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">{t('data_import_export.tab_management')}</span>
</TabsTrigger>
</TabsList>
{/* ── IMPORT TAB ──────────────────────────────────────────── */}
<TabsContent value="import" className="space-y-6 mt-6">
{canImport ? (
<>
{/* Import Type Selection */}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<FileSpreadsheet className="h-5 w-5 text-emerald-500" />
{t('data_import_export.import_type')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{[
{ value: 'STUDENT', icon: GraduationCap, label: t('data_import_export.type_student') },
{ value: 'TEACHER', icon: Users, label: t('data_import_export.type_teacher') },
{ value: 'GRADE', icon: ClipboardCheck, label: t('data_import_export.type_grade') },
{ value: 'ATTENDANCE', icon: CalendarCheck, label: t('data_import_export.type_attendance') },
{ value: 'COMPETENCY', icon: BarChart3, label: t('data_import_export.type_competency') },
].map((item) => (
<motion.button
key={item.value}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => {
setImportType(item.value);
setParsedData(null);
setSelectedFile(null);
setColumnMapping({});
setImportResult(null);
}}
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${
importType === item.value
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 shadow-md'
: 'border-muted hover:border-emerald-300 dark:hover:border-emerald-700'
}`}
>
<item.icon className={`h-6 w-6 ${importType === item.value ? 'text-emerald-600' : 'text-muted-foreground'}`} />
<span className={`text-xs font-medium ${importType === item.value ? 'text-emerald-700 dark:text-emerald-300' : 'text-muted-foreground'}`}>
{item.label}
</span>
</motion.button>
))}
</div>
</CardContent>
</Card>
</motion.div>
{/* File Upload Zone */}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Upload className="h-5 w-5 text-emerald-500" />
{t('data_import_export.upload_title')}
</CardTitle>
<CardDescription>{t('data_import_export.upload_subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<div
onDragEnter={handleDragIn}
onDragLeave={handleDragOut}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`relative cursor-pointer rounded-xl border-2 border-dashed p-8 md:p-12 text-center transition-all ${
dragActive
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 scale-[1.02]'
: selectedFile
? 'border-emerald-400 bg-emerald-50/50 dark:bg-emerald-950/20'
: 'border-muted hover:border-emerald-300 dark:hover:border-emerald-700'
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) handleFileSelect(e.target.files[0]);
}}
/>
<motion.div
animate={dragActive ? { scale: 1.1, y: -5 } : { scale: 1, y: 0 }}
className="flex flex-col items-center gap-3"
>
<div className={`rounded-full p-4 ${dragActive ? 'bg-emerald-100 dark:bg-emerald-900/50' : 'bg-muted'}`}>
<Upload className={`h-8 w-8 ${dragActive ? 'text-emerald-500' : 'text-muted-foreground'}`} />
</div>
<div>
<p className="font-medium text-foreground">
{dragActive ? t('data_import_export.drag_drop_active') : t('data_import_export.drag_drop')}
</p>
<p className="text-sm text-muted-foreground mt-1">
{t('data_import_export.supported_formats')} &bull; {t('data_import_export.max_file_size')}
</p>
</div>
</motion.div>
{selectedFile && !dragActive && (
<div className="mt-4 flex items-center justify-center gap-2">
<FileText className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{selectedFile.name}</span>
<span className="text-xs text-muted-foreground">({formatFileSize(selectedFile.size)})</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 ml-1"
onClick={(e) => {
e.stopPropagation();
setSelectedFile(null);
setParsedData(null);
setColumnMapping({});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
{/* Column Mapping */}
<AnimatePresence>
{parsedData && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<MapPin className="h-5 w-5 text-emerald-500" />
{t('data_import_export.column_mapping')}
</CardTitle>
<CardDescription>{t('data_import_export.column_mapping_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{parsedData.headers.map((header) => (
<div key={header} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground truncate block">{header}</span>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1">
<Select
value={columnMapping[header] || ''}
onValueChange={(val) => {
setColumnMapping((prev) => ({ ...prev, [header]: val }));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('data_import_export.db_field')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">-- {t('data_import_export.select_all')} --</SelectItem>
{(IMPORT_FIELD_SCHEMAS[importType] || []).map((field) => (
<SelectItem key={field.value} value={field.value}>
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
{/* Data Preview */}
<AnimatePresence>
{parsedData && parsedData.rows.length > 0 && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="h-5 w-5 text-emerald-500" />
{t('data_import_export.preview')}
</CardTitle>
<CardDescription>{t('data_import_export.preview_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">#</th>
{parsedData.headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{parsedData.rows.map((row, idx) => (
<tr key={idx} className="border-t hover:bg-muted/30">
<td className="px-3 py-2 text-muted-foreground">{idx + 1}</td>
{parsedData.headers.map((h) => (
<td key={h} className="px-3 py-2 max-w-[200px] truncate">{row[h] || '—'}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
{/* Import Progress & Result */}
<AnimatePresence>
{(importing || importResult) && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
{importing ? (
<Loader2 className="h-5 w-5 text-emerald-500 animate-spin" />
) : (
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
)}
{t('data_import_export.import_progress')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Progress value={importProgress} className="h-3" />
{importing && (
<p className="text-sm text-muted-foreground text-center">{t('data_import_export.importing')}</p>
)}
{importResult && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-3">
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
<p className="text-2xl font-bold text-emerald-600">{String(importResult.totalRows ?? 0)}</p>
<p className="text-xs text-muted-foreground">{t('data_import_export.total_rows')}</p>
</div>
<div className="text-center p-3 rounded-lg bg-teal-50 dark:bg-teal-950/30">
<p className="text-2xl font-bold text-teal-600">{String(importResult.created ?? 0)}</p>
<p className="text-xs text-muted-foreground">{t('data_import_export.success_rows')}</p>
</div>
<div className="text-center p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30">
<p className="text-2xl font-bold text-amber-600">{String(importResult.skipped ?? 0)}</p>
<p className="text-xs text-muted-foreground">{t('data_import_export.error_rows')}</p>
</div>
</div>
{Array.isArray(importResult.errors) && importResult.errors.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">{t('data_import_export.validation_errors')}</p>
<div className="max-h-40 overflow-y-auto rounded-lg bg-red-50 dark:bg-red-950/30 p-3">
{(importResult.errors as string[]).map((err: string, idx: number) => (
<p key={idx} className="text-xs text-red-700 dark:text-red-300">{err}</p>
))}
</div>
</div>
)}
</motion.div>
)}
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
{/* Start Import Button */}
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex justify-end">
<Button
size="lg"
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white gap-2 px-8"
disabled={!selectedFile || importing}
onClick={handleImport}
>
{importing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('data_import_export.importing')}
</>
) : (
<>
<Play className="h-4 w-4" />
{t('data_import_export.start_import')}
</>
)}
</Button>
</motion.div>
</>
) : (
<NoAccessCard />
)}
</TabsContent>
{/* ── EXPORT TAB ──────────────────────────────────────────── */}
<TabsContent value="export" className="space-y-6 mt-6">
{canExport ? (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Export Configuration */}
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Download className="h-5 w-5 text-emerald-500" />
{t('data_import_export.export_title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Export Type */}
<div className="space-y-2">
<Label>{t('data_import_export.export_type')}</Label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{[
{ value: 'STUDENT', icon: GraduationCap, label: t('data_import_export.type_student') },
{ value: 'GRADE', icon: ClipboardCheck, label: t('data_import_export.type_grade') },
{ value: 'ATTENDANCE', icon: CalendarCheck, label: t('data_import_export.type_attendance') },
{ value: 'COMPETENCY', icon: BarChart3, label: t('data_import_export.type_competency') },
{ value: 'REPORT', icon: FileText, label: t('data_import_export.type_report') },
].map((item) => (
<motion.button
key={item.value}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setExportType(item.value)}
className={`flex items-center gap-2 p-2.5 rounded-lg border-2 transition-all text-sm ${
exportType === item.value
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30'
: 'border-muted hover:border-emerald-300'
}`}
>
<item.icon className={`h-4 w-4 ${exportType === item.value ? 'text-emerald-600' : 'text-muted-foreground'}`} />
<span className={exportType === item.value ? 'text-emerald-700 dark:text-emerald-300 font-medium' : 'text-muted-foreground'}>
{item.label}
</span>
</motion.button>
))}
</div>
</div>
{/* Export Format */}
<div className="space-y-2">
<Label>{t('data_import_export.export_format')}</Label>
<div className="flex gap-3">
{[
{ value: 'CSV', icon: FileSpreadsheet, label: t('data_import_export.format_csv') },
{ value: 'JSON', icon: FileText, label: t('data_import_export.format_json') },
].map((item) => (
<motion.button
key={item.value}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setExportFormat(item.value)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg border-2 transition-all ${
exportFormat === item.value
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30'
: 'border-muted hover:border-emerald-300'
}`}
>
<item.icon className={`h-4 w-4 ${exportFormat === item.value ? 'text-emerald-600' : 'text-muted-foreground'}`} />
<span className={exportFormat === item.value ? 'text-emerald-700 dark:text-emerald-300 font-medium' : 'text-muted-foreground'}>
{item.label}
</span>
</motion.button>
))}
</div>
</div>
<Separator />
{/* Date Range */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
{t('data_import_export.date_range')}
</Label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs text-muted-foreground">{t('data_import_export.date_from')}</Label>
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
</div>
<div>
<Label className="text-xs text-muted-foreground">{t('data_import_export.date_to')}</Label>
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
</div>
</div>
</div>
{/* Class & Subject Filters */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>{t('data_import_export.class_filter')}</Label>
<Input placeholder={t('data_import_export.all_classes')} value={classFilter} onChange={(e) => setClassFilter(e.target.value)} />
</div>
<div className="space-y-2">
<Label>{t('data_import_export.subject_filter')}</Label>
<Input placeholder={t('data_import_export.all_subjects')} value={subjectFilter} onChange={(e) => setSubjectFilter(e.target.value)} />
</div>
</div>
{/* Options */}
<div className="space-y-2">
<Label>{t('data_import_export.export_options')}</Label>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox id="headers" checked={includeHeaders} onCheckedChange={(v) => setIncludeHeaders(!!v)} />
<Label htmlFor="headers" className="text-sm font-normal">{t('data_import_export.include_headers')}</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="metadata" checked={includeMetadata} onCheckedChange={(v) => setIncludeMetadata(!!v)} />
<Label htmlFor="metadata" className="text-sm font-normal">{t('data_import_export.include_metadata')}</Label>
</div>
</div>
</div>
{/* Export Button */}
<Button
size="lg"
className="w-full bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white gap-2"
disabled={exporting}
onClick={handleExport}
>
{exporting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('data_import_export.exporting')}
</>
) : (
<>
<Download className="h-4 w-4" />
{t('data_import_export.start_export')}
</>
)}
</Button>
</CardContent>
</Card>
</motion.div>
{/* Export Templates & Result */}
<div className="space-y-6">
{/* Export Templates */}
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Sparkles className="h-5 w-5 text-emerald-500" />
{t('data_import_export.export_templates')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{[
{ key: 'template_full', types: ['STUDENT', 'GRADE', 'ATTENDANCE', 'COMPETENCY'], formats: ['CSV', 'JSON'] },
{ key: 'template_grades_only', types: ['GRADE'], formats: ['CSV'] },
{ key: 'template_attendance_only', types: ['ATTENDANCE'], formats: ['CSV'] },
{ key: 'template_competency_only', types: ['COMPETENCY'], formats: ['CSV', 'JSON'] },
].map((template) => (
<motion.button
key={template.key}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
onClick={() => {
setExportType(template.types[0]);
setExportFormat(template.formats[0]);
}}
className="w-full flex items-center justify-between p-3 rounded-lg border border-muted hover:border-emerald-300 dark:hover:border-emerald-700 transition-all"
>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{t(`data_import_export.${template.key}`)}</span>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</motion.button>
))}
</CardContent>
</Card>
</motion.div>
{/* Export Result */}
<AnimatePresence>
{exportResult && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }}>
<Card className="border-emerald-200 dark:border-emerald-800">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
{t('data_import_export.export_success')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-3 rounded-lg bg-muted/50">
<p className="text-lg font-bold">{String(exportResult.recordCount ?? 0)}</p>
<p className="text-xs text-muted-foreground">{t('data_import_export.records')}</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/50">
<p className="text-lg font-bold">{formatFileSize(Number(exportResult.fileSize ?? 0))}</p>
<p className="text-xs text-muted-foreground">{t('data_import_export.file_size')}</p>
</div>
</div>
{exportResult.fileData && (
<Button
className="w-full gap-2"
variant="outline"
onClick={() => handleDownload(
String(exportResult.fileData),
String(exportResult.fileName || 'export')
)}
>
<Download className="h-4 w-4" />
{t('data_import_export.download')} — {String(exportResult.fileName)}
</Button>
)}
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</>
) : (
<NoAccessCard />
)}
</TabsContent>
{/* ── HISTORY TAB ─────────────────────────────────────────── */}
<TabsContent value="history" className="space-y-6 mt-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t('data_import_export.history_title')}</h2>
<Button variant="outline" size="sm" className="gap-2" onClick={loadHistory} disabled={historyLoading}>
<RefreshCw className={`h-4 w-4 ${historyLoading ? 'animate-spin' : ''}`} />
{t('action.refresh')}
</Button>
</div>
{/* Import History */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Upload className="h-5 w-5 text-emerald-500" />
{t('data_import_export.import_history')}
</CardTitle>
</CardHeader>
<CardContent>
{importJobs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<History className="h-10 w-10 mx-auto mb-2 opacity-50" />
<p>{t('data_import_export.no_history')}</p>
</div>
) : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.file_name')}</th>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.status')}</th>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.total_rows')}</th>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.success_rows')}</th>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.error_rows')}</th>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.created_at')}</th>
</tr>
</thead>
<tbody>
{importJobs.map((job) => (
<tr key={job.id} className="border-t hover:bg-muted/30">
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="truncate max-w-[200px]">{job.fileName}</span>
</div>
</td>
<td className="px-3 py-2"><StatusBadge status={job.status} /></td>
<td className="px-3 py-2">{job.totalRows}</td>
<td className="px-3 py-2 text-emerald-600 font-medium">{job.successRows}</td>
<td className="px-3 py-2 text-red-600 font-medium">{job.errorRows}</td>
<td className="px-3 py-2 text-muted-foreground">{new Date(job.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Export History */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Download className="h-5 w-5 text-emerald-500" />
{t('data_import_export.history_title')}
</CardTitle>
</CardHeader>
<CardContent>
{exportJobs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Download className="h-10 w-10 mx-auto mb-2 opacity-50" />
<p>{t('data_import_export.no_history')}</p>
</div>
) : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.file_name')}</th>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.export_format')}</th>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.file_size')}</th>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.status')}</th>
<th className="px-3 py-2 text-left font-medium">{t('data_import_export.created_at')}</th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{exportJobs.map((job) => (
<tr key={job.id} className="border-t hover:bg-muted/30">
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="truncate max-w-[200px]">{job.fileName || '—'}</span>
</div>
</td>
<td className="px-3 py-2"><Badge variant="outline">{job.format}</Badge></td>
<td className="px-3 py-2 text-muted-foreground">{formatFileSize(job.fileSize)}</td>
<td className="px-3 py-2"><StatusBadge status={job.status} /></td>
<td className="px-3 py-2 text-muted-foreground">{new Date(job.createdAt).toLocaleDateString()}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{job.status === 'completed' && job.fileData && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1"
onClick={() => handleDownload(job.fileData!, job.fileName || 'export')}
>
<Download className="h-3 w-3" />
</Button>
)}
{isAdmin && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-destructive"
onClick={() => handleDeleteExport(job.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ── MANAGEMENT TAB ──────────────────────────────────────── */}
<TabsContent value="management" className="space-y-6 mt-6">
{canManage ? (
<>
{/* Database Statistics */}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<BarChart3 className="h-5 w-5 text-emerald-500" />
{t('data_import_export.db_statistics')}
</CardTitle>
<Button variant="outline" size="sm" className="gap-2" onClick={loadStats} disabled={statsLoading}>
<RefreshCw className={`h-4 w-4 ${statsLoading ? 'animate-spin' : ''}`} />
{t('action.refresh')}
</Button>
</div>
</CardHeader>
<CardContent>
{statsLoading && !dbStats ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-24 rounded-xl bg-muted animate-pulse" />
))}
</div>
) : dbStats ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{statCards.map((card) => (
<motion.div
key={card.label}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.03 }}
className="relative overflow-hidden rounded-xl p-4 border border-muted"
>
<div className={`absolute inset-0 bg-gradient-to-br ${card.color} opacity-10`} />
<div className="relative z-10">
<card.icon className="h-5 w-5 text-muted-foreground mb-2" />
<p className="text-2xl font-bold text-foreground">
<AnimatedCounter value={card.value} />
</p>
<p className="text-xs text-muted-foreground mt-1">{card.label}</p>
</div>
</motion.div>
))}
</div>
) : null}
</CardContent>
</Card>
</motion.div>
{/* Cleanup Tools */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Trash2 className="h-5 w-5 text-emerald-500" />
{t('data_import_export.cleanup_tools')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Orphaned Records */}
<div className="flex items-center justify-between p-3 rounded-lg border border-muted">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-2">
<AlertCircle className="h-4 w-4 text-amber-600" />
</div>
<div>
<p className="text-sm font-medium">{t('data_import_export.remove_orphans')}</p>
<p className="text-xs text-muted-foreground">
{t('data_import_export.remove_orphans_desc')}
{cleanupInfo && ` (${cleanupInfo.totalOrphans} ${t('data_import_export.records')})`}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-1 text-destructive"
disabled={!canCleanup || !cleanupInfo || cleanupInfo.totalOrphans === 0}
onClick={() => {
setConfirmDialog({
open: true,
type: 'cleanup',
onConfirm: () => handleCleanup('remove_orphans'),
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* Bulk Delete */}
<div className="flex items-center justify-between p-3 rounded-lg border border-muted">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-red-100 dark:bg-red-900/30 p-2">
<Trash2 className="h-4 w-4 text-red-600" />
</div>
<div>
<p className="text-sm font-medium">{t('data_import_export.bulk_delete')}</p>
<p className="text-xs text-muted-foreground">{t('data_import_export.bulk_delete_desc')}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-1 text-destructive"
disabled={!canCleanup}
onClick={() => {
toast.info('Use the import/export features to manage specific records');
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
{/* Backup & Restore */}
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Archive className="h-5 w-5 text-emerald-500" />
{t('data_import_export.backup')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Create Backup */}
<div className="flex items-center justify-between p-3 rounded-lg border border-muted">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2">
<Archive className="h-4 w-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-medium">{t('data_import_export.backup')}</p>
<p className="text-xs text-muted-foreground">{t('data_import_export.backup_desc')}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={!canCleanup}
onClick={() => handleCleanup('backup')}
>
<Archive className="h-3 w-3" />
</Button>
</div>
{/* Restore Backup */}
<div className="flex items-center justify-between p-3 rounded-lg border border-muted">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-teal-100 dark:bg-teal-900/30 p-2">
<RefreshCw className="h-4 w-4 text-teal-600" />
</div>
<div>
<p className="text-sm font-medium">{t('data_import_export.restore')}</p>
<p className="text-xs text-muted-foreground">{t('data_import_export.restore_desc')}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={!canCleanup}
onClick={() => {
toast.info('Backup restore requires server-side access. Contact your administrator.');
}}
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Info Banner */}
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }}>
<div className="flex items-start gap-3 p-4 rounded-xl bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800">
<Info className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div className="text-sm text-emerald-800 dark:text-emerald-200">
<p className="font-medium mb-1">Data Management Best Practices</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>Always create a backup before performing bulk operations</li>
<li>Review the data preview before importing to catch errors early</li>
<li>Use column mapping to ensure correct field alignment</li>
<li>Run cleanup periodically to remove orphaned records</li>
<li>Export data regularly for archival purposes</li>
</ul>
</div>
</div>
</motion.div>
</>
) : (
<NoAccessCard />
)}
</TabsContent>
</Tabs>
{/* Confirm Dialog */}
<Dialog open={confirmDialog.open} onOpenChange={(open) => setConfirmDialog({ open, type: confirmDialog.type, onConfirm: confirmDialog.onConfirm })}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
{confirmDialog.type === 'cleanup' ? t('data_import_export.confirm_cleanup') : t('data_import_export.confirm_delete')}
</DialogTitle>
<DialogDescription>
{confirmDialog.type === 'cleanup' ? t('data_import_export.confirm_cleanup_msg') : t('data_import_export.confirm_delete_msg')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDialog({ open: false, type: '', onConfirm: () => {} })}>
{t('action.cancel')}
</Button>
<Button
variant="destructive"
onClick={confirmDialog.onConfirm}
>
{t('action.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── No Access Card ─────────────────────────────────────────────────
function NoAccessCard() {
return (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<div className="rounded-full bg-rose-100 dark:bg-rose-900/30 p-6">
<Shield className="h-12 w-12 text-rose-500" />
</div>
<h2 className="text-xl font-semibold text-foreground">{t('data_import_export.no_access')}</h2>
<p className="text-muted-foreground text-center max-w-md">{t('data_import_export.no_access_desc')}</p>
</div>
);
}