Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7cfb0ef2c | ||
|
|
a3956ab8c5 | ||
|
|
ae4211bb68 | ||
|
|
3f195e1765 | ||
|
|
7df2789153 | ||
| 4252478e73 |
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
ARG NODE_ENV=production
|
||||||
|
ENV NODE_ENV=development
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY packages/shared/package.json ./packages/shared/
|
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 NODE_ENV=production
|
||||||
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
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { NextConfig } from 'next';
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
transpilePackages: ['@fiberops/shared'],
|
transpilePackages: ['@fiberops/shared'],
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"name": "@fiberops/shared",
|
"name": "@fiberops/shared",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.ts",
|
"main": "./dist/index.js",
|
||||||
"types": "./src/index.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "CommonJS",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "node",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
|||||||
@@ -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 ?? null)).catch(() => {}).finally(() => setLoading(false));
|
api.get('/accounting/overview').then((r) => setData(r.data.data || r.data)).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 ?? [])).catch(() => {});
|
api.get('/accounting/trial-balance').then((r) => setData(r.data.data || r.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 ?? []))
|
.then((r) => setEntries(r.data.data || r.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 ?? [])).catch(() => {});
|
api.get('/accounting/general-ledger').then((r) => setEntries(r.data.data || r.data)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// When initialAccountId changes (clicked from trial balance), update filter
|
// When initialAccountId changes (clicked from trial balance), update filter
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
'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,8 +463,7 @@ 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 isLoading = useAuthStore((s) => s.isLoading);
|
const { toast } = useToast();
|
||||||
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);
|
||||||
@@ -487,8 +486,6 @@ 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))
|
||||||
@@ -513,15 +510,7 @@ 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');
|
||||||
});
|
});
|
||||||
}, [isLoading, user, toast]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // 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 ?? []))
|
api.get('/payments/unremitted').then((r) => setPayments(r.data.data || r.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 = Array.isArray(r.data.data) ? r.data.data : [];
|
const payments = r.data.data || r.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,
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
'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,7 +13,6 @@ 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'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -113,13 +112,3 @@ 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
10
src/app/not-found.tsx
Normal file
10
src/app/not-found.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user