From 9c4ab46c767b1af275131efe25cfb09b8676f584 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 7 May 2026 06:19:24 +0800 Subject: [PATCH] feat: dashboard error boundaries, loading guards, and data import settings page - Add page-level and layout-level error.tsx boundaries for dashboard - Add loading guard to dashboard page to defer API calls until auth is ready - Fix Dockerfile to accept NEXT_PUBLIC_API_URL as build arg - Fix .env.example API URL to include /api suffix - Add Data Import tab in settings for CSV/Excel import of clients and invoices - Create import page with template download, drag-drop upload, and row-level results --- .env.example | 2 +- Dockerfile | 2 + src/app/(dashboard)/dashboard/error.tsx | 45 +++ src/app/(dashboard)/dashboard/page.tsx | 15 +- .../dashboard/settings/data-import/page.tsx | 267 ++++++++++++++++++ .../(dashboard)/dashboard/settings/layout.tsx | 11 + src/app/(dashboard)/error.tsx | 37 +++ 7 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/error.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/data-import/page.tsx create mode 100644 src/app/(dashboard)/error.tsx diff --git a/.env.example b/.env.example index d658484..4960a3d 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ -NEXT_PUBLIC_API_URL=http://localhost:3001 +NEXT_PUBLIC_API_URL=http://localhost:3001/api diff --git a/Dockerfile b/Dockerfile index 9ccd2a1..881ab9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,8 @@ COPY packages/shared/package.json ./packages/shared/ RUN npm install COPY packages/shared/ ./packages/shared/ COPY . . +ARG NEXT_PUBLIC_API_URL=http://localhost:3001/api +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL RUN npx next build FROM node:20-alpine AS runner diff --git a/src/app/(dashboard)/dashboard/error.tsx b/src/app/(dashboard)/dashboard/error.tsx new file mode 100644 index 0000000..0c80de7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/error.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useEffect } from 'react'; + +export default function DashboardError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('[Dashboard]', error); + }, [error]); + + return ( +
+
+ + + + + +
+

Something went wrong

+

+ The dashboard failed to load. This may be a temporary issue. +

+
+ + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index b5bad9c..d5d5f7c 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -463,7 +463,8 @@ function priorityVariant(priority: string): 'error' | 'warning' | 'info' | 'defa export default function DashboardPage() { const user = useAuthStore((s) => s.user); - const { toast } = useToast(); + const isLoading = useAuthStore((s) => s.isLoading); + const toast = useToast().toast; const [kpis, setKpis] = useState(null); const [kpiDenied, setKpiDenied] = useState(false); @@ -486,6 +487,8 @@ export default function DashboardPage() { } useEffect(() => { + if (isLoading || !user) return; + api .get('/dashboard/kpis') .then((r) => setKpis(r.data.data)) @@ -510,7 +513,15 @@ export default function DashboardPage() { if (isForbidden(err)) setActivityDenied(true); else toast('Failed to load activity', 'error'); }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [isLoading, user, toast]); // eslint-disable-line react-hooks/exhaustive-deps + + if (isLoading) { + return ( +
+

Loading dashboard...

