Merge develop to main #1

Open
kibin wants to merge 2 commits from develop into main
7 changed files with 376 additions and 3 deletions
Showing only changes of commit 9c4ab46c76 - Show all commits

View File

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

View File

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

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() { 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">

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: '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>
);
}

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>
);
}