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 (
+
+ );
+ }
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.
+
+
+
+ );
+}