From 9c4ab46c767b1af275131efe25cfb09b8676f584 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 7 May 2026 06:19:24 +0800 Subject: [PATCH 1/2] 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. +

+ +
+ ); +} -- 2.43.0 From ca1afbd8dbc506e702f13d9ba63a61a8c9fd77b8 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 7 May 2026 06:49:44 +0800 Subject: [PATCH 2/2] fix: replace dangerous r.data.data || r.data fallback with nullish coalescing The || operator fallback returned the response wrapper object (truthy but not an array) when r.data.data was null/undefined, causing .map() crashes. Use ?? null, ?? [], and Array.isArray() guards instead. --- src/app/(dashboard)/dashboard/accounting/page.tsx | 8 ++++---- src/app/(dashboard)/dashboard/payments/page.tsx | 2 +- src/app/(dashboard)/dashboard/reports/page.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/(dashboard)/dashboard/accounting/page.tsx b/src/app/(dashboard)/dashboard/accounting/page.tsx index b4e7cd5..38c3e89 100644 --- a/src/app/(dashboard)/dashboard/accounting/page.tsx +++ b/src/app/(dashboard)/dashboard/accounting/page.tsx @@ -75,7 +75,7 @@ function AccountingOverview() { const [loading, setLoading] = useState(true); useEffect(() => { - api.get('/accounting/overview').then((r) => setData(r.data.data || r.data)).catch(() => {}).finally(() => setLoading(false)); + api.get('/accounting/overview').then((r) => setData(r.data.data ?? null)).catch(() => {}).finally(() => setLoading(false)); }, []); if (loading) { @@ -226,7 +226,7 @@ function TrialBalance({ onAccountClick }: { onAccountClick: (accountId: string) const [selectedAccount, setSelectedAccount] = useState(null); useEffect(() => { - api.get('/accounting/trial-balance').then((r) => setData(r.data.data || r.data)).catch(() => {}); + api.get('/accounting/trial-balance').then((r) => setData(r.data.data ?? [])).catch(() => {}); }, []); const totalDebit = data.reduce((s, a) => s + a.debit, 0); @@ -332,7 +332,7 @@ function AccountBreakdownModal({ if (!open) return; setLoading(true); api.get(`/accounting/general-ledger?accountId=${account.id}`) - .then((r) => setEntries(r.data.data || r.data)) + .then((r) => setEntries(r.data.data ?? [])) .catch(() => setEntries([])) .finally(() => setLoading(false)); }, [open, account.id]); @@ -454,7 +454,7 @@ function GeneralLedger({ initialAccountId, onClearAccountFilter }: { initialAcco const [dateTo, setDateTo] = useState(''); useEffect(() => { - api.get('/accounting/general-ledger').then((r) => setEntries(r.data.data || r.data)).catch(() => {}); + api.get('/accounting/general-ledger').then((r) => setEntries(r.data.data ?? [])).catch(() => {}); }, []); // When initialAccountId changes (clicked from trial balance), update filter diff --git a/src/app/(dashboard)/dashboard/payments/page.tsx b/src/app/(dashboard)/dashboard/payments/page.tsx index fdc56a6..defcbc9 100644 --- a/src/app/(dashboard)/dashboard/payments/page.tsx +++ b/src/app/(dashboard)/dashboard/payments/page.tsx @@ -217,7 +217,7 @@ function SubmitRemittanceModal({ open, onClose, onSuccess }: { open: boolean; on useEffect(() => { if (!open) return; setSelected(new Set()); setNotes(''); setLoading(true); - api.get('/payments/unremitted').then((r) => setPayments(r.data.data || r.data)) + api.get('/payments/unremitted').then((r) => setPayments(r.data.data ?? [])) .catch(() => toast('Failed to load unremitted payments', 'error')) .finally(() => setLoading(false)); }, [open, toast]); diff --git a/src/app/(dashboard)/dashboard/reports/page.tsx b/src/app/(dashboard)/dashboard/reports/page.tsx index f3e7e2d..26b255f 100644 --- a/src/app/(dashboard)/dashboard/reports/page.tsx +++ b/src/app/(dashboard)/dashboard/reports/page.tsx @@ -238,7 +238,7 @@ function CompanyOverview() { api.get('/dashboard/financial-summary').then((r) => setFinancial(r.data.data)).catch(() => {}), api.get('/dashboard/revenue-chart').then((r) => setRevenue(r.data.data ?? [])).catch(() => {}), api.get('/payments/unremitted').then((r) => { - const payments = r.data.data || r.data || []; + const payments = Array.isArray(r.data.data) ? r.data.data : []; setUnremitted({ total: payments.reduce((s: number, p: any) => s + Number(p.amount), 0), count: payments.length, -- 2.43.0