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

View File

@@ -0,0 +1,159 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import L from 'leaflet';
// Fix marker icon paths for webpack/Next.js
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
const DEFAULT_CENTER: [number, number] = [14.5995, 120.9842]; // Manila
const DEFAULT_ZOOM = 15;
interface LeafletMapProps {
latitude?: number | null;
longitude?: number | null;
height?: string;
interactive?: boolean;
onLocationSelect?: (lat: number, lng: number) => void;
zoom?: number;
}
export function LeafletMap({
latitude,
longitude,
height = '300px',
interactive = false,
onLocationSelect,
zoom = DEFAULT_ZOOM,
}: LeafletMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const markerRef = useRef<L.Marker | null>(null);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
const center: [number, number] =
latitude != null && longitude != null
? [latitude, longitude]
: DEFAULT_CENTER;
const map = L.map(mapRef.current, {
center,
zoom,
zoomControl: true,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
// Add marker if coordinates provided
if (latitude != null && longitude != null) {
const marker = L.marker([latitude, longitude]).addTo(map);
markerRef.current = marker;
}
// Click to place marker in interactive mode
if (interactive && onLocationSelect) {
map.on('click', (e: L.LeafletMouseEvent) => {
const { lat, lng } = e.latlng;
if (markerRef.current) {
markerRef.current.setLatLng([lat, lng]);
} else {
const marker = L.marker([lat, lng]).addTo(map);
markerRef.current = marker;
}
onLocationSelect(lat, lng);
});
}
mapInstanceRef.current = map;
// Try geolocation on first load if no coordinates
if (latitude == null || longitude == null) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
map.setView([pos.coords.latitude, pos.coords.longitude], zoom);
},
() => {
// Geolocation denied — keep default center
},
);
}
}
return () => {
map.remove();
mapInstanceRef.current = null;
markerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update marker when coordinates change externally
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (latitude != null && longitude != null) {
if (markerRef.current) {
markerRef.current.setLatLng([latitude, longitude]);
} else {
const marker = L.marker([latitude, longitude]).addTo(map);
markerRef.current = marker;
}
map.setView([latitude, longitude], zoom);
}
}, [latitude, longitude, zoom]);
const handleUseMyLocation = useCallback(() => {
const map = mapInstanceRef.current;
if (!map || !navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude: lat, longitude: lng } = pos.coords;
map.setView([lat, lng], zoom);
if (markerRef.current) {
markerRef.current.setLatLng([lat, lng]);
} else {
const marker = L.marker([lat, lng]).addTo(map);
markerRef.current = marker;
}
onLocationSelect?.(lat, lng);
},
() => {
// Geolocation failed
},
);
}, [zoom, onLocationSelect]);
return (
<div className="relative" style={{ height }}>
<div ref={mapRef} className="absolute inset-0 rounded-lg" />
{interactive && (
<button
type="button"
onClick={handleUseMyLocation}
className="absolute top-2 right-2 z-[1000] flex items-center gap-1.5 rounded-lg bg-white px-3 py-2 text-xs font-medium text-surface-700 shadow-md border border-surface-200 hover:bg-surface-50 transition-colors cursor-pointer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
</svg>
My Location
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { useState } from 'react';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { LeafletMap } from './leaflet-map';
interface LocationPickerModalProps {
open: boolean;
onClose: () => void;
onConfirm: (latitude: number, longitude: number) => void;
title?: string;
description?: string;
initialLatitude?: number | null;
initialLongitude?: number | null;
}
export function LocationPickerModal({
open,
onClose,
onConfirm,
title = 'Pin Client Location',
description = 'Click on the map to pin the client location, or use your current location.',
initialLatitude,
initialLongitude,
}: LocationPickerModalProps) {
const [latitude, setLatitude] = useState<number | null>(initialLatitude ?? null);
const [longitude, setLongitude] = useState<number | null>(initialLongitude ?? null);
// Reset when modal opens
const isOpen = open;
if (isOpen && latitude === null && initialLatitude != null) {
setLatitude(initialLatitude);
setLongitude(initialLongitude ?? null);
}
function handleSelect(lat: number, lng: number) {
setLatitude(lat);
setLongitude(lng);
}
function handleConfirm() {
if (latitude !== null && longitude !== null) {
onConfirm(latitude, longitude);
}
}
return (
<FormModal open={open} onClose={onClose} title={title} description={description} wide>
<div className="space-y-4">
<LeafletMap
latitude={latitude}
longitude={longitude}
height="350px"
interactive
onLocationSelect={handleSelect}
/>
{latitude !== null && longitude !== null ? (
<div className="flex items-center justify-between rounded-lg bg-surface-50 px-4 py-3">
<span className="text-sm text-surface-600">
<span className="font-medium text-surface-800">Lat:</span> {latitude.toFixed(6)},{' '}
<span className="font-medium text-surface-800">Lng:</span> {longitude.toFixed(6)}
</span>
</div>
) : (
<p className="text-sm text-surface-400 text-center py-2">
Click on the map to pin the location
</p>
)}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleConfirm} disabled={latitude === null || longitude === null}>
Confirm Location
</Button>
</div>
</div>
</FormModal>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
interface Area { id: string; name: string; }
interface Plan { id: string; name: string; price: string; speedDown: number; speedUp: number; }
interface CreateClientModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function CreateClientModal({ open, onClose, onSuccess }: CreateClientModalProps) {
const { toast } = useToast();
const [areas, setAreas] = useState<Area[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [form, setForm] = useState({
firstName: '', lastName: '', email: '', phone: '', address: '', areaId: '',
planId: '', subscriptionType: 'postpaid',
});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
api.get('/areas').then((r) => setAreas(r.data.data)).catch(() => {});
api.get('/plans').then((r) => setPlans(r.data.data)).catch(() => {});
setForm({ firstName: '', lastName: '', email: '', phone: '', address: '', areaId: '', planId: '', subscriptionType: 'postpaid' });
}
}, [open]);
const selectedPlan = plans.find((p) => p.id === form.planId);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.planId) { toast('Please select a plan', 'error'); return; }
setSubmitting(true);
try {
await api.post('/clients', {
...form,
email: form.email || undefined,
phone: form.phone || undefined,
areaId: form.areaId || undefined,
});
toast('Client onboarded! Installation ticket created.', 'success');
onSuccess();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to onboard client', 'error');
} finally {
setSubmitting(false);
}
}
const inputClass = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<FormModal open={open} onClose={onClose} title="Onboard New Client" description="Register client, assign plan, and start the installation workflow." wide>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Client Info */}
<div>
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Client Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="c-first" className="block text-sm font-medium text-surface-700 dark:text-surface-300">First Name</label>
<input id="c-first" type="text" required value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} className={inputClass} />
</div>
<div>
<label htmlFor="c-last" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Last Name</label>
<input id="c-last" type="text" required value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} className={inputClass} />
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<label htmlFor="c-email" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Email <span className="text-surface-400 font-normal">(optional)</span></label>
<input id="c-email" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
</div>
<div>
<label htmlFor="c-phone" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Phone <span className="text-surface-400 font-normal">(optional)</span></label>
<input id="c-phone" type="text" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={inputClass} placeholder="09171234567" />
</div>
</div>
<div className="mt-3">
<label htmlFor="c-addr" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Address</label>
<input id="c-addr" type="text" required minLength={5} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} className={inputClass} placeholder="Street, Barangay, City" />
</div>
<div className="mt-3">
<label htmlFor="c-area" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Area <span className="text-surface-400 font-normal">(optional)</span></label>
<select id="c-area" value={form.areaId} onChange={(e) => setForm({ ...form, areaId: e.target.value })} className={inputClass}>
<option value="">No area assigned</option>
{areas.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
</div>
{/* Plan & Subscription */}
<div className="border-t border-surface-200 pt-5">
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Subscription Plan <span className="text-red-400">*</span></h3>
<div className="flex gap-3 mb-4">
{(['postpaid', 'prepaid'] as const).map((t) => (
<button key={t} type="button" onClick={() => setForm({ ...form, subscriptionType: t })}
className={`flex-1 px-4 py-3 rounded-lg border text-sm font-medium transition-all duration-200 cursor-pointer text-left ${
form.subscriptionType === t
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 ring-2 ring-primary-500/20'
: 'border-surface-200 dark:border-surface-600 text-surface-500 dark:text-surface-400 hover:border-surface-300 dark:hover:border-surface-500'
}`}>
<span className="block font-semibold">{t === 'postpaid' ? 'Postpaid' : 'Prepaid'}</span>
<span className="block text-[11px] font-normal mt-0.5 text-surface-400">
{t === 'postpaid' ? 'Install → Activate → Invoice after 1 month' : 'Install → Pay first → Then activate'}
</span>
</button>
))}
</div>
<label className="block text-sm font-medium text-surface-700 mb-2">Select Plan</label>
<div className="space-y-2 max-h-48 overflow-y-auto">
{plans.map((p) => (
<button key={p.id} type="button" onClick={() => setForm({ ...form, planId: p.id })}
className={`w-full text-left px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer ${
form.planId === p.id
? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20 ring-2 ring-primary-500/20'
: 'border-surface-200 dark:border-surface-600 hover:border-surface-300 dark:hover:border-surface-500'
}`}>
<div className="flex items-center justify-between">
<div>
<span className="font-medium text-surface-800 dark:text-surface-200">{p.name}</span>
<span className="ml-2 text-xs text-surface-500">{p.speedDown}/{p.speedUp} Mbps</span>
</div>
<span className="font-medium text-surface-900 dark:text-surface-100">PHP {Number(p.price).toLocaleString()}/mo</span>
</div>
</button>
))}
{plans.length === 0 && (
<div className="px-4 py-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700">
No plans available. Create plans in Settings first.
</div>
)}
</div>
</div>
{/* Summary */}
{selectedPlan && (
<div className="bg-surface-50 dark:bg-surface-800 rounded-lg p-4 text-sm space-y-1">
<p className="font-medium text-surface-800 dark:text-surface-200">Onboarding Summary</p>
<p className="text-surface-600 dark:text-surface-400">Plan: {selectedPlan.name} PHP {Number(selectedPlan.price).toLocaleString()}/mo ({form.subscriptionType})</p>
<p className="text-surface-500 text-xs">
{form.subscriptionType === 'postpaid'
? 'Install ticket → resolve → Activation ticket → resolve → Active + 1st invoice (due 1 month)'
: 'Install ticket → resolve → 1st invoice → pay → Activation ticket → resolve → Active + next invoice'}
</p>
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting} disabled={!form.planId}>Onboard Client</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
const CATEGORIES = ['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other'];
interface CreateExpenseModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function CreateExpenseModal({ open, onClose, onSuccess }: CreateExpenseModalProps) {
const { toast } = useToast();
const [form, setForm] = useState({ category: 'utilities', description: '', amount: 0, notes: '' });
const [submitting, setSubmitting] = useState(false);
const ic = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
useEffect(() => { if (open) setForm({ category: 'utilities', description: '', amount: 0, notes: '' }); }, [open]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try { await api.post('/expenses', { ...form, notes: form.notes || undefined }); toast('Expense submitted', 'success'); onSuccess(); onClose(); }
catch (err: any) { toast(err.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="New Expense" description="Submit an expense for approval">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Category</label>
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} className={ic}>
{CATEGORIES.map((c) => <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
</select></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Amount (PHP)</label><input type="number" required min={1} step={0.01} value={form.amount || ''} onChange={(e) => setForm({ ...form, amount: parseFloat(e.target.value) || 0 })} className={ic} /></div>
</div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Description</label><input type="text" required minLength={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className={ic} placeholder="What was the expense for?" /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Notes <span className="text-surface-400 font-normal">(optional)</span></label><input type="text" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} className={ic} /></div>
<div className="flex justify-end gap-3 pt-2"><Button type="button" variant="secondary" onClick={onClose}>Cancel</Button><Button type="submit" loading={submitting}>Submit Expense</Button></div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
interface Plan { id: string; name: string; price: string; speedDown: number; speedUp: number; }
interface Props {
open: boolean;
onClose: () => void;
onSuccess: () => void;
clientId: string;
clientName: string;
}
export function CreateSubscriptionModal({ open, onClose, onSuccess, clientId, clientName }: Props) {
const { toast } = useToast();
const [plans, setPlans] = useState<Plan[]>([]);
const [planId, setPlanId] = useState('');
const [type, setType] = useState('postpaid');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
api.get('/plans').then((r) => setPlans(r.data.data)).catch(() => {});
setPlanId('');
setType('postpaid');
}
}, [open]);
const selectedPlan = plans.find((p) => p.id === planId);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!planId) { toast('Please select a plan', 'error'); return; }
setSubmitting(true);
try {
await api.post('/subscriptions', { clientId, planId, type });
toast('Subscription created', 'success');
onSuccess();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to create subscription', 'error');
} finally {
setSubmitting(false);
}
}
const inputClass = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<FormModal open={open} onClose={onClose} title="New Subscription" description={`Create a subscription for ${clientName}`}>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Plan</label>
<div className="space-y-2 max-h-48 overflow-y-auto">
{plans.map((p) => (
<button key={p.id} type="button" onClick={() => setPlanId(p.id)}
className={`w-full text-left px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer ${
planId === p.id ? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20 ring-2 ring-primary-500/20' : 'border-surface-200 dark:border-surface-600 hover:border-surface-300 dark:hover:border-surface-500'
}`}>
<div className="flex items-center justify-between">
<span className="font-medium text-surface-800 dark:text-surface-200">{p.name}</span>
<span className="font-medium text-surface-900 dark:text-surface-100">PHP {Number(p.price).toLocaleString()}</span>
</div>
<p className="text-xs text-surface-500 mt-0.5">{p.speedDown}/{p.speedUp} Mbps</p>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Type</label>
<div className="flex gap-3">
{(['postpaid', 'prepaid'] as const).map((t) => (
<button key={t} type="button" onClick={() => setType(t)}
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-all duration-200 cursor-pointer ${
type === t ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400' : 'border-surface-200 dark:border-surface-600 text-surface-500 dark:text-surface-400 hover:border-surface-300 dark:hover:border-surface-500'
}`}>
{t === 'postpaid' ? 'Postpaid' : 'Prepaid'}
<p className="text-[11px] font-normal mt-0.5 text-surface-400">
{t === 'postpaid' ? 'Use first, pay later' : 'Pay first, then activate'}
</p>
</button>
))}
</div>
</div>
{selectedPlan && (
<div className="bg-surface-50 dark:bg-surface-800 rounded-lg p-3 text-sm">
<p className="text-surface-500">Summary: <span className="font-medium text-surface-800">{selectedPlan.name}</span> PHP {Number(selectedPlan.price).toLocaleString()}/month ({type})</p>
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting} disabled={!planId}>Create Subscription</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
interface Props {
open: boolean;
onClose: () => void;
onSuccess: () => void;
prefillClientId?: string;
prefillClientName?: string;
}
export function CreateTicketModal({ open, onClose, onSuccess, prefillClientId, prefillClientName }: Props) {
const { toast } = useToast();
const [clients, setClients] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [clientSearch, setClientSearch] = useState(prefillClientName || '');
const [selectedClientId, setSelectedClientId] = useState(prefillClientId || '');
const [showDropdown, setShowDropdown] = useState(false);
const [form, setForm] = useState({ type: 'support', title: '', description: '', priority: 'normal', assigneeId: '' });
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
api.get('/clients?limit=100').then((r) => {
const d = r.data.data;
setClients(Array.isArray(d) ? d : d.items);
}).catch(() => {});
// Try to load users for assignee (may fail for non-admin)
api.get('/users').then((r) => setUsers(r.data.data)).catch(() => {});
setForm({ type: 'support', title: '', description: '', priority: 'normal', assigneeId: '' });
if (prefillClientId) {
setSelectedClientId(prefillClientId);
setClientSearch(prefillClientName || '');
}
}, [open, prefillClientId, prefillClientName]);
const filteredClients = useMemo(() => {
if (!clientSearch || selectedClientId) return [];
const q = clientSearch.toLowerCase();
return clients.filter((c) =>
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
c.accountNumber.toLowerCase().includes(q),
).slice(0, 6);
}, [clientSearch, clients, selectedClientId]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.title.trim()) { toast('Title is required', 'error'); return; }
setSubmitting(true);
try {
await api.post('/tickets', {
...form,
clientId: selectedClientId || undefined,
assigneeId: form.assigneeId || undefined,
description: form.description || undefined,
});
toast('Ticket created', 'success');
onSuccess();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to create ticket', 'error');
} finally {
setSubmitting(false);
}
}
const inputClass = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-200 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<FormModal open={open} onClose={onClose} title="Create Ticket" description="Create a support, maintenance, or custom ticket">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Client search */}
{!prefillClientId && (
<div className="relative">
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Client <span className="text-surface-400 font-normal">(optional)</span></label>
{selectedClientId ? (
<div className="flex items-center justify-between px-3.5 py-2.5 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-700">
<span className="text-sm text-surface-800 dark:text-surface-200">{clientSearch}</span>
<button type="button" onClick={() => { setSelectedClientId(''); setClientSearch(''); }} className="text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 cursor-pointer" aria-label="Clear">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M3 3l8 8M11 3l-8 8" /></svg>
</button>
</div>
) : (
<div>
<input type="text" value={clientSearch} onChange={(e) => { setClientSearch(e.target.value); setShowDropdown(true); }}
onFocus={() => setShowDropdown(true)} placeholder="Search client..." className={inputClass} />
{showDropdown && filteredClients.length > 0 && (
<div className="absolute z-10 mt-1 w-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg shadow-lg max-h-40 overflow-y-auto">
{filteredClients.map((c: any) => (
<button key={c.id} type="button" onClick={() => { setSelectedClientId(c.id); setClientSearch(`${c.firstName} ${c.lastName}`); setShowDropdown(false); }}
className="w-full text-left px-4 py-2 hover:bg-surface-50 dark:hover:bg-surface-700 text-sm cursor-pointer dark:text-surface-300">{c.firstName} {c.lastName} <span className="text-surface-400 font-mono text-xs">{c.accountNumber}</span></button>
))}
</div>
)}
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="tk-type" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Type</label>
<select id="tk-type" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} className={inputClass}>
<option value="support">Support</option>
<option value="maintenance">Maintenance</option>
<option value="installation">Installation</option>
<option value="activation">Activation</option>
</select>
</div>
<div>
<label htmlFor="tk-priority" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Priority</label>
<select id="tk-priority" value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })} className={inputClass}>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div>
<label htmlFor="tk-title" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Title</label>
<input id="tk-title" type="text" required minLength={3} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
className={inputClass} placeholder="Brief description of the issue" />
</div>
<div>
<label htmlFor="tk-desc" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Description <span className="text-surface-400 font-normal">(optional)</span></label>
<textarea id="tk-desc" rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })}
className={`${inputClass} resize-none`} placeholder="Detailed description..." />
</div>
{users.length > 0 && (
<div>
<label htmlFor="tk-assignee" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Assign To <span className="text-surface-400 font-normal">(optional)</span></label>
<select id="tk-assignee" value={form.assigneeId} onChange={(e) => setForm({ ...form, assigneeId: e.target.value })} className={inputClass}>
<option value="">Unassigned</option>
{users.map((u: any) => <option key={u.id} value={u.id}>{u.firstName} {u.lastName} ({u.roles.join(', ')})</option>)}
</select>
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting}>Create Ticket</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,303 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { useToast } from '@/components/ui/toast';
interface Invoice {
id: string;
number: string;
amount: string;
balance: string;
status: string;
dueDate: string;
client: { id: string; firstName: string; lastName: string; accountNumber: string };
}
interface Client {
id: string;
firstName: string;
lastName: string;
accountNumber: string;
}
interface PaymentModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
prefillClientId?: string;
prefillClientName?: string;
prefillInvoice?: Invoice;
}
export function PaymentModal({
open,
onClose,
onSuccess,
prefillClientId,
prefillClientName,
prefillInvoice,
}: PaymentModalProps) {
const { toast } = useToast();
const [clients, setClients] = useState<Client[]>([]);
const [clientSearch, setClientSearch] = useState(prefillClientName || '');
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(prefillInvoice || null);
const [amount, setAmount] = useState<number>(0);
const [method, setMethod] = useState('cash');
const [referenceNo, setReferenceNo] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [showClientDropdown, setShowClientDropdown] = useState(false);
// Load clients for search
useEffect(() => {
if (!open) return;
api.get('/clients?limit=100').then((r) => {
const d = r.data.data;
setClients(Array.isArray(d) ? d : d.items);
}).catch(() => {});
}, [open]);
// Pre-fill client if provided
useEffect(() => {
if (prefillClientId && clients.length > 0) {
const c = clients.find((c) => c.id === prefillClientId);
if (c) {
setSelectedClient(c);
setClientSearch(`${c.firstName} ${c.lastName}`);
}
}
}, [prefillClientId, clients]);
// Pre-fill invoice
useEffect(() => {
if (prefillInvoice) {
setSelectedInvoice(prefillInvoice);
setAmount(Number(prefillInvoice.balance));
}
}, [prefillInvoice]);
// Load unpaid invoices when client selected
useEffect(() => {
if (!selectedClient) { setInvoices([]); return; }
Promise.all([
api.get(`/invoices?clientId=${selectedClient.id}&status=sent`),
api.get(`/invoices?clientId=${selectedClient.id}&status=partial`),
api.get(`/invoices?clientId=${selectedClient.id}&status=overdue`),
]).then(([sent, partial, overdue]) => {
const sentList = sent.data.data.items || sent.data.data;
const partialList = partial.data.data.items || partial.data.data;
const overdueList = overdue.data.data.items || overdue.data.data;
setInvoices([...sentList, ...partialList, ...overdueList]);
}).catch(() => {});
}, [selectedClient]);
// Filter clients by search
const filteredClients = useMemo(() => {
if (!clientSearch || selectedClient) return [];
const q = clientSearch.toLowerCase();
return clients.filter((c) =>
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
c.accountNumber.toLowerCase().includes(q),
).slice(0, 8);
}, [clientSearch, clients, selectedClient]);
function selectClient(c: Client) {
setSelectedClient(c);
setClientSearch(`${c.firstName} ${c.lastName}`);
setShowClientDropdown(false);
setSelectedInvoice(null);
setAmount(0);
}
function clearClient() {
setSelectedClient(null);
setClientSearch('');
setSelectedInvoice(null);
setAmount(0);
setInvoices([]);
}
function selectInvoice(inv: Invoice) {
setSelectedInvoice(inv);
setAmount(Number(inv.balance));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedClient) { toast('Please select a client', 'error'); return; }
if (amount <= 0) { toast('Amount must be greater than 0', 'error'); return; }
setSubmitting(true);
try {
await api.post('/payments', {
clientId: selectedClient.id,
invoiceId: selectedInvoice?.id,
amount,
method,
referenceNo: referenceNo || undefined,
notes: notes || undefined,
});
toast('Payment recorded successfully', 'success');
onSuccess();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to record payment', 'error');
} finally {
setSubmitting(false);
}
}
const inputClass = 'block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-200 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<FormModal open={open} onClose={onClose} title="Record Payment" description="Record a payment from a client" wide>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Client search */}
<div className="relative">
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Client</label>
{selectedClient ? (
<div className="flex items-center gap-3 px-3.5 py-2.5 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-700">
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 text-xs font-bold">
{selectedClient.firstName[0]}{selectedClient.lastName[0]}
</div>
<div className="flex-1">
<span className="text-sm font-medium text-surface-800 dark:text-surface-200">{selectedClient.firstName} {selectedClient.lastName}</span>
<span className="ml-2 text-xs font-mono text-surface-400">{selectedClient.accountNumber}</span>
</div>
<button type="button" onClick={clearClient} className="text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 cursor-pointer" aria-label="Change client">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
</div>
) : (
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-400" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="7" cy="7" r="5" /><path d="M11 11l3.5 3.5" /></svg>
<input
type="text"
value={clientSearch}
onChange={(e) => { setClientSearch(e.target.value); setShowClientDropdown(true); }}
onFocus={() => setShowClientDropdown(true)}
placeholder="Search by name or account #..."
aria-label="Search client"
className={`${inputClass} pl-9`}
/>
{showClientDropdown && filteredClients.length > 0 && (
<div className="absolute z-10 mt-1 w-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{filteredClients.map((c) => (
<button key={c.id} type="button" onClick={() => selectClient(c)}
className="w-full text-left px-4 py-2.5 hover:bg-surface-50 dark:hover:bg-surface-700 flex items-center gap-3 cursor-pointer transition-colors">
<div className="w-7 h-7 rounded-full bg-primary-50 flex items-center justify-center text-primary-600 text-xs font-bold">
{c.firstName[0]}{c.lastName[0]}
</div>
<div>
<span className="text-sm text-surface-800 dark:text-surface-300">{c.firstName} {c.lastName}</span>
<span className="ml-2 text-xs font-mono text-surface-400">{c.accountNumber}</span>
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
{/* Invoice selection */}
{selectedClient && (
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
Select Invoice <span className="text-red-400 font-normal">*</span>
</label>
{invoices.length > 0 ? (
<div className="space-y-2 max-h-40 overflow-y-auto">
{invoices.map((inv) => (
<button key={inv.id} type="button" onClick={() => selectInvoice(inv)}
className={`w-full text-left px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer ${
selectedInvoice?.id === inv.id
? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20 ring-2 ring-primary-500/20'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600 hover:bg-surface-50 dark:hover:bg-surface-700'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-surface-700 dark:text-surface-300">{inv.number}</span>
<Badge label={inv.status} variant={statusBadgeVariant(inv.status)} />
</div>
<div className="text-right">
<span className="text-sm font-medium text-surface-900 dark:text-surface-200">PHP {Number(inv.balance).toLocaleString()}</span>
<span className="text-xs text-surface-400 ml-2">of {Number(inv.amount).toLocaleString()}</span>
</div>
</div>
<p className="mt-1 text-xs text-surface-400">Due: {new Date(inv.dueDate).toLocaleDateString()}</p>
</button>
))}
</div>
) : (
<div className="px-4 py-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700">
No unpaid invoices for this client. Generate an invoice first before recording payment.
</div>
)}
</div>
)}
{/* Amount + Method */}
{selectedClient && (
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="pay-amount" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
Amount (PHP)
{selectedInvoice && (
<span className="text-surface-400 font-normal ml-1">Balance: {Number(selectedInvoice.balance).toLocaleString()}</span>
)}
</label>
<input id="pay-amount" type="number" required min={0.01} step={0.01} value={amount || ''}
onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
className={inputClass} placeholder="0.00" />
{selectedInvoice && amount > 0 && amount < Number(selectedInvoice.balance) && (
<p className="mt-1 text-xs text-amber-600">Partial payment remaining balance: PHP {(Number(selectedInvoice.balance) - amount).toLocaleString()}</p>
)}
</div>
<div>
<label htmlFor="pay-method" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Payment Method</label>
<select id="pay-method" value={method} onChange={(e) => setMethod(e.target.value)} className={inputClass}>
<option value="cash">Cash</option>
<option value="gcash">GCash</option>
<option value="maya">Maya</option>
<option value="bank_transfer">Bank Transfer</option>
</select>
</div>
</div>
)}
{/* Reference + Notes */}
{selectedClient && method !== 'cash' && (
<div>
<label htmlFor="pay-ref" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Reference Number</label>
<input id="pay-ref" type="text" value={referenceNo} onChange={(e) => setReferenceNo(e.target.value)}
className={inputClass} placeholder="Transaction reference #" />
</div>
)}
{selectedClient && (
<div>
<label htmlFor="pay-notes" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
Notes <span className="text-surface-400 font-normal">(optional)</span>
</label>
<input id="pay-notes" type="text" value={notes} onChange={(e) => setNotes(e.target.value)}
className={inputClass} placeholder="Additional notes" />
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting} disabled={!selectedClient || !selectedInvoice || amount <= 0}>
Record Payment
</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
const ADMIN_API_URL = process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api';
function formatBytes(bytes: number) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
interface SupportModalProps {
open: boolean;
onClose: () => void;
}
export function SupportModal({ open, onClose }: SupportModalProps) {
const { toast } = useToast();
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState('general');
const [priority, setPriority] = useState('normal');
const [submitting, setSubmitting] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setSubject(''); setDescription(''); setCategory('general'); setPriority('normal'); setSelectedFiles([]);
}
}, [open]);
function authHeaders() {
const token = localStorage.getItem('accessToken');
return { Authorization: `Bearer ${token}` };
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!subject.trim() || !description.trim()) return;
setSubmitting(true);
try {
const res = await fetch(`${ADMIN_API_URL}/public/support/tickets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ subject, description, category, priority }),
});
const data = await res.json();
const ticketId = data.data?.id || data.id;
if (ticketId && selectedFiles.length > 0) {
const formData = new FormData();
selectedFiles.forEach((f) => formData.append('files', f));
await fetch(`${ADMIN_API_URL}/public/support/tickets/${ticketId}/attachments`, {
method: 'POST', headers: authHeaders(), body: formData,
});
}
toast('Ticket submitted', 'success');
onClose();
} catch { toast('Failed to create ticket', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="New Support Ticket" description="Describe your issue and we'll get back to you" wide>
<form onSubmit={handleSubmit} className="space-y-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Subject</label>
<input type="text" required value={subject} onChange={(e) => setSubject(e.target.value)} className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 bg-white dark:bg-surface-800 rounded-lg text-sm text-surface-900 dark:text-surface-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Description</label>
<textarea required rows={4} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm resize-none" /></div>
<div className="flex gap-4">
<div className="flex-1"><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Category</label>
<select value={category} onChange={(e) => setCategory(e.target.value)} className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 bg-white dark:bg-surface-800 rounded-lg text-sm text-surface-900 dark:text-surface-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
<option value="general">General</option><option value="billing">Billing</option><option value="technical">Technical</option><option value="account">Account</option><option value="feature_request">Feature Request</option>
</select></div>
<div className="flex-1"><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Priority</label>
<select value={priority} onChange={(e) => setPriority(e.target.value)} className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 bg-white dark:bg-surface-800 rounded-lg text-sm text-surface-900 dark:text-surface-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
<option value="low">Low</option><option value="normal">Normal</option><option value="high">High</option><option value="urgent">Urgent</option>
</select></div>
</div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Attachments</label>
<div className="border border-dashed border-surface-300 dark:border-surface-600 rounded-lg p-3 text-center">
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.txt,.doc,.docx" className="hidden"
onChange={(e) => { if (e.target.files) { setSelectedFiles([...selectedFiles, ...Array.from(e.target.files!)].slice(0, 5)); e.target.value = ''; } }} />
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-sm text-primary-600 hover:text-primary-700">Click to attach files</button>
<p className="text-xs text-surface-400 mt-1">Max 5 files, 10MB each</p>
</div>
{selectedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{selectedFiles.map((f, i) => (
<div key={i} className="flex items-center gap-1.5 px-2 py-1 bg-surface-50 dark:bg-surface-700 border border-surface-200 dark:border-surface-600 rounded text-xs">
<span className="text-surface-700">{f.name}</span><span className="text-surface-400">({formatBytes(f.size)})</span>
<button type="button" onClick={() => setSelectedFiles(selectedFiles.filter((_, j) => j !== i))} className="text-surface-400 hover:text-red-500 ml-1">&times;</button>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting}>Submit Ticket</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
import { LocationPickerModal } from '@/components/maps/location-picker-modal';
const LeafletMap = dynamic(() => import('@/components/maps/leaflet-map').then((m) => ({ default: m.LeafletMap })), { ssr: false });
interface TicketDetailModalProps {
open: boolean;
onClose: () => void;
onUpdated: () => void;
ticketId: string | null;
}
export function TicketDetailModal({ open, onClose, onUpdated, ticketId }: TicketDetailModalProps) {
const { toast } = useToast();
const [ticket, setTicket] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState('');
const [priority, setPriority] = useState('');
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
const [resolving, setResolving] = useState(false);
const [selectedLat, setSelectedLat] = useState<number | null>(null);
const [selectedLng, setSelectedLng] = useState<number | null>(null);
const [showLocationPicker, setShowLocationPicker] = useState(false);
useEffect(() => {
if (!open || !ticketId) return;
setLoading(true);
api.get(`/tickets/${ticketId}`).then((r) => {
const t = r.data.data;
setTicket(t);
setStatus(t.status);
setPriority(t.priority);
if (t.client?.latitude != null && t.client?.longitude != null) {
setSelectedLat(t.client.latitude);
setSelectedLng(t.client.longitude);
}
}).catch(() => toast('Failed to load ticket', 'error'))
.finally(() => setLoading(false));
}, [open, ticketId, toast]);
async function handleUpdate() {
if (!ticket) return;
setSaving(true);
try {
const updates: any = {};
if (status !== ticket.status) updates.status = status;
if (priority !== ticket.priority) updates.priority = priority;
if (comment.trim()) {
const existingDesc = ticket.description || '';
const timestamp = new Date().toLocaleString();
const newDesc = existingDesc
? `${existingDesc}\n\n--- Comment (${timestamp}) ---\n${comment.trim()}`
: `--- Comment (${timestamp}) ---\n${comment.trim()}`;
updates.description = newDesc;
}
if (Object.keys(updates).length === 0) {
toast('No changes to save', 'info');
setSaving(false);
return;
}
await api.patch(`/tickets/${ticketId}`, updates);
toast('Ticket updated', 'success');
setComment('');
onUpdated();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to update', 'error');
} finally {
setSaving(false);
}
}
async function handleResolve() {
setResolving(true);
try {
const body: any = {};
if (selectedLat !== null && selectedLng !== null) {
body.latitude = selectedLat;
body.longitude = selectedLng;
}
await api.patch(`/tickets/${ticketId}/resolve`, body);
toast('Ticket resolved', 'success');
onUpdated();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to resolve', 'error');
} finally {
setResolving(false);
}
}
const inputClass = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
if (!open) return null;
return (
<FormModal open={open} onClose={onClose} title={ticket?.title || 'Loading...'} wide>
{loading ? (
<div className="py-8 text-center text-surface-400">Loading ticket details...</div>
) : ticket ? (
<div className="space-y-5">
{/* Ticket info */}
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Type</span>
<div className="mt-1"><Badge label={ticket.type} /></div>
</div>
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Status</span>
<div className="mt-1"><Badge label={ticket.status} variant={statusBadgeVariant(ticket.status)} /></div>
</div>
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Client</span>
<p className="mt-1 text-sm text-surface-800 dark:text-surface-200">
{ticket.client ? `${ticket.client.firstName} ${ticket.client.lastName}` : '—'}
</p>
</div>
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Assignee</span>
<p className="mt-1 text-sm text-surface-800 dark:text-surface-200">
{ticket.assignee ? `${ticket.assignee.firstName} ${ticket.assignee.lastName}` : 'Unassigned'}
</p>
</div>
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Created</span>
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{new Date(ticket.createdAt).toLocaleString()}</p>
</div>
{ticket.resolvedAt && (
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Resolved</span>
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{new Date(ticket.resolvedAt).toLocaleString()}</p>
</div>
)}
</div>
{/* Notes/Comments history */}
{ticket.description && (
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Notes & Comments</span>
<div className="mt-2 bg-surface-50 dark:bg-surface-800 rounded-lg p-4 text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap max-h-40 overflow-y-auto">
{ticket.description}
</div>
</div>
)}
{/* Update form — only if not resolved/cancelled */}
{ticket.status !== 'resolved' && ticket.status !== 'cancelled' && (
<>
<div className="border-t border-surface-200 pt-5">
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Update Ticket</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="t-status" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Status</label>
<select id="t-status" value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
</select>
</div>
<div>
<label htmlFor="t-priority" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Priority</label>
<select id="t-priority" value={priority} onChange={(e) => setPriority(e.target.value)} className={inputClass}>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
</div>
<div>
<label htmlFor="t-comment" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Add Comment</label>
<textarea id="t-comment" rows={3} value={comment} onChange={(e) => setComment(e.target.value)}
className={`${inputClass} resize-none`} placeholder="Add a note or comment..." />
</div>
{/* Location picker for installation tickets */}
{ticket.type === 'installation' && ticket.clientId && (
<div className="border-t border-surface-200 pt-5">
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Client Location</h3>
{selectedLat !== null && selectedLng !== null ? (
<div className="mb-3">
<LeafletMap latitude={selectedLat} longitude={selectedLng} height="200px" zoom={16} />
</div>
) : null}
<div className="flex items-center justify-between">
<span className="text-sm text-surface-500">
{selectedLat !== null && selectedLng !== null
? `${selectedLat.toFixed(6)}, ${selectedLng.toFixed(6)}`
: 'No location pinned yet'}
</span>
<Button size="sm" variant="secondary" onClick={() => setShowLocationPicker(true)}>
{selectedLat !== null ? 'Update Pin' : 'Pin Location'}
</Button>
</div>
</div>
)}
<div className="flex justify-between pt-2">
<Button variant="secondary" onClick={handleResolve} loading={resolving}>
Resolve Ticket
</Button>
<div className="flex gap-3">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleUpdate} loading={saving}>Save Changes</Button>
</div>
</div>
</>
)}
{/* Already resolved */}
{(ticket.status === 'resolved' || ticket.status === 'cancelled') && (
<div className="flex justify-end pt-2">
<Button variant="secondary" onClick={onClose}>Close</Button>
</div>
)}
</div>
) : null}
<LocationPickerModal
open={showLocationPicker}
onClose={() => setShowLocationPicker(false)}
onConfirm={(lat, lng) => {
setSelectedLat(lat);
setSelectedLng(lng);
setShowLocationPicker(false);
}}
initialLatitude={selectedLat}
initialLongitude={selectedLng}
title="Pin Installation Location"
description="Pin the client's installation location on the map."
/>
</FormModal>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
interface TransferModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function TransferModal({ open, onClose, onSuccess }: TransferModalProps) {
const { toast } = useToast();
const [accounts, setAccounts] = useState<any[]>([]);
const [form, setForm] = useState({ fromAccountId: '', toAccountId: '', amount: 0, description: '' });
const [submitting, setSubmitting] = useState(false);
const ic = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
useEffect(() => {
if (open) {
setForm({ fromAccountId: '', toAccountId: '', amount: 0, description: '' });
api.get('/accounts').then((r) => setAccounts(r.data.data)).catch(() => {});
}
}, [open]);
const fromAccount = accounts.find((a: any) => a.id === form.fromAccountId);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try { await api.post('/accounts/transfer', { ...form, description: form.description || undefined }); toast('Transfer completed', 'success'); onSuccess(); onClose(); }
catch (err: any) { toast(err.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="Transfer Funds" description="Move funds between company accounts. A journal entry will be created automatically.">
<form onSubmit={handleSubmit} className="space-y-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">From Account</label>
<select required value={form.fromAccountId} onChange={(e) => setForm({ ...form, fromAccountId: e.target.value })} className={ic}>
<option value="">Select source...</option>
{accounts.map((a: any) => <option key={a.id} value={a.id}>{a.name} (PHP {Number(a.balance).toLocaleString()})</option>)}
</select></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">To Account</label>
<select required value={form.toAccountId} onChange={(e) => setForm({ ...form, toAccountId: e.target.value })} className={ic}>
<option value="">Select destination...</option>
{accounts.filter((a: any) => a.id !== form.fromAccountId).map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Amount (PHP) {fromAccount && <span className="text-surface-400 font-normal">Available: {Number(fromAccount.balance).toLocaleString()}</span>}</label>
<input type="number" required min={0.01} step={0.01} value={form.amount || ''} onChange={(e) => setForm({ ...form, amount: parseFloat(e.target.value) || 0 })} className={ic} /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Description <span className="text-surface-400 font-normal">(optional)</span></label>
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className={ic} placeholder="e.g. Weekly GCash to bank transfer" /></div>
<div className="flex justify-end gap-3 pt-2"><Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting} disabled={!form.fromAccountId || !form.toAccountId || form.amount <= 0}>Transfer</Button></div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import { ButtonHTMLAttributes, useState, useRef, useEffect } from 'react';
export type IconName =
| 'eye'
| 'credit-card'
| 'x-circle'
| 'check'
| 'x'
| 'edit'
| 'copy'
| 'pause'
| 'play'
| 'user-x'
| 'user-check'
| 'external-link'
| 'more'
| 'trash';
interface ActionIconProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
icon: IconName;
variant?: 'primary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
label: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
const ICONS: Record<IconName, (size: number) => React.ReactNode> = {
eye: (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
<circle cx="8" cy="8" r="2" />
</svg>
),
'credit-card': (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1.5" y="3" width="13" height="10" rx="1.5" />
<path d="M1.5 6.5h13M4.5 10h2" />
</svg>
),
'x-circle': (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="8" r="6" />
<path d="M10 6l-4 4M6 6l4 4" />
</svg>
),
check: (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8.5l3.5 3.5 6.5-7" />
</svg>
),
x: (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
),
edit: (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11.5 2.5l2 2-8.5 8.5H3v-2l8.5-8.5z" />
</svg>
),
copy: (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="5" width="8" height="8" rx="1" />
<path d="M3 11V3.5A1.5 1.5 0 014.5 2H11" />
</svg>
),
pause: (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="3" width="2.5" height="10" rx="0.5" />
<rect x="9.5" y="3" width="2.5" height="10" rx="0.5" />
</svg>
),
play: (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4.5 2.5l9 5.5-9 5.5V2.5z" />
</svg>
),
'user-x': (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6.5" cy="5" r="2.5" />
<path d="M2 14c0-2.5 2-4.5 4.5-4.5S11 11.5 11 14" />
<path d="M12 5l3 3M15 5l-3 3" />
</svg>
),
'user-check': (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6.5" cy="5" r="2.5" />
<path d="M2 14c0-2.5 2-4.5 4.5-4.5S11 11.5 11 14" />
<path d="M12 6l2 2 3.5-3.5" />
</svg>
),
'external-link': (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 2h6v6M14 2L7 9" />
<path d="M6 3H3v10h10v-3" />
</svg>
),
more: (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="3.5" r="1.5" />
<circle cx="8" cy="8" r="1.5" />
<circle cx="8" cy="12.5" r="1.5" />
</svg>
),
trash: (s) => (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4.5h10M6.5 4.5V3a1 1 0 011-1h1a1 1 0 011 1v1.5M5 4.5l.5 8.5h5l.5-8.5" />
</svg>
),
};
const VARIANT_STYLES: Record<string, string> = {
primary:
'text-primary-600 hover:bg-primary-50 hover:text-primary-700 focus-visible:ring-primary-500/30',
ghost:
'text-surface-400 hover:bg-surface-100 hover:text-surface-700 focus-visible:ring-surface-500/30',
danger:
'text-surface-400 hover:bg-red-50 hover:text-red-600 focus-visible:ring-red-500/30',
};
const SIZE_MAP = { sm: 32, md: 36, lg: 40 } as const;
const ICON_SIZE_MAP = { sm: 15, md: 16, lg: 18 } as const;
export function ActionIcon({
icon,
variant = 'ghost',
size = 'sm',
label,
onClick,
className = '',
disabled,
...rest
}: ActionIconProps) {
const [showTooltip, setShowTooltip] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const btnSize = SIZE_MAP[size];
const iconSize = ICON_SIZE_MAP[size];
return (
<button
ref={btnRef}
type="button"
aria-label={label}
onClick={onClick}
disabled={disabled}
className={`relative inline-flex items-center justify-center rounded-lg transition-all duration-150 cursor-pointer focus:outline-none focus-visible:ring-2 disabled:opacity-40 disabled:cursor-not-allowed ${VARIANT_STYLES[variant]} ${className}`}
style={{ width: btnSize, height: btnSize }}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => {
timeoutRef.current = setTimeout(() => setShowTooltip(false), 100);
}}
onFocus={() => setShowTooltip(true)}
onBlur={() => setShowTooltip(false)}
{...rest}
>
{ICONS[icon](iconSize)}
{showTooltip && (
<span
role="tooltip"
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 px-2 py-1 text-[11px] font-medium text-white bg-surface-800 rounded-md whitespace-nowrap pointer-events-none z-50 shadow-sm"
>
{label}
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-surface-800" />
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { ActionIcon } from './action-icon';
import type { IconName } from './action-icon';
export interface ActionMenuItem {
icon: IconName;
label: string;
onClick: () => void;
variant?: 'default' | 'danger';
disabled?: boolean;
}
interface ActionMenuProps {
items: ActionMenuItem[];
}
export function ActionMenu({ items }: ActionMenuProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
close();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [open, close]);
return (
<div ref={menuRef} className="relative inline-block">
<ActionIcon
icon="more"
variant="ghost"
label="Actions"
onClick={(e) => {
e.stopPropagation();
setOpen((prev) => !prev);
}}
/>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 min-w-[160px] bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-lg py-1 z-50 animate-in fade-in"
>
{items.map((item, i) => (
<button
key={i}
role="menuitem"
disabled={item.disabled}
onClick={(e) => {
e.stopPropagation();
item.onClick();
close();
}}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
item.variant === 'danger'
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20'
: 'text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-700'
}`}
>
<ActionIcon
icon={item.icon}
variant={item.variant === 'danger' ? 'danger' : 'ghost'}
label={item.label}
size="sm"
onClick={() => {}}
className="pointer-events-none"
/>
<span>{item.label}</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
interface BadgeProps {
label: string;
variant?: 'default' | 'success' | 'warning' | 'error' | 'info' | 'purple';
}
const variantStyles: Record<string, string> = {
default: 'bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-300',
success: 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400',
warning: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
error: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400',
info: 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
purple: 'bg-violet-50 dark:bg-violet-900/30 text-violet-700 dark:text-violet-400',
};
export function Badge({ label, variant = 'default' }: BadgeProps) {
return (
<span
className={`inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full ${variantStyles[variant]}`}
role="status"
aria-label={label}
>
{label}
</span>
);
}
export function statusBadgeVariant(status: string): BadgeProps['variant'] {
const map: Record<string, BadgeProps['variant']> = {
active: 'success',
resolved: 'success',
paid: 'success',
confirmed: 'success',
open: 'info',
sent: 'info',
pending: 'warning',
in_progress: 'warning',
partial: 'warning',
suspended: 'warning',
overdue: 'error',
cancelled: 'error',
inactive: 'error',
rejected: 'error',
void: 'default',
draft: 'default',
postpaid: 'info',
prepaid: 'purple',
};
return map[status] || 'default';
}

View File

@@ -0,0 +1,42 @@
import { forwardRef } from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md';
loading?: boolean;
}
const variantStyles: Record<string, string> = {
primary: 'bg-primary-600 text-white shadow-sm hover:bg-primary-700 focus:ring-primary-500/50',
secondary: 'bg-white dark:bg-surface-800 text-surface-700 dark:text-surface-200 border border-surface-200 dark:border-surface-600 hover:bg-surface-50 dark:hover:bg-surface-700 focus:ring-surface-300/50',
danger: 'bg-red-600 text-white shadow-sm hover:bg-red-700 focus:ring-red-500/50',
ghost: 'text-surface-500 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-700',
};
const sizeStyles: Record<string, string> = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', loading, children, disabled, className = '', ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled || loading}
className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
{...props}
>
{loading && (
<svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path d="M4 12a8 8 0 018-8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
)}
{children}
</button>
);
},
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,238 @@
'use client';
import { useState, useMemo } from 'react';
import { EmptyState } from './empty-state';
import { TableSkeleton } from './skeleton';
interface Column<T> {
key: string;
label: string;
sortable?: boolean;
align?: 'left' | 'right' | 'center';
render: (item: T) => React.ReactNode;
}
interface FilterOption {
label: string;
value: string;
}
interface QuickFilter {
key: string;
label: string;
options: FilterOption[];
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
loading?: boolean;
emptyTitle?: string;
emptyDescription?: string;
keyExtractor: (item: T) => string;
searchPlaceholder?: string;
searchValue?: string;
onSearchChange?: (value: string) => void;
quickFilters?: QuickFilter[];
activeFilters?: Record<string, string>;
onFilterChange?: (key: string, value: string) => void;
pageSize?: number;
onRowClick?: (item: T) => void;
maxHeight?: string;
headerActions?: React.ReactNode;
}
const PAGE_SIZES = [10, 20, 50, 100];
export function DataTable<T>({
columns,
data,
loading,
emptyTitle = 'No data',
emptyDescription,
keyExtractor,
searchPlaceholder,
searchValue,
onSearchChange,
quickFilters,
activeFilters,
onFilterChange,
pageSize: initialPageSize = 20,
onRowClick,
maxHeight,
headerActions,
}: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(initialPageSize);
function handleSort(key: string) {
if (sortKey === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
setPage(1);
}
const sortedData = useMemo(() => {
if (!sortKey) return data;
return [...data].sort((a, b) => {
const aVal = (a as any)[sortKey];
const bVal = (b as any)[sortKey];
if (aVal == null) return 1;
if (bVal == null) return -1;
const cmp = typeof aVal === 'string' ? aVal.localeCompare(bVal) : aVal - bVal;
return sortDir === 'asc' ? cmp : -cmp;
});
}, [data, sortKey, sortDir]);
const totalPages = Math.ceil(sortedData.length / perPage);
const paginatedData = sortedData.slice((page - 1) * perPage, page * perPage);
const startItem = sortedData.length === 0 ? 0 : (page - 1) * perPage + 1;
const endItem = Math.min(page * perPage, sortedData.length);
// Reset page when data changes
if (page > totalPages && totalPages > 0) setPage(totalPages);
function getPageNumbers(): (number | '...')[] {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages: (number | '...')[] = [1];
if (page > 3) pages.push('...');
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) pages.push(i);
if (page < totalPages - 2) pages.push('...');
if (totalPages > 1) pages.push(totalPages);
return pages;
}
if (loading) return <TableSkeleton rows={6} cols={columns.length} />;
return (
<div className="flex flex-col">
{/* Search + Quick Filters + Header Actions row */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div className="flex flex-wrap items-center gap-3">
{onSearchChange && (
<div className="relative flex-shrink-0">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-400" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="7" cy="7" r="5" /><path d="M11 11l3.5 3.5" />
</svg>
<input type="text" value={searchValue} onChange={(e) => { onSearchChange(e.target.value); setPage(1); }}
placeholder={searchPlaceholder || 'Search...'} aria-label={searchPlaceholder || 'Search'}
className="w-64 pl-9 pr-3 py-2 rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-sm text-surface-900 dark:text-surface-200 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200" />
</div>
)}
{/* Quick filter chips */}
{quickFilters && onFilterChange && quickFilters.map((filter) => (
<div key={filter.key} className="flex items-center gap-1">
<span className="text-xs text-surface-400 dark:text-surface-500 mr-1">{filter.label}:</span>
<button onClick={() => onFilterChange(filter.key, '')}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-all duration-150 cursor-pointer ${
!activeFilters?.[filter.key] ? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-400' : 'bg-surface-100 dark:bg-surface-700 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-600'
}`}>All</button>
{filter.options.map((opt) => (
<button key={opt.value} onClick={() => onFilterChange(filter.key, opt.value)}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-all duration-150 cursor-pointer ${
activeFilters?.[filter.key] === opt.value ? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-400' : 'bg-surface-100 dark:bg-surface-700 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-600'
}`}>{opt.label}</button>
))}
</div>
))}
</div>
{headerActions && <div className="flex-shrink-0">{headerActions}</div>}
</div>
{sortedData.length === 0 ? (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700">
<EmptyState title={emptyTitle} description={emptyDescription}
icon={<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="3" width="18" height="18" rx="3" /><path d="M9 9h6M9 13h4" /></svg>} />
</div>
) : (
<>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 flex flex-col">
{/* Scrollable table body with sticky header */}
<div className="overflow-x-auto" style={{ maxHeight: maxHeight ?? 'calc(100vh - 300px)' }}>
<table className="min-w-full divide-y divide-surface-200 dark:divide-surface-700" role="grid">
<thead className="bg-surface-50/50 dark:bg-surface-800/80 sticky top-0 z-10">
<tr>
{columns.map((col) => (
<th key={col.key}
className={`px-5 py-3 text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider bg-surface-50/80 dark:bg-surface-800/90 backdrop-blur-sm ${
col.align === 'right' ? 'text-right' : 'text-left'
} ${col.sortable ? 'cursor-pointer select-none hover:text-surface-700 dark:hover:text-surface-300 transition-colors' : ''}`}
onClick={col.sortable ? () => handleSort(col.key) : undefined}
aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}>
<span className="flex items-center gap-1">
{col.label}
{col.sortable && sortKey === col.key && (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
{sortDir === 'asc' ? <path d="M6 3l4 6H2z" /> : <path d="M6 9l4-6H2z" />}
</svg>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-surface-100 dark:divide-surface-700">
{paginatedData.map((item) => (
<tr key={keyExtractor(item)}
className={`group transition-colors duration-100 ${
onRowClick
? 'cursor-pointer hover:bg-primary-50/40 dark:hover:bg-primary-900/20 border-l-2 border-l-transparent hover:border-l-primary-400'
: 'hover:bg-surface-50/50 dark:hover:bg-surface-700/50'
}`}
onClick={onRowClick ? () => onRowClick(item) : undefined}
>
{columns.map((col) => (
<td key={col.key} className={`px-5 py-3.5 text-sm text-surface-700 dark:text-surface-300 ${col.align === 'right' ? 'text-right' : 'text-left'}`}>
{col.render(item)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination controls — always visible */}
<div className="mt-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2 text-sm text-surface-500 dark:text-surface-400">
<span>Showing {startItem}-{endItem} of {sortedData.length}</span>
<select value={perPage} onChange={(e) => { setPerPage(Number(e.target.value)); setPage(1); }}
aria-label="Items per page"
className="ml-2 rounded-md border border-surface-200 dark:border-surface-700 px-2 py-1 text-xs bg-white dark:bg-surface-800 text-surface-700 dark:text-surface-300 cursor-pointer focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20">
{PAGE_SIZES.map((s) => <option key={s} value={s}>{s}/page</option>)}
</select>
</div>
{totalPages > 1 && (
<nav className="flex items-center gap-1" aria-label="Pagination">
<button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
aria-label="Previous page">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M10 4l-4 4 4 4" /></svg>
</button>
{getPageNumbers().map((p, i) =>
p === '...' ? (
<span key={`dots-${i}`} className="px-1 text-surface-300 dark:text-surface-500">...</span>
) : (
<button key={p} onClick={() => setPage(p as number)}
className={`min-w-[32px] h-8 rounded-md text-sm font-medium transition-colors cursor-pointer ${
page === p ? 'bg-primary-600 text-white' : 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700'
}`}>{p}</button>
),
)}
<button onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
aria-label="Next page">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M6 4l4 4-4 4" /></svg>
</button>
</nav>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center" role="status">
{icon && (
<div className="w-12 h-12 rounded-full bg-surface-100 flex items-center justify-center text-surface-400 mb-4">
{icon}
</div>
)}
<h3 className="text-sm font-semibold text-surface-700">{title}</h3>
{description && (
<p className="mt-1 text-sm text-surface-400 max-w-sm">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { useEffect, useRef } from 'react';
interface FormModalProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
wide?: boolean;
}
export function FormModal({ open, onClose, title, description, children, wide }: FormModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [open]);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open) onClose();
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4 bg-surface-900/40 backdrop-blur-sm overflow-y-auto"
role="dialog"
aria-modal="true"
aria-labelledby="form-modal-title"
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
>
<div className={`bg-white dark:bg-surface-800 rounded-xl shadow-xl w-full p-6 mb-10 animate-in fade-in slide-in-from-top-4 duration-200 ${wide ? 'max-w-2xl' : 'max-w-lg'}`}>
<div className="flex items-start justify-between mb-5">
<div>
<h2 id="form-modal-title" className="text-lg font-semibold text-surface-900 dark:text-surface-200">{title}</h2>
{description && <p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{description}</p>}
</div>
<button onClick={onClose} className="p-1 text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors cursor-pointer rounded-lg hover:bg-surface-50 dark:hover:bg-surface-700" aria-label="Close">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M6 6l8 8M14 6l-8 8" /></svg>
</button>
</div>
{children}
</div>
</div>
);
}

122
src/components/ui/modal.tsx Normal file
View File

@@ -0,0 +1,122 @@
'use client';
import { useState, useEffect, useRef } from 'react';
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
const SIZE_CLASSES: Record<ModalSize, string> = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-5xl',
full: 'max-w-7xl',
};
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children?: React.ReactNode;
variant?: 'default' | 'danger';
confirmLabel?: string;
cancelLabel?: string;
onConfirm?: () => void | Promise<void>;
loading?: boolean;
size?: ModalSize;
}
export function Modal({
open,
onClose,
title,
description,
children,
variant = 'default',
confirmLabel,
cancelLabel = 'Cancel',
onConfirm,
loading,
size = 'sm',
}: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const firstFocusRef = useRef<HTMLButtonElement>(null);
const [processing, setProcessing] = useState(false);
async function handleConfirm() {
if (!onConfirm || processing) return;
setProcessing(true);
try {
await onConfirm();
} catch {
// Let consumer handle errors via their own toast — just stop processing
} finally {
setProcessing(false);
}
}
useEffect(() => {
if (open) {
firstFocusRef.current?.focus();
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open) onClose();
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-surface-900/40 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
>
<div className={`bg-white dark:bg-surface-800 rounded-xl shadow-xl ${SIZE_CLASSES[size]} w-full p-6 animate-in fade-in zoom-in duration-200`}>
<h2 id="modal-title" className="text-lg font-semibold text-surface-900 dark:text-surface-200">
{title}
</h2>
{description && (
<p className="mt-2 text-sm text-surface-500 dark:text-surface-400">{description}</p>
)}
{children && <div className="mt-4">{children}</div>}
{(onConfirm || cancelLabel) && (
<div className="mt-6 flex justify-end gap-3">
<button
ref={firstFocusRef}
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-surface-600 dark:text-surface-300 bg-surface-100 dark:bg-surface-700 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors duration-200 cursor-pointer"
>
{cancelLabel}
</button>
{onConfirm && (
<button
onClick={handleConfirm}
disabled={loading || processing}
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors duration-200 cursor-pointer disabled:opacity-50 ${
variant === 'danger'
? 'bg-red-600 hover:bg-red-700'
: 'bg-primary-600 hover:bg-primary-700'
}`}
>
{loading || processing ? 'Processing...' : confirmLabel}
</button>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
interface PageHeaderProps {
title: string;
description?: string;
action?: React.ReactNode;
}
export function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-surface-900 dark:text-surface-100">{title}</h1>
{description && (
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { useState, forwardRef, useRef, useEffect } from 'react';
interface SelectProps {
value?: string | string[];
onChange: (value: string | string[]) => void;
options: { label: string; value: string }[];
placeholder?: string;
disabled?: boolean;
multiple?: boolean;
}
const Select = forwardRef<HTMLDivElement, SelectProps>(
({ value, onChange, options, placeholder, disabled = false, multiple = false }, ref) => {
const [isOpen, setIsOpen] = useState(false);
const selectRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const isMulti = multiple;
const currentValue = value;
const selectedLabels = isMulti
? (Array.isArray(currentValue) ? currentValue : [])
: options.find((o) => o.value === currentValue)?.label || '';
function handleClick() {
setIsOpen(!isOpen);
}
function handleSelect(optionValue: string) {
if (isMulti) {
const newValues = Array.isArray(currentValue) ? [...currentValue] : [];
if (newValues.includes(optionValue)) {
onChange(newValues.filter((v) => v !== optionValue));
} else {
onChange([...newValues, optionValue]);
}
} else {
onChange(optionValue);
}
setIsOpen(false);
}
function handleClickOutside(e: MouseEvent) {
if (triggerRef.current && !triggerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
return (
<div className="relative" ref={ref}>
<div
ref={triggerRef}
onClick={handleClick}
className={`
flex items-center justify-between px-3 py-2 rounded-lg border
${disabled ? 'bg-surface-50 dark:bg-surface-800 border-surface-200 dark:border-surface-700 text-surface-300' : 'bg-white dark:bg-surface-800 border-surface-300 dark:border-surface-600 cursor-pointer hover:border-surface-400 dark:hover:border-surface-500'}
transition-all duration-200
min-w-[200px] max-w-full
`}
>
<span className={selectedLabels.length === 0 ? 'text-surface-400' : 'text-surface-900 dark:text-surface-200 truncate'}>
{placeholder || 'Select...'}
{isMulti && Array.isArray(selectedLabels) && selectedLabels.length > 0 && <span className="text-surface-400"> ({selectedLabels.length})</span>}
{!isMulti && selectedLabels && <span className="text-surface-900 dark:text-surface-200"> {selectedLabels}</span>}
</span>
<svg
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 8 8 12" />
</svg>
</div>
{isOpen && (
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-lg max-h-60 overflow-y-auto">
{options.map((option) => {
const isSelected = isMulti
? Array.isArray(currentValue) && currentValue.includes(option.value)
: currentValue === option.value;
return (
<div
key={option.value}
onClick={() => handleSelect(option.value)}
className={`
px-3 py-2 cursor-pointer hover:bg-surface-50 dark:hover:bg-surface-700
${isSelected ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400' : 'text-surface-800 dark:text-surface-300'}
`}
>
<div className="flex items-center gap-2">
{isMulti && (
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="w-4 h-4"
readOnly
/>
)}
<span className="flex-1">{option.label}</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
);
Select.displayName = 'Select';
export { Select };

View File

@@ -0,0 +1,38 @@
export function Skeleton({ className = '' }: { className?: string }) {
return (
<div
className={`animate-pulse bg-surface-200 rounded ${className}`}
role="status"
aria-label="Loading"
/>
);
}
export function TableSkeleton({ rows = 5, cols = 4 }: { rows?: number; cols?: number }) {
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 overflow-hidden">
<div className="p-4 space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4">
{Array.from({ length: cols }).map((_, j) => (
<Skeleton key={j} className={`h-5 ${j === 0 ? 'w-24' : 'flex-1'}`} />
))}
</div>
))}
</div>
</div>
);
}
export function CardSkeleton() {
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-5">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
<Skeleton className="mt-3 h-8 w-16" />
<Skeleton className="mt-2 h-3 w-20" />
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
interface ToastContextType {
toast: (message: string, type?: Toast['type']) => void;
}
const ToastContext = createContext<ToastContextType>({ toast: () => {} });
export function useToast() {
return useContext(ToastContext);
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, type: Toast['type'] = 'info') => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toast: addToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 space-y-2" aria-live="polite">
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={() => removeToast(t.id)} />
))}
</div>
</ToastContext.Provider>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
useEffect(() => {
const timer = setTimeout(onDismiss, 4000);
return () => clearTimeout(timer);
}, [onDismiss]);
const styles: Record<string, string> = {
success: 'bg-emerald-600',
error: 'bg-red-600',
info: 'bg-surface-800',
};
const icons: Record<string, React.ReactNode> = {
success: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 8.5l3 3 5-6" />
</svg>
),
error: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="8" cy="8" r="6" /><path d="M8 5v3M8 10v.5" />
</svg>
),
info: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="8" cy="8" r="6" /><path d="M8 7v4M8 5v.5" />
</svg>
),
};
return (
<div
className={`${styles[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 text-sm font-medium min-w-[280px] animate-in slide-in-from-right duration-300`}
role="alert"
>
{icons[toast.type]}
<span className="flex-1">{toast.message}</span>
<button
onClick={onDismiss}
className="text-white/70 hover:text-white cursor-pointer"
aria-label="Dismiss"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
</button>
</div>
);
}