2 Commits
dev ... develop

Author SHA1 Message Date
kevin-asprec
ca1afbd8db 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.
2026-05-07 06:49:44 +08:00
kevin-asprec
9c4ab46c76 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
2026-05-07 06:19:24 +08:00
16 changed files with 390 additions and 71 deletions

View File

@@ -1 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3001/api

View File

@@ -1,13 +1,12 @@
FROM node:20-alpine AS builder
ARG NODE_ENV=production
ENV NODE_ENV=development
WORKDIR /app
COPY package.json package-lock.json* ./
COPY packages/shared/package.json ./packages/shared/
RUN npm install
COPY packages/shared/ ./packages/shared/
COPY . .
ENV NODE_ENV=production
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

View File

@@ -1,2 +0,0 @@
# fiberops-web-new

View File

@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@fiberops/shared'],
output: 'standalone',
};

View File

@@ -2,8 +2,8 @@
"name": "@fiberops/shared",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc",
"lint": "tsc --noEmit",

View File

@@ -1,14 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",

View File

@@ -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<TrialBalanceItem | null>(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

View File

@@ -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 (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 px-6">
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-red-600 dark:text-red-400">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h2 className="text-lg font-semibold text-surface-900 dark:text-surface-100">Something went wrong</h2>
<p className="text-sm text-surface-500 dark:text-surface-400 text-center max-w-md">
The dashboard failed to load. This may be a temporary issue.
</p>
<div className="flex gap-3">
<button
onClick={() => reset()}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
Try again
</button>
<button
onClick={() => (window.location.href = '/dashboard')}
className="rounded-lg border border-surface-300 dark:border-surface-600 px-4 py-2 text-sm font-medium text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors"
>
Reload page
</button>
</div>
</div>
);
}

View File

@@ -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<Kpis | null>(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 (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-surface-400">Loading dashboard...</p>
</div>
);
}
return (
<div className="flex flex-col h-full">

View File

@@ -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]);

View File

@@ -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,

View File