+
+ ); + } return (
diff --git a/src/app/(dashboard)/dashboard/settings/data-import/page.tsx b/src/app/(dashboard)/dashboard/settings/data-import/page.tsx new file mode 100644 index 0000000..c94c871 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/data-import/page.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { api } from '@/lib/api'; +import { useAuthStore } from '@/stores/auth.store'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/components/ui/toast'; + +interface ImportResult { + total: number; + imported: number; + errors: number; + details: { row: number; status: 'ok' | 'error'; message?: string }[]; +} + +type ImportType = 'clients' | 'invoices'; + +export default function DataImportPage() { + const { toast } = useToast(); + const hasRole = useAuthStore((s) => s.hasRole); + + const [clientsFile, setClientsFile] = useState(null); + const [invoicesFile, setInvoicesFile] = useState(null); + const [importingClients, setImportingClients] = useState(false); + const [importingInvoices, setImportingInvoices] = useState(false); + const [clientsResult, setClientsResult] = useState(null); + const [invoicesResult, setInvoicesResult] = useState(null); + + if (!hasRole('tenant_admin')) { + return
Access denied. Admin role required.
; + } + + const downloadTemplate = async (type: ImportType) => { + try { + const res = await api.get(`/import/template/${type}`, { responseType: 'blob' }); + const disposition = res.headers['content-disposition']; + const match = disposition?.match(/filename="(.+)"/); + const filename = match?.[1] || `fiberops_${type}_template.xlsx`; + + const url = window.URL.createObjectURL(new Blob([res.data])); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); + toast('Template downloaded', 'success'); + } catch { + toast('Failed to download template', 'error'); + } + }; + + const handleImport = async ( + type: ImportType, + file: File, + setImporting: (v: boolean) => void, + setResult: (r: ImportResult) => void, + ) => { + setImporting(true); + setResult(null!); + try { + const formData = new FormData(); + formData.append('file', file); + const res = await api.post(`/import/${type}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + setResult(res.data); + toast(`Imported ${res.data.imported} of ${res.data.total} rows`, 'success'); + } catch (err: any) { + const msg = err.response?.data?.message || 'Import failed'; + toast(msg, 'error'); + } finally { + setImporting(false); + } + }; + + return ( +
+ {/* Clients & Subscriptions Import */} +
+
+

Clients & Subscriptions

+

+ Import existing clients with their subscription details. Download the template, fill in your data, and upload. +

+
+ +
+ +
+ + + + {clientsFile && ( + + )} + + {clientsResult && } +
+ + {/* Outstanding Invoices Import */} +
+
+

Outstanding Invoices

+

+ Import unpaid or partially paid invoices for existing clients. Clients must be imported first. +

+
+ +
+ +
+ + + + {invoicesFile && ( + + )} + + {invoicesResult && } +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* File Input Component */ +/* ------------------------------------------------------------------ */ + +function FileInput({ + accept, + file, + onChange, + disabled, +}: { + accept: string; + file: File | null; + onChange: (f: File | null) => void; + disabled: boolean; +}) { + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (disabled) return; + const f = e.dataTransfer.files[0]; + if (f) onChange(f); + }, + [disabled, onChange], + ); + + return ( +
e.preventDefault()} + onDrop={handleDrop} + className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${ + file + ? 'border-primary-300 bg-primary-50/50 dark:border-primary-700 dark:bg-primary-900/20' + : 'border-surface-300 dark:border-surface-600 hover:border-primary-400' + }`} + > + {file ? ( +
+ + + + {file.name} + ({(file.size / 1024).toFixed(1)} KB) + {!disabled && ( + + )} +
+ ) : ( +
+

+ Drag & drop a file here, or{' '} + +

+

Supports CSV, XLSX, XLS (max 10 MB)

+
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Import Results Component */ +/* ------------------------------------------------------------------ */ + +function ImportResults({ result }: { result: ImportResult }) { + return ( +
+
+ + Total: {result.total} + + + Imported: {result.imported} + + {result.errors > 0 && ( + + Errors: {result.errors} + + )} +
+ {result.details.length > 0 && ( +
    + {result.details.map((d, i) => ( +
  • + + {d.status === 'ok' ? '+' : 'x'} + + + Row {d.row}: {d.message} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/layout.tsx b/src/app/(dashboard)/dashboard/settings/layout.tsx index d3c6994..b9c0023 100644 --- a/src/app/(dashboard)/dashboard/settings/layout.tsx +++ b/src/app/(dashboard)/dashboard/settings/layout.tsx @@ -13,6 +13,7 @@ const SETTINGS_TABS = [ { label: 'Roles', href: '/dashboard/settings/roles', icon: rolesIcon(), roles: ['tenant_admin'] }, { label: 'Plans', href: '/dashboard/settings/plans', icon: plansIcon(), roles: ['manager'] }, { label: 'Areas', href: '/dashboard/settings/areas', icon: areasIcon(), roles: ['manager'] }, + { label: 'Data Import', href: '/dashboard/settings/data-import', icon: importIcon(), roles: ['tenant_admin'] }, { label: 'Support', href: '/dashboard/settings/support', icon: supportIcon(), roles: ['tenant_admin'] }, ]; @@ -112,3 +113,13 @@ function supportIcon() { ); } + +function importIcon() { + return ( + + + + + + ); +} diff --git a/src/app/(dashboard)/error.tsx b/src/app/(dashboard)/error.tsx new file mode 100644 index 0000000..97afed7 --- /dev/null +++ b/src/app/(dashboard)/error.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useEffect } from 'react'; + +export default function DashboardLayoutError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('[DashboardLayout]', error); + }, [error]); + + return ( +
+
+ + + + + +
+

Something went wrong

+

+ An unexpected error occurred. Please try again. +

+ +
+ ); +}