initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:37 +08:00
commit 3f5bf0e118
88 changed files with 10997 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getProfile, isAuthenticated } from '@/lib/auth';
import { useAuthStore } from '@/stores/auth.store';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const setUser = useAuthStore((s) => s.setUser);
const setLoading = useAuthStore((s) => s.setLoading);
const isLoading = useAuthStore((s) => s.isLoading);
useEffect(() => {
async function loadUser() {
if (!isAuthenticated()) {
setUser(null);
router.push('/login');
return;
}
try {
const user = await getProfile();
setUser(user);
} catch {
setUser(null);
router.push('/login');
}
}
loadUser();
}, [router, setUser, setLoading]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,165 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { logout } from '@/lib/auth';
import { useAuthStore } from '@/stores/auth.store';
import { api } from '@/lib/api';
function timeAgo(date: string) {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
interface Notification {
id: string;
title: string;
message: string;
isRead: boolean;
createdAt: string;
channel: string;
}
export function Header({ onSupportOpen }: { onSupportOpen: () => void }) {
const router = useRouter();
const { theme, setTheme } = useTheme();
const logoutStore = useAuthStore((s) => s.logout);
const [unread, setUnread] = useState(0);
const [showNotifs, setShowNotifs] = useState(false);
const [notifs, setNotifs] = useState<Notification[]>([]);
const fetchUnread = useCallback(() => {
api.get('/notifications/unread-count').then((r) => setUnread(r.data.data.count)).catch(() => {});
}, []);
// Auto-refresh unread count every 30s and on window focus
useEffect(() => {
fetchUnread();
const interval = setInterval(fetchUnread, 30_000);
const onFocus = () => fetchUnread();
window.addEventListener('focus', onFocus);
return () => { clearInterval(interval); window.removeEventListener('focus', onFocus); };
}, [fetchUnread]);
async function toggleNotifs() {
const next = !showNotifs;
setShowNotifs(next);
if (next) {
const res = await api.get('/notifications');
setNotifs(res.data.data);
}
}
async function markAllRead() {
await api.patch('/notifications/read-all');
setUnread(0);
setNotifs(notifs.map((n) => ({ ...n, isRead: true })));
}
async function markOneRead(id: string) {
await api.patch(`/notifications/${id}/read`);
setNotifs(notifs.map((n) => n.id === id ? { ...n, isRead: true } : n));
setUnread((u) => Math.max(0, u - 1));
}
function handleLogout() {
logout();
logoutStore();
router.push('/login');
}
return (
<header className="h-14 bg-white/80 dark:bg-surface-900/80 backdrop-blur-sm border-b border-surface-200/60 dark:border-surface-700/60 flex items-center justify-end gap-3 px-6 shrink-0 z-10">
{/* Notification bell */}
<div className="relative">
<button onClick={toggleNotifs}
className="relative p-2 text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors duration-200 cursor-pointer rounded-lg hover:bg-surface-50 dark:hover:bg-surface-700">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M13.73 12.73A14.65 14.65 0 0014.5 9V7.5a5.5 5.5 0 10-11 0V9c0 1.3.26 2.56.77 3.73L3 14h12l-1.27-1.27z" />
<path d="M7 14v.5a2 2 0 004 0V14" />
</svg>
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
{unread > 9 ? '9+' : unread}
</span>
)}
</button>
{showNotifs && (
<div className="absolute right-0 top-12 w-80 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl shadow-lg shadow-surface-200/50 dark:shadow-surface-900/50 overflow-hidden z-50">
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-100 dark:border-surface-700">
<span className="text-sm font-semibold text-surface-800 dark:text-surface-200">Notifications</span>
{unread > 0 && (
<button onClick={markAllRead} className="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 cursor-pointer">Mark all read</button>
)}
</div>
<div className="max-h-64 overflow-y-auto">
{notifs.length > 0 ? notifs.map((n) => (
<button
key={n.id}
onClick={() => { if (!n.isRead) markOneRead(n.id); }}
className={`w-full text-left px-4 py-3 border-b border-surface-50 dark:border-surface-700/50 transition-colors ${
!n.isRead ? 'bg-primary-50/30 dark:bg-primary-900/20 hover:bg-primary-50/50 dark:hover:bg-primary-900/30' : 'hover:bg-surface-50 dark:hover:bg-surface-700/50'
}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-surface-800 dark:text-surface-200">{n.title}</p>
{!n.isRead && <span className="mt-1.5 w-2 h-2 rounded-full bg-primary-500 shrink-0" />}
</div>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-0.5">{n.message}</p>
<p className="text-[10px] text-surface-300 dark:text-surface-600 mt-1">{timeAgo(n.createdAt)}</p>
</button>
)) : (
<div className="px-4 py-6 text-center text-sm text-surface-400 dark:text-surface-500">No notifications</div>
)}
</div>
</div>
)}
</div>
{/* Dark/Light mode toggle */}
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors duration-200 cursor-pointer rounded-lg hover:bg-surface-50 dark:hover:bg-surface-700"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
{theme === 'dark' ? (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="9" cy="9" r="3.5" />
<path d="M9 1.5v1M9 15.5v1M1.5 9h1M15.5 9h1M3.4 3.4l.7.7M13.9 13.9l.7.7M3.4 14.6l.7-.7M13.9 4.1l.7-.7" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 10.7A6 6 0 017.3 4 6 6 0 1014 10.7z" />
</svg>
)}
</button>
{/* Support icon */}
<button onClick={onSupportOpen}
className="p-2 text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors duration-200 cursor-pointer rounded-lg hover:bg-surface-50 dark:hover:bg-surface-700"
title="Support">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="9" r="7.5" />
<path d="M6.75 7.5a2.25 2.25 0 014.5 0c0 1.5-2.25 1.875-2.25 3" />
<circle cx="9" cy="13.125" r="0.375" fill="currentColor" />
</svg>
</button>
<div className="w-px h-5 bg-surface-200 dark:bg-surface-700" />
<button onClick={handleLogout}
className="flex items-center gap-2 text-sm text-surface-500 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 transition-colors duration-200 cursor-pointer">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3M11 11l3-3-3-3M14 8H6" />
</svg>
Sign out
</button>
</header>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
const CHANGE_PASSWORD_PATH = '/dashboard/change-password';
export function MustChangePasswordGuard({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (isLoading || !user) return;
if (user.mustChangePassword && pathname !== CHANGE_PASSWORD_PATH) {
router.replace(CHANGE_PASSWORD_PATH);
}
}, [user?.mustChangePassword, pathname, isLoading, router]);
if (isLoading) return null;
// If user must change password, only render the change-password page content
if (user?.mustChangePassword && pathname !== CHANGE_PASSWORD_PATH) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,159 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
/** Module name for access check — if set, item only shows when user canView this module */
module?: string;
/** Fallback: legacy role check (used if module not set) */
roles?: string[];
}
/* SVG icons — no emojis */
const icons = {
dashboard: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="1" width="7" height="7" rx="1.5" /><rect x="10" y="1" width="7" height="4" rx="1.5" /><rect x="1" y="10" width="7" height="4" rx="1.5" /><rect x="10" y="7" width="7" height="7" rx="1.5" />
</svg>
),
clients: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="9" cy="5.5" r="3" /><path d="M2 16.5c0-3.314 3.134-6 7-6s7 2.686 7 6" />
</svg>
),
subscriptions: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h12M3 10h12M3 14h8" />
</svg>
),
tickets: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<rect x="2" y="3" width="14" height="12" rx="2" /><path d="M6 3v12M2 9h4M12 9h4" />
</svg>
),
invoices: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 2h10a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V3a1 1 0 011-1z" /><path d="M6 6h6M6 9h6M6 12h3" />
</svg>
),
payments: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="9" cy="9" r="7" /><path d="M9 5v8M7 7h3.5a1.5 1.5 0 010 3H7h4a1.5 1.5 0 010 3H7" />
</svg>
),
areas: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 16s-6-4.35-6-8.5a6 6 0 0112 0C15 11.65 9 16 9 16z" /><circle cx="9" cy="7.5" r="2" />
</svg>
),
plans: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="14" height="14" rx="2" /><path d="M6 6h6v6H6z" />
</svg>
),
users: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="6.5" cy="5" r="2.5" /><circle cx="12.5" cy="5" r="2.5" /><path d="M1 15c0-2.761 2.462-5 5.5-5s5.5 2.239 5.5 5M10 15c0-2.761 1.12-5 2.5-5s2.5 2.239 2.5 5" />
</svg>
),
settings: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="9" cy="9" r="2.5" /><path d="M14.7 11.1a1.2 1.2 0 00.24 1.32l.04.04a1.45 1.45 0 11-2.05 2.05l-.04-.04a1.2 1.2 0 00-1.32-.24 1.2 1.2 0 00-.73 1.1v.12a1.45 1.45 0 01-2.9 0v-.06a1.2 1.2 0 00-.79-1.1 1.2 1.2 0 00-1.32.24l-.04.04a1.45 1.45 0 11-2.05-2.05l.04-.04a1.2 1.2 0 00.24-1.32 1.2 1.2 0 00-1.1-.73h-.12a1.45 1.45 0 010-2.9h.06a1.2 1.2 0 001.1-.79 1.2 1.2 0 00-.24-1.32l-.04-.04a1.45 1.45 0 112.05-2.05l.04.04a1.2 1.2 0 001.32.24h.06a1.2 1.2 0 00.73-1.1v-.12a1.45 1.45 0 012.9 0v.06a1.2 1.2 0 00.73 1.1 1.2 1.2 0 001.32-.24l.04-.04a1.45 1.45 0 112.05 2.05l-.04.04a1.2 1.2 0 00-.24 1.32v.06a1.2 1.2 0 001.1.73h.12a1.45 1.45 0 010 2.9h-.06a1.2 1.2 0 00-1.1.73z" />
</svg>
),
};
const NAV_ITEMS: NavItem[] = [
{ label: 'Dashboard', href: '/dashboard', icon: icons.dashboard, module: 'dashboard' },
{ label: 'Clients', href: '/dashboard/clients', icon: icons.clients, module: 'clients' },
{ label: 'Tickets', href: '/dashboard/tickets', icon: icons.tickets, module: 'tickets' },
{ label: 'Invoices', href: '/dashboard/invoices', icon: icons.invoices, module: 'invoices' },
{ label: 'Payments', href: '/dashboard/payments', icon: icons.payments, module: 'payments' },
{ label: 'Employees', href: '/dashboard/employees', icon: icons.users, module: 'employees' },
{ label: 'Payroll', href: '/dashboard/payroll', icon: icons.payments, module: 'payroll' },
{ label: 'Expenses', href: '/dashboard/expenses', icon: icons.payments, module: 'expenses' },
{ label: 'Assets', href: '/dashboard/assets', icon: icons.plans, module: 'assets' },
{ label: 'Fund Transfers', href: '/dashboard/accounts', icon: icons.invoices, module: 'fund_transfers' },
{ label: 'Accounting', href: '/dashboard/accounting', icon: icons.subscriptions, module: 'accounting' },
{ label: 'Reports', href: '/dashboard/reports', icon: icons.invoices, module: 'reports' },
{ label: 'Settings', href: '/dashboard/settings', icon: icons.settings, module: 'settings' },
];
export function Sidebar() {
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const canView = useAuthStore((s) => s.canView);
const isSuperAdmin = useAuthStore((s) => s.isSuperAdmin);
const visibleItems = NAV_ITEMS.filter((item) => {
// Super admin sees everything
if (isSuperAdmin()) return true;
// Check module-level access
if (item.module) return canView(item.module);
return true;
});
return (
<aside className="w-60 bg-white dark:bg-surface-900 border-r border-surface-200/80 dark:border-surface-700/80 flex flex-col overflow-y-auto">
{/* Logo */}
<div className="px-5 py-5 border-b border-surface-100 dark:border-surface-700">
<div className="flex items-center gap-2.5">
<svg width="28" height="28" viewBox="0 0 40 40" fill="none" className="text-primary-600 dark:text-primary-400">
<rect width="40" height="40" rx="10" fill="currentColor" fillOpacity="0.1" />
<path d="M12 20h16M20 12v16" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
<div>
<span className="text-base font-bold text-surface-900 dark:text-surface-100 tracking-tight">FiberOps</span>
{user?.tenant && (
<p className="text-[11px] text-surface-400 leading-none mt-0.5">{user.tenant.name}</p>
)}
</div>
</div>
</div>
{/* Nav */}
<nav aria-label="Main navigation" className="flex-1 px-3 py-4 space-y-0.5">
{visibleItems.map((item) => {
const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? 'page' : undefined}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-[13px] font-medium transition-all duration-200 cursor-pointer ${
isActive
? 'bg-primary-50 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 shadow-sm shadow-primary-100 dark:shadow-primary-900/30'
: 'text-surface-500 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-700 hover:text-surface-800 dark:hover:text-surface-200'
}`}
>
<span aria-hidden="true" className={isActive ? 'text-primary-600 dark:text-primary-400' : 'text-surface-400 dark:text-surface-500'}>{item.icon}</span>
{item.label}
</Link>
);
})}
</nav>
{/* User */}
<div className="px-4 py-4 border-t border-surface-100 dark:border-surface-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/50 flex items-center justify-center text-primary-700 dark:text-primary-300 text-xs font-bold">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-surface-800 dark:text-surface-200 truncate">
{user?.firstName} {user?.lastName}
</p>
<p className="text-[11px] text-surface-400 truncate">
{user?.tenantRoles?.map((r) => r.name).join(', ') || user?.roles?.join(', ') || ''}
</p>
</div>
</div>
</div>
</aside>
);
}