Merge develop to main #1
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ COPY packages/shared/package.json ./packages/shared/
|
|||||||
RUN npm install
|
RUN npm install
|
||||||
COPY packages/shared/ ./packages/shared/
|
COPY packages/shared/ ./packages/shared/
|
||||||
COPY . .
|
COPY . .
|
||||||
|
ARG NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||||
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
RUN npx next build
|
RUN npx next build
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function AccountingOverview() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (loading) {
|
||||||
@@ -226,7 +226,7 @@ function TrialBalance({ onAccountClick }: { onAccountClick: (accountId: string)
|
|||||||
const [selectedAccount, setSelectedAccount] = useState<TrialBalanceItem | null>(null);
|
const [selectedAccount, setSelectedAccount] = useState<TrialBalanceItem | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
const totalDebit = data.reduce((s, a) => s + a.debit, 0);
|
||||||
@@ -332,7 +332,7 @@ function AccountBreakdownModal({
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.get(`/accounting/general-ledger?accountId=${account.id}`)
|
api.get(`/accounting/general-ledger?accountId=${account.id}`)
|
||||||
.then((r) => setEntries(r.data.data || r.data))
|
.then((r) => setEntries(r.data.data ?? []))
|
||||||
.catch(() => setEntries([]))
|
.catch(() => setEntries([]))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [open, account.id]);
|
}, [open, account.id]);
|
||||||
@@ -454,7 +454,7 @@ function GeneralLedger({ initialAccountId, onClearAccountFilter }: { initialAcco
|
|||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
// When initialAccountId changes (clicked from trial balance), update filter
|
||||||
|
|||||||
45
src/app/(dashboard)/dashboard/error.tsx
Normal file
45
src/app/(dashboard)/dashboard/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -463,7 +463,8 @@ function priorityVariant(priority: string): 'error' | 'warning' | 'info' | 'defa
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const user = useAuthStore((s) => s.user);
|
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 [kpis, setKpis] = useState<Kpis | null>(null);
|
||||||
const [kpiDenied, setKpiDenied] = useState(false);
|
const [kpiDenied, setKpiDenied] = useState(false);
|
||||||
@@ -486,6 +487,8 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isLoading || !user) return;
|
||||||
|
|
||||||
api
|
api
|
||||||
.get('/dashboard/kpis')
|
.get('/dashboard/kpis')
|
||||||
.then((r) => setKpis(r.data.data))
|
.then((r) => setKpis(r.data.data))
|
||||||
@@ -510,7 +513,15 @@ export default function DashboardPage() {
|
|||||||
if (isForbidden(err)) setActivityDenied(true);
|
if (isForbidden(err)) setActivityDenied(true);
|
||||||
else toast('Failed to load activity', 'error');
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ function SubmitRemittanceModal({ open, onClose, onSuccess }: { open: boolean; on
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setSelected(new Set()); setNotes(''); setLoading(true);
|
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'))
|
.catch(() => toast('Failed to load unremitted payments', 'error'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [open, toast]);
|
}, [open, toast]);
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ function CompanyOverview() {
|
|||||||
api.get('/dashboard/financial-summary').then((r) => setFinancial(r.data.data)).catch(() => {}),
|
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('/dashboard/revenue-chart').then((r) => setRevenue(r.data.data ?? [])).catch(() => {}),
|
||||||
api.get('/payments/unremitted').then((r) => {
|
api.get('/payments/unremitted').then((r) => {
|
||||||
const payments = r.data.data || r.data || [];
|
const payments = Array.isArray(r.data.data) ? r.data.data : [];
|
||||||
setUnremitted({
|
setUnremitted({
|
||||||
total: payments.reduce((s: number, p: any) => s + Number(p.amount), 0),
|
total: payments.reduce((s: number, p: any) => s + Number(p.amount), 0),
|
||||||
count: payments.length,
|
count: payments.length,
|
||||||
|
|||||||
267
src/app/(dashboard)/dashboard/settings/data-import/page.tsx
Normal file
267
src/app/(dashboard)/dashboard/settings/data-import/page.tsx
Normal 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 & 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const SETTINGS_TABS = [
|
|||||||
{ label: 'Roles', href: '/dashboard/settings/roles', icon: rolesIcon(), roles: ['tenant_admin'] },
|
{ label: 'Roles', href: '/dashboard/settings/roles', icon: rolesIcon(), roles: ['tenant_admin'] },
|
||||||
{ label: 'Plans', href: '/dashboard/settings/plans', icon: plansIcon(), roles: ['manager'] },
|
{ label: 'Plans', href: '/dashboard/settings/plans', icon: plansIcon(), roles: ['manager'] },
|
||||||
{ label: 'Areas', href: '/dashboard/settings/areas', icon: areasIcon(), 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'] },
|
{ label: 'Support', href: '/dashboard/settings/support', icon: supportIcon(), roles: ['tenant_admin'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -112,3 +113,13 @@ function supportIcon() {
|
|||||||
</svg>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
37
src/app/(dashboard)/error.tsx
Normal file
37
src/app/(dashboard)/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user