Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca1afbd8db | ||
|
|
9c4ab46c76 |
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@fiberops/shared'],
|
||||
output: 'standalone',
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
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() {
|
||||
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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
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: '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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user