@@ -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<File | null>(null);
const [invoicesFile, setInvoicesFile] = useState<File | null>(null);
const [importingClients, setImportingClients] = useState(false);
const [importingInvoices, setImportingInvoices] = useState(false);
const [clientsResult, setClientsResult] = useState<ImportResult | null>(null);
const [invoicesResult, setInvoicesResult] = useState<ImportResult | null>(null);
if (!hasRole('tenant_admin')) {
return <div className="text-red-600 font-medium" role="alert">Access denied. Admin role required.</div>;
}
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<ImportResult>(`/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 (
<div className="space-y-8 max-w-3xl">
{/* Clients & Subscriptions Import */}
<section className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6 space-y-4">
<div>
<h2 className="text-lg font-semibold text-surface-900 dark:text-surface-100">Clients &amp; Subscriptions</h2>
<p className="text-sm text-surface-500 mt-1">
Import existing clients with their subscription details. Download the template, fill in your data, and upload.
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => downloadTemplate('clients')}>
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="mr-2">
<path d="M2 12v3a2 2 0 002 2h10a2 2 0 002-2v-3" /><path d="M9 2v10" /><path d="M5 8l4 4 4-4" />
</svg>
Download Template
</Button>
</div>
<FileInput
accept=".csv,.xlsx,.xls"
file={clientsFile}
onChange={setClientsFile}
disabled={importingClients}
/>
{clientsFile && (
<Button
onClick={() => handleImport('clients', clientsFile, setImportingClients, setClientsResult)}
loading={importingClients}
>
Import Clients
</Button>
)}
{clientsResult && <ImportResults result={clientsResult} />}
</section>
{/* Outstanding Invoices Import */}
<section className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6 space-y-4">
<div>
<h2 className="text-lg font-semibold text-surface-900 dark:text-surface-100">Outstanding Invoices</h2>
<p className="text-sm text-surface-500 mt-1">
Import unpaid or partially paid invoices for existing clients. Clients must be imported first.
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => downloadTemplate('invoices')}>
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="mr-2">
<path d="M2 12v3a2 2 0 002 2h10a2 2 0 002-2v-3" /><path d="M9 2v10" /><path d="M5 8l4 4 4-4" />
</svg>
Download Template
</Button>
</div>
<FileInput
accept=".csv,.xlsx,.xls"
file={invoicesFile}
onChange={setInvoicesFile}
disabled={importingInvoices}
/>
{invoicesFile && (
<Button
onClick={() => handleImport('invoices', invoicesFile, setImportingInvoices, setInvoicesResult)}
loading={importingInvoices}
>
Import Invoices
</Button>
)}
{invoicesResult && <ImportResults result={invoicesResult} />}
</section>
</div>
);
}
/* ------------------------------------------------------------------ */
/* 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 (
<div
onDragOver={(e) => 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 ? (
<div className="flex items-center justify-center gap-3">
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary-600">
<rect x="2" y="2" width="14" height="14" rx="2" /><path d="M6 9l2.5 2.5L12 7" />
</svg>
<span className="text-sm font-medium text-surface-700 dark:text-surface-300">{file.name}</span>
<span className="text-xs text-surface-400">({(file.size / 1024).toFixed(1)} KB)</span>
{!disabled && (
<button
type="button"
onClick={() => onChange(null)}
className="text-surface-400 hover:text-red-500 transition-colors ml-2"
>
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 4l10 10M14 4L4 14" />
</svg>
</button>
)}
</div>
) : (
<div>
<p className="text-sm text-surface-500">
Drag &amp; drop a file here, or{' '}
<label className="text-primary-600 hover:text-primary-700 cursor-pointer font-medium">
browse
<input
type="file"
accept={accept}
className="hidden"
onChange={(e) => e.target.files?.[0] && onChange(e.target.files[0])}
disabled={disabled}
/>
</label>
</p>
<p className="text-xs text-surface-400 mt-1">Supports CSV, XLSX, XLS (max 10 MB)</p>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Import Results Component */
/* ------------------------------------------------------------------ */
function ImportResults({ result }: { result: ImportResult }) {
return (
<div className="border border-surface-200 dark:border-surface-700 rounded-lg overflow-hidden">
<div className="flex items-center gap-4 px-4 py-3 bg-surface-50 dark:bg-surface-800/50 border-b border-surface-200 dark:border-surface-700">
<span className="text-sm font-medium text-surface-700 dark:text-surface-300">
Total: {result.total}
</span>
<span className="text-sm font-medium text-green-600 dark:text-green-400">
Imported: {result.imported}
</span>
{result.errors > 0 && (
<span className="text-sm font-medium text-red-600 dark:text-red-400">
Errors: {result.errors}
</span>
)}
</div>
{result.details.length > 0 && (
<ul className="divide-y divide-surface-100 dark:divide-surface-700/50 max-h-64 overflow-y-auto">
{result.details.map((d, i) => (
<li key={i} className="px-4 py-2 flex items-start gap-2 text-sm">
<span className={d.status === 'ok' ? 'text-green-500' : 'text-red-500'}>
{d.status === 'ok' ? '+' : 'x'}
</span>
<span className="text-surface-600 dark:text-surface-400">
Row {d.row}: {d.message}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -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() {
</svg>
);
}
function importIcon() {
return (
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12v3a2 2 0 002 2h10a2 2 0 002-2v-3" />
<path d="M9 2v10" />
<path d="M5 8l4 4 4-4" />
</svg>
);
}

View File

@@ -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 (
<div className="flex flex-col items-center justify-center min-h-screen gap-4 px-6 bg-surface-50 dark:bg-surface-900">
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-red-600 dark:text-red-400">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h2 className="text-lg font-semibold text-surface-900 dark:text-surface-100">Something went wrong</h2>
<p className="text-sm text-surface-500 dark:text-surface-400 text-center max-w-md">
An unexpected error occurred. Please try again.
</p>
<button
onClick={() => reset()}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
Try again
</button>
</div>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
import { useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function ImpersonateHandler() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const token = searchParams.get('token');
const userStr = searchParams.get('user');
const tenantStr = searchParams.get('tenant');
if (token && userStr && tenantStr) {
localStorage.setItem('accessToken', token);
localStorage.setItem('user', userStr);
localStorage.setItem('tenant', tenantStr);
localStorage.setItem('is_impersonating', 'true');
router.push('/dashboard');
} else {
router.push('/login');
}
}, [router, searchParams]);
return null;
}
export default function ImpersonatePage() {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-50 dark:bg-surface-900">
<div className="text-center">
<div className="w-10 h-10 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-surface-600 dark:text-surface-400">Loading impersonation session...</p>
</div>
<Suspense fallback={null}>
<ImpersonateHandler />
</Suspense>
</div>
);
}

View File

@@ -1,10 +0,0 @@
export default function NotFound() {
return (
<html lang="en">
<body>
<h1>404 - Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
</body>
</html>
);
}