Compare commits

12 Commits

Author SHA1 Message Date
john kevin asprec
9adabb6f5d feat: add impersonate page to handle session token injection and redirection 2026-06-16 22:33:18 +08:00
kevin-asprec
9f031a6757 fix: override NODE_ENV=development for npm install in Coolify
Coolify injects ARG NODE_ENV=production which causes npm to skip
devDependencies (typescript, tailwindcss, postcss). Override with
ENV NODE_ENV=development before npm install, then set production
before next build.
2026-04-14 20:41:57 +08:00
kevin-asprec
342e17aed2 fix: remove ENV NODE_ENV=development that breaks Next.js static generation 2026-04-14 20:12:54 +08:00
kevin-asprec
428b6f58d8 fix: add not-found.tsx to prevent Html import error during build 2026-04-14 09:42:38 +08:00
kevin-asprec
af20fcb1dc fix: set NODE_ENV=development during build to include devDependencies 2026-04-14 09:00:52 +08:00
kevin-asprec
f069b84f87 fix: add cache bust arg to force clean Docker build 2026-04-14 06:41:20 +08:00
kevin-asprec
60159970aa fix: use next.config.mjs to avoid typescript build dependency 2026-04-14 06:30:59 +08:00
kevin-asprec
dcadef92b6 fix: install typescript for next.config.ts transpilation in Dockerfile 2026-04-14 06:05:30 +08:00
kevin-asprec
3f195e1765 fix: compile shared package as CommonJS for NestJS compatibility 2026-04-13 16:52:39 +08:00
kevin-asprec
7df2789153 merge: develop into main 2026-04-13 13:04:28 +08:00
kevin-asprec
8c91ee7e4b fix: standalone tsconfig and Dockerfile for monorepo split 2026-04-13 12:04:20 +08:00
kevin-asprec
3f5bf0e118 initial: standalone repo from monorepo split 2026-04-13 09:36:37 +08:00
90 changed files with 11061 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.next
.git
.env
*.tsbuildinfo
test-screenshots
tests

1
.env.example Normal file
View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:3001

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
.next/
out/
.env
.env.local
.env.*.local
dist/
*.tsbuildinfo
test-screenshots/
tests/

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:20-alpine AS builder
ARG NODE_ENV=production
ENV NODE_ENV=development
WORKDIR /app
COPY package.json package-lock.json* ./
COPY packages/shared/package.json ./packages/shared/
RUN npm install
COPY packages/shared/ ./packages/shared/
COPY . .
ENV NODE_ENV=production
RUN npx next build
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
ENV NODE_ENV=production HOSTNAME=0.0.0.0
EXPOSE 3000
USER nextjs
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
next.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['@fiberops/shared'],
output: 'standalone',
};
export default nextConfig;

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "fiberops-web",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@fiberops/shared": "workspace:*",
"@hookform/resolvers": "^5.0.0",
"@tanstack/react-query": "^5.75.0",
"axios": "^1.7.0",
"leaflet": "^1.9.4",
"next": "^15.3.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"recharts": "^2.15.0",
"zod": "^3.24.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"postcss": "^8.5.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "@fiberops/shared",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"lint": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,16 @@
export { Role, ALL_ROLES, satisfiesRole } from './roles';
export {
Permission, getPermissionsForRoles,
MODULES, MODULE_LABELS, ACTIONS, ACTION_LABELS, MODULE_ACTIONS,
DEFAULT_ROLE_PERMISSIONS,
} from './permissions';
export type { Module, Action, PermissionRow, PermissionString } from './permissions';
export {
SubscriptionStatus,
SubscriptionType,
InvoiceStatus,
TicketType,
TicketStatus,
PaymentMethod,
AccountStatus,
} from './statuses';

View File

@@ -0,0 +1,224 @@
import { Role } from './roles';
/**
* All modules available in the permission matrix.
* Each module can have: view, create, update, archive, approve, export actions.
*/
export const MODULES = [
'dashboard',
'clients',
'subscriptions',
'invoices',
'payments',
'tickets',
'employees',
'payroll',
'expenses',
'assets',
'accounts',
'fund_transfers',
'accounting',
'reports',
'areas',
'plans',
'settings',
'users',
] as const;
export type Module = (typeof MODULES)[number];
export const MODULE_LABELS: Record<Module, string> = {
dashboard: 'Dashboard',
clients: 'Clients',
subscriptions: 'Subscriptions',
invoices: 'Invoices',
payments: 'Payments',
tickets: 'Tickets',
employees: 'Employees',
payroll: 'Payroll',
expenses: 'Expenses',
assets: 'Assets',
accounts: 'Company Accounts',
fund_transfers: 'Fund Transfers',
accounting: 'Accounting',
reports: 'Reports',
areas: 'Areas',
plans: 'Plans',
settings: 'Settings',
users: 'Users',
};
export const ACTIONS = ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'] as const;
export type Action = (typeof ACTIONS)[number];
export const ACTION_LABELS: Record<Action, string> = {
canView: 'View',
canCreate: 'Create',
canUpdate: 'Update',
canArchive: 'Archive',
canApprove: 'Approve',
canExport: 'Export',
};
/**
* Defines which actions are applicable per module.
* Only these checkboxes should be shown/enforced in the matrix.
*/
export const MODULE_ACTIONS: Record<Module, readonly Action[]> = {
dashboard: ['canView'],
clients: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
subscriptions: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
invoices: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'],
payments: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'],
tickets: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
employees: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
payroll: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'],
expenses: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'],
assets: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'],
accounts: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'],
fund_transfers: ['canView', 'canCreate', 'canApprove', 'canExport'],
accounting: ['canView', 'canExport'],
reports: ['canView', 'canExport'],
areas: ['canView', 'canCreate', 'canUpdate', 'canArchive'],
plans: ['canView', 'canCreate', 'canUpdate', 'canArchive'],
settings: ['canView', 'canUpdate'],
users: ['canView', 'canCreate', 'canUpdate', 'canArchive'],
};
/**
* Permission matrix type — one row per module with boolean actions.
*/
export interface PermissionRow {
module: Module;
canView: boolean;
canCreate: boolean;
canUpdate: boolean;
canArchive: boolean;
canApprove: boolean;
canExport: boolean;
}
/**
* Default permission matrices for system roles seeded per tenant.
*/
function allTrue(modules: readonly Module[], actions: readonly Action[]): PermissionRow[] {
return MODULES.map((mod) => ({
module: mod,
canView: actions.includes('canView') && modules.includes(mod),
canCreate: actions.includes('canCreate') && modules.includes(mod),
canUpdate: actions.includes('canUpdate') && modules.includes(mod),
canArchive: actions.includes('canArchive') && modules.includes(mod),
canApprove: actions.includes('canApprove') && modules.includes(mod),
canExport: actions.includes('canExport') && modules.includes(mod),
}));
}
const ALL_MODULES = [...MODULES] as Module[];
export const DEFAULT_ROLE_PERMISSIONS: Record<string, PermissionRow[]> = {
tenant_admin: MODULES.map((mod) => ({
module: mod,
canView: true,
canCreate: true,
canUpdate: true,
canArchive: true,
canApprove: true,
canExport: true,
})),
manager: MODULES.map((mod) => {
const noAccess: Module[] = ['users'];
const viewOnly: Module[] = ['dashboard', 'accounting', 'settings'];
if (noAccess.includes(mod)) return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (viewOnly.includes(mod)) return { module: mod, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: mod === 'accounting' };
return {
module: mod,
canView: true,
canCreate: true,
canUpdate: true,
canArchive: true,
canApprove: ['invoices', 'payments', 'expenses', 'payroll', 'fund_transfers'].includes(mod),
canExport: true,
};
}),
technician: MODULES.map((mod) => {
// Technicians can create/update tickets and record payments in the field.
// They can VIEW clients, subscriptions, invoices for context but creating/updating
// those requires manager-level API access (@Roles('manager') on POST/PATCH).
// Assets require manager via class-level @Roles('manager').
// Subscriptions require manager for all endpoints.
const viewOnly: Module[] = ['clients', 'subscriptions', 'invoices', 'dashboard'];
const fullAccess: Module[] = ['tickets', 'payments'];
if (viewOnly.includes(mod)) return { module: mod, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
if (fullAccess.includes(mod)) return { module: mod, canView: true, canCreate: true, canUpdate: true, canArchive: false, canApprove: false, canExport: false };
return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}),
collector: MODULES.map((mod) => {
const canWrite: Module[] = ['payments'];
const canView: Module[] = ['dashboard', 'clients', 'invoices', 'payments'];
if (!canView.includes(mod)) return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
return {
module: mod,
canView: true,
canCreate: canWrite.includes(mod),
canUpdate: false,
canArchive: false,
canApprove: false,
canExport: false,
};
}),
};
// ─── Legacy support (for super_admin which uses old UserRole system) ───
export const Permission = {
READ_DASHBOARD: 'read:dashboard',
READ_REPORTS: 'read:reports',
READ_CLIENTS: 'read:clients',
WRITE_CLIENTS: 'write:clients',
DELETE_CLIENTS: 'delete:clients',
READ_SUBSCRIPTIONS: 'read:subscriptions',
WRITE_SUBSCRIPTIONS: 'write:subscriptions',
READ_INVOICES: 'read:invoices',
WRITE_INVOICES: 'write:invoices',
VOID_INVOICES: 'void:invoices',
READ_PAYMENTS: 'read:payments',
WRITE_PAYMENTS: 'write:payments',
APPROVE_REMITTANCES: 'approve:remittances',
READ_TICKETS: 'read:tickets',
WRITE_TICKETS: 'write:tickets',
ASSIGN_TICKETS: 'assign:tickets',
READ_EMPLOYEES: 'read:employees',
WRITE_EMPLOYEES: 'write:employees',
READ_PAYROLL: 'read:payroll',
WRITE_PAYROLL: 'write:payroll',
READ_EXPENSES: 'read:expenses',
WRITE_EXPENSES: 'write:expenses',
APPROVE_EXPENSES: 'approve:expenses',
READ_ASSETS: 'read:assets',
WRITE_ASSETS: 'write:assets',
READ_ACCOUNTS: 'read:accounts',
WRITE_ACCOUNTS: 'write:accounts',
TRANSFER_ACCOUNTS: 'transfer:accounts',
READ_ACCOUNTING: 'read:accounting',
WRITE_ACCOUNTING: 'write:accounting',
READ_SETTINGS: 'read:settings',
WRITE_SETTINGS: 'write:settings',
MANAGE_USERS: 'manage:users',
MANAGE_TENANTS: 'manage:tenants',
} as const;
export type PermissionString = (typeof Permission)[keyof typeof Permission];
/**
* Get permissions for super_admin (all permissions).
*/
export function getPermissionsForRoles(roles: string[]): string[] {
if (roles.includes(Role.SUPER_ADMIN)) {
return Object.values(Permission);
}
// For tenant users, permissions come from TenantRole → RolePermission in DB
return [];
}

View File

@@ -0,0 +1,36 @@
export const Role = {
SUPER_ADMIN: 'super_admin',
TENANT_ADMIN: 'tenant_admin',
MANAGER: 'manager',
TECHNICIAN: 'technician',
VIEWER: 'viewer',
} as const;
export type Role = (typeof Role)[keyof typeof Role];
export const ALL_ROLES: readonly Role[] = Object.values(Role);
/**
* Numeric hierarchy level per role.
* Higher number = more powerful role.
* Used for hierarchy-aware authorization checks.
*/
const ROLE_LEVEL: Record<string, number> = {
[Role.SUPER_ADMIN]: 100,
[Role.TENANT_ADMIN]: 80,
[Role.MANAGER]: 60,
[Role.TECHNICIAN]: 40,
[Role.VIEWER]: 20,
};
/**
* Check if any of the user's roles satisfies the required role level.
* A higher-level role always satisfies a lower-level requirement.
*
* Example: user with ['manager'] satisfies 'technician' because manager(60) >= technician(40).
*/
export function satisfiesRole(userRoles: string[], requiredRole: string): boolean {
const requiredLevel = ROLE_LEVEL[requiredRole];
if (requiredLevel === undefined) return false;
return userRoles.some((r) => (ROLE_LEVEL[r] ?? 0) >= requiredLevel);
}

View File

@@ -0,0 +1,67 @@
export const SubscriptionStatus = {
PENDING: 'pending',
ACTIVE: 'active',
SUSPENDED: 'suspended',
CANCELLED: 'cancelled',
EXPIRED: 'expired',
} as const;
export type SubscriptionStatus =
(typeof SubscriptionStatus)[keyof typeof SubscriptionStatus];
export const SubscriptionType = {
PREPAID: 'prepaid',
POSTPAID: 'postpaid',
} as const;
export type SubscriptionType =
(typeof SubscriptionType)[keyof typeof SubscriptionType];
export const InvoiceStatus = {
DRAFT: 'draft',
SENT: 'sent',
PARTIAL: 'partial',
PAID: 'paid',
OVERDUE: 'overdue',
VOID: 'void',
} as const;
export type InvoiceStatus =
(typeof InvoiceStatus)[keyof typeof InvoiceStatus];
export const TicketType = {
INSTALLATION: 'installation',
ACTIVATION: 'activation',
SUPPORT: 'support',
MAINTENANCE: 'maintenance',
} as const;
export type TicketType = (typeof TicketType)[keyof typeof TicketType];
export const TicketStatus = {
OPEN: 'open',
IN_PROGRESS: 'in_progress',
RESOLVED: 'resolved',
CANCELLED: 'cancelled',
} as const;
export type TicketStatus = (typeof TicketStatus)[keyof typeof TicketStatus];
export const PaymentMethod = {
GCASH: 'gcash',
MAYA: 'maya',
CASH: 'cash',
BANK_TRANSFER: 'bank_transfer',
} as const;
export type PaymentMethod =
(typeof PaymentMethod)[keyof typeof PaymentMethod];
export const AccountStatus = {
ACTIVE: 'active',
INACTIVE: 'inactive',
SUSPENDED: 'suspended',
} as const;
export type AccountStatus =
(typeof AccountStatus)[keyof typeof AccountStatus];

View File

@@ -0,0 +1,3 @@
export * from './constants';
export * from './schemas';
export * from './types';

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export const registerTenantSchema = z.object({
tenantName: z.string().min(2, 'Tenant name must be at least 2 characters'),
slug: z
.string()
.min(2)
.max(50)
.regex(
/^[a-z0-9-]+$/,
'Slug must contain only lowercase letters, numbers, and hyphens',
),
adminEmail: z.string().email('Invalid email address'),
adminPassword: z.string().min(8, 'Password must be at least 8 characters'),
adminFirstName: z.string().min(1, 'First name is required'),
adminLastName: z.string().min(1, 'Last name is required'),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterTenantInput = z.infer<typeof registerTenantSchema>;

View File

@@ -0,0 +1,5 @@
export { loginSchema, registerTenantSchema } from './auth';
export type { LoginInput, RegisterTenantInput } from './auth';
export { createUserSchema, updateUserSchema } from './user';
export type { CreateUserInput, UpdateUserInput } from './user';

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
import { Role } from '../constants/roles';
const roleEnum = z.enum([
Role.SUPER_ADMIN,
Role.TENANT_ADMIN,
Role.MANAGER,
Role.TECHNICIAN,
Role.VIEWER,
]);
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
roles: z.array(roleEnum).min(1, 'At least one role is required'),
});
export const updateUserSchema = z.object({
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
roles: z.array(roleEnum).min(1).optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

View File

@@ -0,0 +1,36 @@
export interface ApiResponse<T = unknown> {
success: boolean;
data: T | null;
error: string | null;
meta?: PaginationMeta;
}
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PaginationQuery {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface JwtPayload {
sub: string;
tenantId?: string | null;
roles: string[];
permissions: string[];
iat?: number;
exp?: number;
}
export interface TokenResponse {
accessToken: string;
refreshToken: string;
mustChangePassword?: boolean;
}

View File

@@ -0,0 +1,7 @@
export type {
ApiResponse,
PaginationMeta,
PaginationQuery,
JwtPayload,
TokenResponse,
} from './api';

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

File diff suppressed because one or more lines are too long

8
postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

View File

@@ -0,0 +1,638 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { Badge } from '@/components/ui/badge';
import { ActionIcon } from '@/components/ui/action-icon';
import { Modal } from '@/components/ui/modal';
import { Skeleton } from '@/components/ui/skeleton';
type Tab = 'overview' | 'trial-balance' | 'ledger';
const typeColors: Record<string, 'info' | 'success' | 'purple' | 'warning' | 'error'> = {
asset: 'info', liability: 'error', equity: 'purple', revenue: 'success', expense: 'warning',
};
function formatPHP(value: number): string {
return `${value.toLocaleString('en-PH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export default function AccountingPage() {
const [tab, setTab] = useState<Tab>('overview');
const [filterAccountId, setFilterAccountId] = useState<string | null>(null);
function goToLedger(accountId: string) {
setFilterAccountId(accountId);
setTab('ledger');
}
function clearAccountFilter() {
setFilterAccountId(null);
}
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0">
<PageHeader title="Accounting" description="General ledger and financial overview" />
<div className="mt-4 flex gap-1 border-b border-surface-200 dark:border-surface-700 mb-5">
{([['overview', 'Overview'], ['trial-balance', 'Trial Balance'], ['ledger', 'General Ledger']] as const).map(([key, label]) => (
<button key={key} onClick={() => setTab(key as Tab)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors cursor-pointer ${
tab === key ? 'border-primary-600 text-primary-700' : 'border-transparent text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'
}`}>{label}</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{tab === 'overview' && <AccountingOverview />}
{tab === 'trial-balance' && <TrialBalance onAccountClick={goToLedger} />}
{tab === 'ledger' && <GeneralLedger initialAccountId={filterAccountId} onClearAccountFilter={clearAccountFilter} />}
</div>
</div>
);
}
/* ── Accounting Overview ────────────────────────────────────── */
interface OverviewData {
totalAssets: number;
totalLiabilities: number;
totalEquity: number;
totalRevenue: number;
totalExpenses: number;
accountsReceivable: number;
cashAccounts: { code: string; name: string; balance: number }[];
cashOnHand: number;
monthlyRevenue: number;
monthlyExpenses: number;
netIncome: number;
expenseBreakdown: { code: string; name: string; amount: number }[];
}
function AccountingOverview() {
const [data, setData] = useState<OverviewData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/accounting/overview').then((r) => setData(r.data.data || r.data)).catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-5">
<Skeleton className="h-4 w-24 mb-3" />
<Skeleton className="h-7 w-32" />
</div>
))}
</div>
</div>
);
}
if (!data) return <p className="text-surface-400 py-8 text-center">Failed to load accounting overview</p>;
const maxExpense = data.expenseBreakdown.length > 0 ? Math.max(...data.expenseBreakdown.map((e) => e.amount)) : 1;
return (
<div className="space-y-6">
{/* Row 1 — Key Financial Position */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-emerald-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Cash on Hand</p>
<p className="mt-2 text-2xl font-bold text-emerald-700">{formatPHP(data.cashOnHand)}</p>
<p className="text-xs text-surface-400 mt-1">From journal entries</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-primary-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Accounts Receivable</p>
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-200">{formatPHP(data.accountsReceivable)}</p>
<p className="text-xs text-surface-400 mt-1">Outstanding invoices</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-blue-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Monthly Revenue</p>
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-200">{formatPHP(data.monthlyRevenue)}</p>
<p className="text-xs text-surface-400 mt-1">Current month credits</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-amber-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Net Income (MTD)</p>
<p className={`mt-2 text-2xl font-bold ${data.netIncome >= 0 ? 'text-emerald-700' : 'text-red-600'}`}>
{formatPHP(Math.abs(data.netIncome))}{data.netIncome < 0 ? ' loss' : ''}
</p>
<p className="text-xs text-surface-400 mt-1">Revenue minus expenses</p>
</div>
</div>
{/* Row 2 — Cash Account Breakdown + Expense Breakdown */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Cash accounts */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-4">Cash Account Breakdown</h2>
<div className="space-y-3">
{data.cashAccounts.map((acc) => {
const pct = data.cashOnHand !== 0 ? Math.abs(acc.balance / data.cashOnHand) * 100 : 0;
return (
<div key={acc.code}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm text-surface-700 dark:text-surface-300">
<span className="font-mono text-surface-400 mr-2">{acc.code}</span>
{acc.name}
</span>
<span className="text-sm font-bold text-surface-900 dark:text-surface-200">{formatPHP(acc.balance)}</span>
</div>
<div className="h-2 bg-surface-100 dark:bg-surface-700 rounded-full overflow-hidden">
<div className="h-full bg-primary-500 rounded-full" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div>
);
})}
</div>
</div>
{/* Expense breakdown */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-1">Expense Breakdown (MTD)</h2>
<p className="text-xs text-surface-400 mb-4">Total: {formatPHP(data.monthlyExpenses)}</p>
{data.expenseBreakdown.length === 0 ? (
<p className="text-sm text-surface-400 py-6 text-center">No expenses this month</p>
) : (
<div className="space-y-3">
{data.expenseBreakdown.map((exp) => {
const pct = maxExpense > 0 ? (exp.amount / maxExpense) * 100 : 0;
return (
<div key={exp.code}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm text-surface-700 dark:text-surface-300">
<span className="font-mono text-surface-400 mr-2">{exp.code}</span>
{exp.name}
</span>
<span className="text-sm font-bold text-surface-900 dark:text-surface-200">{formatPHP(exp.amount)}</span>
</div>
<div className="h-2 bg-surface-100 dark:bg-surface-700 rounded-full overflow-hidden">
<div className="h-full bg-red-400 rounded-full" style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Row 3 — Balance Sheet Summary */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-4">Balance Sheet Summary</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
<div>
<p className="text-xs text-surface-500 dark:text-surface-400 uppercase font-medium mb-1">Total Assets</p>
<p className="text-xl font-bold text-surface-900 dark:text-surface-200">{formatPHP(data.totalAssets)}</p>
</div>
<div>
<p className="text-xs text-surface-500 dark:text-surface-400 uppercase font-medium mb-1">Total Liabilities</p>
<p className="text-xl font-bold text-surface-900 dark:text-surface-200">{formatPHP(data.totalLiabilities)}</p>
</div>
<div>
<p className="text-xs text-surface-500 dark:text-surface-400 uppercase font-medium mb-1">Total Equity</p>
<p className="text-xl font-bold text-surface-900 dark:text-surface-200">{formatPHP(data.totalEquity)}</p>
</div>
<div>
<p className="text-xs text-surface-500 dark:text-surface-400 uppercase font-medium mb-1">Accounting Equation</p>
<p className={`text-sm font-bold ${Math.abs(data.totalAssets - (data.totalLiabilities + data.totalEquity)) < 0.01 ? 'text-emerald-600' : 'text-red-600'}`}>
{Math.abs(data.totalAssets - (data.totalLiabilities + data.totalEquity)) < 0.01 ? 'A = L + E Balanced' : `Imbalance: ${formatPHP(Math.abs(data.totalAssets - (data.totalLiabilities + data.totalEquity)))}`}
</p>
</div>
</div>
</div>
</div>
);
}
/* ── Trial Balance ──────────────────────────────────────────── */
interface TrialBalanceItem {
id: string;
code: string;
name: string;
type: string;
debit: number;
credit: number;
balance: number;
accountId?: string;
}
function TrialBalance({ onAccountClick }: { onAccountClick: (accountId: string) => void }) {
const [data, setData] = useState<TrialBalanceItem[]>([]);
const [selectedAccount, setSelectedAccount] = useState<TrialBalanceItem | null>(null);
useEffect(() => {
api.get('/accounting/trial-balance').then((r) => setData(r.data.data || r.data)).catch(() => {});
}, []);
const totalDebit = data.reduce((s, a) => s + a.debit, 0);
const totalCredit = data.reduce((s, a) => s + a.credit, 0);
return (
<div>
{data.length === 0 ? (
<div className="text-center py-12 text-surface-400">No journal entries yet. Transactions will appear here when payments and invoices are recorded.</div>
) : (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 flex flex-col overflow-hidden" style={{ maxHeight: 'calc(100vh - 300px)' }}>
<div className="overflow-y-auto flex-1 min-h-0">
<table className="min-w-full divide-y divide-surface-200 dark:divide-surface-700">
<thead className="bg-surface-50/50 dark:bg-surface-900/50 sticky top-0 z-10"><tr>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Code</th>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Account</th>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Type</th>
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Debit</th>
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Credit</th>
<th className="px-5 py-3 w-12 bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm"></th>
</tr></thead>
<tbody className="divide-y divide-surface-100 dark:divide-surface-700">
{data.map((a) => (
<tr key={a.code}
onClick={() => setSelectedAccount(a)}
className="hover:bg-primary-50/40 dark:hover:bg-primary-900/20 border-l-2 border-l-transparent hover:border-l-primary-400 transition-all duration-150 cursor-pointer">
<td className="px-5 py-3 text-sm font-mono text-surface-600 dark:text-surface-400">{a.code}</td>
<td className="px-5 py-3 text-sm font-medium text-surface-800 dark:text-surface-200">{a.name}</td>
<td className="px-5 py-3"><Badge label={a.type} variant={typeColors[a.type]} /></td>
<td className="px-5 py-3 text-sm text-right text-surface-900 dark:text-surface-200">{a.debit > 0 ? `PHP ${a.debit.toLocaleString()}` : ''}</td>
<td className="px-5 py-3 text-sm text-right text-surface-900 dark:text-surface-200">{a.credit > 0 ? `PHP ${a.credit.toLocaleString()}` : ''}</td>
<td className="px-5 py-3">
<ActionIcon icon="external-link" variant="ghost" label="View Ledger"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onAccountClick(a.id); }} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Sticky totals footer */}
<div className="border-t-2 border-surface-300 dark:border-surface-600 bg-surface-50 dark:bg-surface-900 flex-shrink-0">
<div className="flex items-center px-5 py-3 text-sm font-semibold">
<span className="flex-1 text-surface-700 dark:text-surface-300">Totals</span>
<span className="w-[120px] text-right text-surface-900 dark:text-surface-200">PHP {totalDebit.toLocaleString()}</span>
<span className="w-[120px] text-right text-surface-900 dark:text-surface-200">PHP {totalCredit.toLocaleString()}</span>
<span className="w-12"></span>
</div>
<div className="flex items-center px-5 py-2 text-xs border-t border-surface-200 dark:border-surface-700">
<span className="flex-1 text-surface-400">Difference</span>
<span className={`text-right ${Math.abs(totalDebit - totalCredit) < 0.01 ? 'text-emerald-600' : 'text-red-600'}`}>
{Math.abs(totalDebit - totalCredit) < 0.01 ? 'Balanced' : `PHP ${Math.abs(totalDebit - totalCredit).toLocaleString()} imbalance`}
</span>
<span className="w-12"></span>
</div>
</div>
</div>
)}
{selectedAccount && (
<AccountBreakdownModal
account={selectedAccount}
open={!!selectedAccount}
onClose={() => setSelectedAccount(null)}
onViewLedger={() => {
setSelectedAccount(null);
onAccountClick(selectedAccount.id);
}}
/>
)}
</div>
);
}
/* ── Account Breakdown Modal ────────────────────────────────── */
interface BreakdownEntry {
id?: string;
debit: string;
credit: string;
journalEntry: {
entryDate: string;
description: string;
reference: string | null;
};
}
function AccountBreakdownModal({
account,
open,
onClose,
onViewLedger,
}: {
account: TrialBalanceItem;
open: boolean;
onClose: () => void;
onViewLedger: () => void;
}) {
const [entries, setEntries] = useState<BreakdownEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!open) return;
setLoading(true);
api.get(`/accounting/general-ledger?accountId=${account.id}`)
.then((r) => setEntries(r.data.data || r.data))
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [open, account.id]);
// Running balance
const rowsWithBalance = useMemo(() => {
let running = 0;
return entries.map((e) => {
running += Number(e.debit) - Number(e.credit);
return { ...e, balance: running };
});
}, [entries]);
const totalDebit = entries.reduce((s, e) => s + Number(e.debit), 0);
const totalCredit = entries.reduce((s, e) => s + Number(e.credit), 0);
return (
<Modal open={open} onClose={onClose} title={`${account.code}${account.name}`}
description={`${account.type.charAt(0).toUpperCase() + account.type.slice(1)} Account`}
size="lg">
{/* Account summary */}
<div className="flex gap-6 mb-4 pb-4 border-b border-surface-200 dark:border-surface-700">
<div>
<div className="text-xs text-surface-500 dark:text-surface-400 uppercase font-medium">Total Debit</div>
<div className="text-lg font-semibold text-surface-900 dark:text-surface-200">PHP {account.debit.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-surface-500 dark:text-surface-400 uppercase font-medium">Total Credit</div>
<div className="text-lg font-semibold text-surface-900 dark:text-surface-200">PHP {account.credit.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-surface-500 dark:text-surface-400 uppercase font-medium">Net Balance</div>
<div className={`text-lg font-semibold ${account.balance >= 0 ? 'text-surface-900 dark:text-surface-200' : 'text-red-600'}`}>
PHP {Math.abs(account.balance).toLocaleString()}{account.balance < 0 ? ' CR' : ''}
</div>
</div>
</div>
{/* Journal entries table */}
{loading ? (
<div className="py-8 text-center text-surface-400 text-sm">Loading entries...</div>
) : entries.length === 0 ? (
<div className="py-8 text-center text-surface-400 text-sm">No journal entries for this account.</div>
) : (
<div className="border border-surface-200 dark:border-surface-700 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto">
<table className="w-full table-fixed divide-y divide-surface-200 dark:divide-surface-700">
<thead className="bg-surface-50 dark:bg-surface-900 sticky top-0 z-10">
<tr>
<th className="w-[90px] px-4 py-2.5 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Date</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Description</th>
<th className="w-[110px] px-4 py-2.5 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Debit</th>
<th className="w-[110px] px-4 py-2.5 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Credit</th>
<th className="w-[120px] px-4 py-2.5 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Balance</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-100 dark:divide-surface-700">
{rowsWithBalance.map((e, i) => (
<tr key={i} className="hover:bg-surface-50/50 dark:hover:bg-surface-700/50">
<td className="px-4 py-2.5 text-sm text-surface-500 dark:text-surface-400 whitespace-nowrap">{new Date(e.journalEntry.entryDate).toLocaleDateString()}</td>
<td className="px-4 py-2.5 text-sm text-surface-800 dark:text-surface-200 truncate" title={e.journalEntry.description}>{e.journalEntry.description}</td>
<td className="px-4 py-2.5 text-sm text-right text-surface-900 dark:text-surface-200 whitespace-nowrap">{Number(e.debit) > 0 ? `PHP ${Number(e.debit).toLocaleString()}` : ''}</td>
<td className="px-4 py-2.5 text-sm text-right text-surface-900 dark:text-surface-200 whitespace-nowrap">{Number(e.credit) > 0 ? `PHP ${Number(e.credit).toLocaleString()}` : ''}</td>
<td className={`px-4 py-2.5 text-sm text-right font-medium whitespace-nowrap ${e.balance >= 0 ? 'text-surface-900 dark:text-surface-200' : 'text-red-600'}`}>
PHP {Math.abs(e.balance).toLocaleString()}{e.balance < 0 ? ' CR' : ''}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-surface-50 font-semibold sticky bottom-0 z-10">
<tr>
<td colSpan={2} className="px-4 py-2.5 text-sm text-surface-700 dark:text-surface-300">Totals ({entries.length} entries)</td>
<td className="px-4 py-2.5 text-sm text-right text-surface-900 dark:text-surface-100">PHP {totalDebit.toLocaleString()}</td>
<td className="px-4 py-2.5 text-sm text-right text-surface-900 dark:text-surface-100">PHP {totalCredit.toLocaleString()}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Footer actions */}
<div className="mt-4 flex justify-end gap-3">
<button onClick={onClose}
className="px-4 py-2 text-sm font-medium text-surface-600 bg-surface-100 rounded-lg hover:bg-surface-200 transition-colors cursor-pointer">
Close
</button>
<button onClick={onViewLedger}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 transition-colors cursor-pointer">
View in Ledger
</button>
</div>
</Modal>
);
}
/* ── General Ledger ─────────────────────────────────────────── */
interface LedgerEntry {
id?: string;
debit: string;
credit: string;
journalEntry: {
entryDate: string;
description: string;
reference: string | null;
};
account: {
id: string;
code: string;
name: string;
};
}
function GeneralLedger({ initialAccountId, onClearAccountFilter }: { initialAccountId: string | null; onClearAccountFilter: () => void }) {
const [entries, setEntries] = useState<LedgerEntry[]>([]);
const [search, setSearch] = useState('');
const [accountFilter, setAccountFilter] = useState<string>(initialAccountId || '');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
useEffect(() => {
api.get('/accounting/general-ledger').then((r) => setEntries(r.data.data || r.data)).catch(() => {});
}, []);
// When initialAccountId changes (clicked from trial balance), update filter
useEffect(() => {
if (initialAccountId) {
setAccountFilter(initialAccountId);
}
}, [initialAccountId]);
// Unique accounts for dropdown
const uniqueAccounts = useMemo(() => {
const seen = new Map<string, { id: string; code: string; name: string }>();
for (const e of entries) {
if (!seen.has(e.account.id)) {
seen.set(e.account.id, e.account);
}
}
return Array.from(seen.values()).sort((a, b) => a.code.localeCompare(b.code));
}, [entries]);
// Filtered entries
const filtered = useMemo(() => {
let result = entries;
// Account filter
if (accountFilter) {
result = result.filter((e) => e.account.id === accountFilter);
}
// Date range filter
if (dateFrom) {
const from = new Date(dateFrom);
result = result.filter((e) => new Date(e.journalEntry.entryDate) >= from);
}
if (dateTo) {
const to = new Date(dateTo);
to.setHours(23, 59, 59, 999);
result = result.filter((e) => new Date(e.journalEntry.entryDate) <= to);
}
// Search filter
if (search) {
const q = search.toLowerCase();
result = result.filter((e) =>
e.journalEntry.description.toLowerCase().includes(q) ||
`${e.account.code} ${e.account.name}`.toLowerCase().includes(q) ||
(e.journalEntry.reference || '').toLowerCase().includes(q)
);
}
return result;
}, [entries, accountFilter, dateFrom, dateTo, search]);
const activeFilterCount = [accountFilter, dateFrom, dateTo].filter(Boolean).length;
function clearFilters() {
setAccountFilter('');
setDateFrom('');
setDateTo('');
setSearch('');
onClearAccountFilter();
}
// Compute running totals when filtered by a single account
const showRunningBalance = !!accountFilter;
const rowsWithBalance = useMemo(() => {
if (!showRunningBalance) return filtered.map((e) => ({ ...e, balance: null }));
let running = 0;
return filtered.map((e) => {
running += Number(e.debit) - Number(e.credit);
return { ...e, balance: running };
});
}, [filtered, showRunningBalance]);
const totalDebit = filtered.reduce((s, e) => s + Number(e.debit), 0);
const totalCredit = filtered.reduce((s, e) => s + Number(e.credit), 0);
const inputClass = 'rounded-lg border border-surface-200 dark:border-surface-600 px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 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 (
<div className="space-y-4">
{/* Filters */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[200px]">
<label className="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">Search</label>
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-400" width="14" height="14" 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={search} onChange={(e) => setSearch(e.target.value)}
placeholder="Description, account, reference..."
className={`${inputClass} pl-9 w-full`} />
</div>
</div>
<div className="min-w-[180px]">
<label className="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">Account</label>
<select value={accountFilter} onChange={(e) => setAccountFilter(e.target.value)} className={`${inputClass} w-full`}>
<option value="">All accounts</option>
{uniqueAccounts.map((a) => (
<option key={a.id} value={a.id}>{a.code} {a.name}</option>
))}
</select>
</div>
<div className="min-w-[140px]">
<label className="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">From</label>
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className={`${inputClass} w-full`} />
</div>
<div className="min-w-[140px]">
<label className="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">To</label>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className={`${inputClass} w-full`} />
</div>
{activeFilterCount > 0 && (
<button type="button" onClick={clearFilters}
className="px-3 py-2 text-sm font-medium text-primary-600 hover:text-primary-700 hover:bg-primary-50 rounded-lg transition-colors cursor-pointer flex items-center gap-1.5">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
Clear
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary-100 text-primary-700 rounded-full">{activeFilterCount}</span>
</button>
)}
</div>
</div>
{/* Account filter banner */}
{accountFilter && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-primary-50 dark:bg-primary-900/30 border border-primary-200 dark:border-primary-800 rounded-lg text-sm">
<span className="text-primary-700 dark:text-primary-400 font-medium">Filtered to:</span>
<span className="text-primary-800 dark:text-primary-300">{uniqueAccounts.find((a) => a.id === accountFilter)?.code} {uniqueAccounts.find((a) => a.id === accountFilter)?.name}</span>
<button type="button" onClick={clearFilters}
className="ml-auto text-primary-500 hover:text-primary-700 cursor-pointer">
<svg width="14" height="14" 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>
)}
{/* Table */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 flex flex-col overflow-hidden" style={{ maxHeight: 'calc(100vh - 380px)' }}>
<div className="overflow-y-auto flex-1 min-h-0">
<table className="min-w-full divide-y divide-surface-200 dark:divide-surface-700">
<thead className="bg-surface-50/50 dark:bg-surface-900/50 sticky top-0 z-10"><tr>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Date</th>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Description</th>
{!accountFilter && <th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Account</th>}
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Reference</th>
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Debit</th>
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Credit</th>
{showRunningBalance && <th className="px-5 py-3 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase bg-surface-50/80 dark:bg-surface-900/80 backdrop-blur-sm">Balance</th>}
</tr></thead>
<tbody className="divide-y divide-surface-100 dark:divide-surface-700">
{rowsWithBalance.map((e, i) => (
<tr key={i} className="hover:bg-surface-50/50 dark:hover:bg-surface-700/50 transition-colors">
<td className="px-5 py-3 text-sm text-surface-500 dark:text-surface-400">{new Date(e.journalEntry.entryDate).toLocaleDateString()}</td>
<td className="px-5 py-3 text-sm text-surface-800 dark:text-surface-200">{e.journalEntry.description}</td>
{!accountFilter && <td className="px-5 py-3 text-sm text-surface-600 dark:text-surface-300">{e.account.code} {e.account.name}</td>}
<td className="px-5 py-3 text-sm font-mono text-surface-400">{e.journalEntry.reference || '—'}</td>
<td className="px-5 py-3 text-sm text-right text-surface-900 dark:text-surface-200">{Number(e.debit) > 0 ? `PHP ${Number(e.debit).toLocaleString()}` : ''}</td>
<td className="px-5 py-3 text-sm text-right text-surface-900 dark:text-surface-200">{Number(e.credit) > 0 ? `PHP ${Number(e.credit).toLocaleString()}` : ''}</td>
{showRunningBalance && (
<td className={`px-5 py-3 text-sm text-right font-medium ${(e.balance ?? 0) >= 0 ? 'text-surface-900 dark:text-surface-200' : 'text-red-600'}`}>
PHP {Math.abs(e.balance ?? 0).toLocaleString()}{(e.balance ?? 0) < 0 ? ' CR' : ''}
</td>
)}
</tr>
))}
{filtered.length === 0 && <tr><td colSpan={showRunningBalance ? 7 : 6} className="px-5 py-8 text-center text-surface-400">No journal entries match your filters</td></tr>}
</tbody>
</table>
</div>
{/* Sticky totals footer */}
{filtered.length > 0 && (
<div className="border-t-2 border-surface-300 dark:border-surface-600 bg-surface-50 dark:bg-surface-900 flex-shrink-0">
<div className="flex items-center px-5 py-3 text-sm font-semibold">
<span className="flex-1 text-surface-700 dark:text-surface-300">Totals ({filtered.length} entries)</span>
<span className="w-[120px] text-right text-surface-900 dark:text-surface-100">PHP {totalDebit.toLocaleString()}</span>
<span className="w-[120px] text-right text-surface-900 dark:text-surface-100">PHP {totalCredit.toLocaleString()}</span>
{showRunningBalance && <span className="w-[120px]"></span>}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { useToast } from '@/components/ui/toast';
import { TransferModal } from '@/components/modals/transfer-modal';
const typeLabels: Record<string, string> = { bank: 'Bank', e_wallet: 'E-Wallet', cash: 'Cash' };
export default function FundTransfersPage() {
const { toast } = useToast();
const [accounts, setAccounts] = useState<any[]>([]);
const [transfers, setTransfers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showTransfer, setShowTransfer] = useState(false);
const [search, setSearch] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const load = useCallback(async () => {
try {
const [a, t] = await Promise.all([api.get('/accounts'), api.get('/accounts/transfers')]);
setAccounts(a.data.data); setTransfers(t.data.data);
} catch { toast('Failed to load', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { load(); }, [load]);
const totalBalance = accounts.reduce((s: number, a: any) => s + Number(a.balance), 0);
const filtered = useMemo(() => {
let result = transfers;
if (search) {
const q = search.toLowerCase();
result = result.filter((t: any) =>
(t.fromAccount?.name || '').toLowerCase().includes(q) ||
(t.toAccount?.name || '').toLowerCase().includes(q) ||
(t.description || '').toLowerCase().includes(q)
);
}
if (dateFrom) {
const from = new Date(dateFrom);
result = result.filter((t: any) => new Date(t.createdAt) >= from);
}
if (dateTo) {
const to = new Date(dateTo);
to.setHours(23, 59, 59, 999);
result = result.filter((t: any) => new Date(t.createdAt) <= to);
}
return result;
}, [transfers, search, dateFrom, dateTo]);
const activeFilterCount = [dateFrom, dateTo].filter(Boolean).length;
function clearFilters() {
setSearch('');
setDateFrom('');
setDateTo('');
}
const inputClass = 'rounded-lg border border-surface-200 dark:border-surface-700 px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<div>
<PageHeader title="Fund Transfers" description="Transfer funds between company accounts"
action={<Button onClick={() => setShowTransfer(true)}>New Transfer</Button>} />
{/* Account balances summary */}
<div className="mt-5 grid grid-cols-2 lg:grid-cols-4 gap-4">
{accounts.map((a: any) => (
<div key={a.id} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-surface-700 dark:text-surface-300">{a.name}</h3>
<Badge label={typeLabels[a.type] || a.type} variant={a.type === 'bank' ? 'info' : a.type === 'e_wallet' ? 'purple' : 'default'} />
</div>
{a.accountNo && <p className="text-xs font-mono text-surface-400 mt-0.5">{a.accountNo}</p>}
<p className="mt-2 text-xl font-bold text-surface-900 dark:text-surface-100">PHP {Number(a.balance).toLocaleString()}</p>
{a.isSystem && <p className="text-[10px] text-surface-300 mt-1">System account</p>}
</div>
))}
</div>
<div className="mt-2 text-right text-sm text-surface-500 dark:text-surface-400">
Total: <span className="font-bold text-surface-900 dark:text-surface-100">PHP {totalBalance.toLocaleString()}</span>
</div>
{/* Transfer history */}
<h2 className="mt-8 text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Transfer History</h2>
{/* Date range filters */}
<div className="mb-4 flex flex-wrap items-end gap-3">
<div className="min-w-[140px]">
<label className="block text-xs font-medium text-surface-500 mb-1">From</label>
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className={`${inputClass} w-full`} />
</div>
<div className="min-w-[140px]">
<label className="block text-xs font-medium text-surface-500 mb-1">To</label>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className={`${inputClass} w-full`} />
</div>
{activeFilterCount > 0 && (
<button type="button" onClick={clearFilters}
className="px-3 py-2 text-sm font-medium text-primary-600 hover:text-primary-700 hover:bg-primary-50 rounded-lg transition-colors cursor-pointer flex items-center gap-1.5">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
Clear
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary-100 text-primary-700 rounded-full">{activeFilterCount}</span>
</button>
)}
</div>
<DataTable
data={filtered}
loading={loading}
keyExtractor={(t: any) => t.id}
emptyTitle="No transfers"
searchPlaceholder="Search by account or description..."
searchValue={search}
onSearchChange={setSearch}
columns={[
{ key: 'createdAt', label: 'Date', sortable: true, render: (t: any) => <span className="text-surface-500 dark:text-surface-400">{new Date(t.createdAt).toLocaleDateString()}</span> },
{ key: 'from', label: 'From', render: (t: any) => <span className="text-surface-800 dark:text-surface-200">{t.fromAccount?.name}</span> },
{ key: 'arrow', label: '', align: 'center' as const, render: () => (
<span className="text-surface-300">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 8h8M9 5l3 3-3 3" /></svg>
</span>
)},
{ key: 'to', label: 'To', render: (t: any) => <span className="text-surface-800 dark:text-surface-200">{t.toAccount?.name}</span> },
{ key: 'amount', label: 'Amount', align: 'right' as const, sortable: true, render: (t: any) => <span className="font-medium text-surface-900 dark:text-surface-100">PHP {Number(t.amount).toLocaleString()}</span> },
{ key: 'description', label: 'Description', render: (t: any) => <span className="text-surface-500 dark:text-surface-400">{t.description || '—'}</span> },
]}
/>
<TransferModal open={showTransfer} onClose={() => setShowTransfer(false)} onSuccess={load} />
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Modal } from '@/components/ui/modal';
import { EmptyState } from '@/components/ui/empty-state';
import { CardSkeleton } from '@/components/ui/skeleton';
import { useToast } from '@/components/ui/toast';
interface Area {
id: string;
name: string;
description: string | null;
isActive: boolean;
_count: { clients: number };
}
export default function AreasPage() {
const { toast } = useToast();
const [areas, setAreas] = useState<Area[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Area | null>(null);
const [deleting, setDeleting] = useState(false);
const loadAreas = useCallback(async () => {
try {
const res = await api.get<{ data: Area[] }>('/areas');
setAreas(res.data.data);
} catch {
toast('Failed to load areas', 'error');
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => { loadAreas(); }, [loadAreas]);
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(`/areas/${deleteTarget.id}`);
toast(`Area "${deleteTarget.name}" deleted`, 'success');
setDeleteTarget(null);
loadAreas();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to delete area', 'error');
} finally {
setDeleting(false);
}
}
return (
<div>
<PageHeader
title="Area Management"
description="Define service areas and zones for client assignment"
action={
<Button onClick={() => setShowCreate(!showCreate)} variant={showCreate ? 'secondary' : 'primary'}>
{showCreate ? 'Cancel' : 'Add Area'}
</Button>
}
/>
{showCreate && (
<CreateAreaForm onCreated={() => { setShowCreate(false); loadAreas(); toast('Area created', 'success'); }} />
)}
{loading ? (
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => <CardSkeleton key={i} />)}
</div>
) : areas.length === 0 ? (
<div className="mt-6">
<EmptyState
title="No areas yet"
description="Create your first service area to start organizing clients by location."
icon={<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M12 22s-8-5.8-8-11.3a8 8 0 0116 0C20 16.2 12 22 12 22z" /><circle cx="12" cy="10.7" r="3" /></svg>}
action={<Button onClick={() => setShowCreate(true)}>Create First Area</Button>}
/>
</div>
) : (
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{areas.map((area) => (
<article key={area.id} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-5 hover:shadow-md hover:shadow-surface-100 transition-all duration-200">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-surface-900 dark:text-surface-200">{area.name}</h3>
{area.description && <p className="text-sm text-surface-500 dark:text-surface-400 mt-1">{area.description}</p>}
</div>
<Badge label={area.isActive ? 'Active' : 'Inactive'} variant={area.isActive ? 'success' : 'error'} />
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-surface-100 dark:border-surface-700">
<span className="text-sm text-surface-500 dark:text-surface-400">{area._count.clients} client{area._count.clients !== 1 ? 's' : ''}</span>
{area._count.clients === 0 && (
<Button size="sm" variant="ghost" onClick={() => setDeleteTarget(area)}>
Delete
</Button>
)}
</div>
</article>
))}
</div>
)}
<Modal
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title="Delete Area"
description={`Are you sure you want to delete "${deleteTarget?.name}"? This action cannot be undone.`}
variant="danger"
confirmLabel="Delete Area"
onConfirm={handleDelete}
loading={deleting}
/>
</div>
);
}
function CreateAreaForm({ onCreated }: { onCreated: () => void }) {
const { toast } = useToast();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await api.post('/areas', { name, description: description || undefined });
onCreated();
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to create area');
toast(err.response?.data?.error || 'Failed to create area', 'error');
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="mt-4 bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6 space-y-4 max-w-lg">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-2 rounded-lg text-sm" role="alert">{error}</div>
)}
<div>
<label htmlFor="area-name" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Area Name</label>
<input id="area-name" type="text" required minLength={2} value={name} onChange={(e) => setName(e.target.value)}
className="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"
placeholder="e.g. Barangay 1 - Centro" />
</div>
<div>
<label htmlFor="area-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>
<input id="area-desc" type="text" value={description} onChange={(e) => setDescription(e.target.value)}
className="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"
placeholder="Description of the service area" />
</div>
<Button type="submit" loading={submitting}>Create Area</Button>
</form>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { DataTable } from '@/components/ui/data-table';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ActionIcon } from '@/components/ui/action-icon';
import { Modal } from '@/components/ui/modal';
import { FormModal } from '@/components/ui/form-modal';
import { useToast } from '@/components/ui/toast';
const CATEGORIES = ['router', 'olt', 'cable', 'tool', 'vehicle', 'computer', 'other'];
export default function AssetsPage() {
const { toast } = useToast();
const [assets, setAssets] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [detailTarget, setDetailTarget] = useState<any>(null);
const [catFilter, setCatFilter] = useState('');
const load = useCallback(async () => {
try {
const params = new URLSearchParams();
if (catFilter) params.set('category', catFilter);
const r = await api.get(`/assets?${params}`);
setAssets(r.data.data);
} catch { toast('Failed to load assets', 'error'); }
finally { setLoading(false); }
}, [catFilter, toast]);
useEffect(() => { load(); }, [load]);
const filtered = search ? assets.filter((a: any) => a.name.toLowerCase().includes(search.toLowerCase()) || a.serialNumber?.toLowerCase().includes(search.toLowerCase())) : assets;
return (
<div>
<PageHeader title="Assets" description="Track company equipment and inventory"
action={<div className="flex gap-2">
<select value={catFilter} onChange={(e) => setCatFilter(e.target.value)} aria-label="Filter category"
className="rounded-lg border border-surface-200 dark:border-surface-700 px-3 py-2 text-sm bg-white dark:bg-surface-800 text-surface-700 dark:text-surface-300 cursor-pointer focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
<option value="">All categories</option>
{CATEGORIES.map((c) => <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
</select>
<Button onClick={() => setShowCreate(true)}>Add Asset</Button>
</div>} />
<div className="mt-5">
<DataTable data={filtered} loading={loading} keyExtractor={(a: any) => a.id}
emptyTitle="No assets" emptyDescription="Add your first equipment or tool."
searchPlaceholder="Search by name or serial number..." searchValue={search} onSearchChange={setSearch}
onRowClick={(a: any) => setDetailTarget(a)}
columns={[
{ key: 'name', label: 'Name', sortable: true, render: (a: any) => <span className="font-medium text-surface-800 dark:text-surface-200">{a.name}</span> },
{ key: 'category', label: 'Category', sortable: true, render: (a: any) => <Badge label={a.category} /> },
{ key: 'serialNumber', label: 'Serial #', render: (a: any) => <span className="font-mono text-surface-500 dark:text-surface-400 text-xs">{a.serialNumber || '—'}</span> },
{ key: 'status', label: 'Status', sortable: true, render: (a: any) => <Badge label={a.status} variant={statusBadgeVariant(a.status)} /> },
{ key: 'assignedTo', label: 'Assigned To', render: (a: any) => a.assignedTo ? <span className="text-surface-600 dark:text-surface-300">{a.assignedTo.firstName} {a.assignedTo.lastName}</span> : <span className="text-surface-300">Unassigned</span> },
{ key: 'purchasePrice', label: 'Value', align: 'right' as const, render: (a: any) => a.purchasePrice ? <span className="text-surface-700 dark:text-surface-300">PHP {Number(a.purchasePrice).toLocaleString()}</span> : <span className="text-surface-300"></span> },
]}
/>
</div>
<CreateAssetModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={load} />
{/* Asset Detail Modal */}
<Modal open={!!detailTarget} onClose={() => setDetailTarget(null)}
title={detailTarget?.name || 'Asset Details'} description={detailTarget?.serialNumber ? `S/N: ${detailTarget.serialNumber}` : ''}>
{detailTarget && (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div><span className="text-surface-500 dark:text-surface-400">Category:</span> <Badge label={detailTarget.category} /></div>
<div><span className="text-surface-500 dark:text-surface-400">Status:</span> <Badge label={detailTarget.status} variant={statusBadgeVariant(detailTarget.status)} /></div>
<div><span className="text-surface-500 dark:text-surface-400">Serial #:</span> <span className="font-mono text-surface-700 dark:text-surface-300">{detailTarget.serialNumber || '—'}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Value:</span> <span className="text-surface-900 dark:text-surface-200 font-medium">{detailTarget.purchasePrice ? `PHP ${Number(detailTarget.purchasePrice).toLocaleString()}` : '—'}</span></div>
<div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Assigned To:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.assignedTo ? `${detailTarget.assignedTo.firstName} ${detailTarget.assignedTo.lastName}` : 'Unassigned'}</span></div>
{detailTarget.location && <div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Location:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.location}</span></div>}
{detailTarget.notes && <div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Notes:</span> <span className="text-surface-600 dark:text-surface-300">{detailTarget.notes}</span></div>}
</div>
<div className="flex justify-end pt-2 border-t border-surface-200 dark:border-surface-700">
<Button size="sm" variant="ghost" onClick={() => setDetailTarget(null)}>Close</Button>
</div>
</div>
)}
</Modal>
</div>
);
}
function CreateAssetModal({ open, onClose, onSuccess }: { open: boolean; onClose: () => void; onSuccess: () => void }) {
const { toast } = useToast();
const [form, setForm] = useState({ name: '', category: 'router', serialNumber: '', purchasePrice: 0, location: '', notes: '' });
const [submitting, setSubmitting] = useState(false);
const ic = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm bg-white dark:bg-surface-800 text-surface-900 dark:text-surface-200 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
useEffect(() => { if (open) setForm({ name: '', category: 'router', serialNumber: '', purchasePrice: 0, location: '', notes: '' }); }, [open]);
async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setSubmitting(true);
try { await api.post('/assets', { ...form, serialNumber: form.serialNumber || undefined, purchasePrice: form.purchasePrice || undefined, location: form.location || undefined, notes: form.notes || undefined }); toast('Asset added', 'success'); onSuccess(); onClose(); }
catch (err: any) { toast(err.response?.data?.error || 'Failed', 'error'); } finally { setSubmitting(false); } }
return (
<FormModal open={open} onClose={onClose} title="Add Asset"><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">Name</label><input type="text" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={ic} placeholder="e.g. Mikrotik hEX S" /></div>
<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>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Serial Number</label><input type="text" value={form.serialNumber} onChange={(e) => setForm({ ...form, serialNumber: e.target.value })} className={ic} /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Purchase Price (PHP)</label><input type="number" min={0} value={form.purchasePrice || ''} onChange={(e) => setForm({ ...form, purchasePrice: parseFloat(e.target.value) || 0 })} className={ic} /></div>
</div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Location</label><input type="text" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} className={ic} placeholder="e.g. Warehouse, Field" /></div>
<div className="flex justify-end gap-3 pt-2"><Button type="button" variant="secondary" onClick={onClose}>Cancel</Button><Button type="submit" loading={submitting}>Add Asset</Button></div>
</form></FormModal>);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
import { api } from '@/lib/api';
import { useToast } from '@/components/ui/toast';
export default function ChangePasswordPage() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const { user, setUser } = useAuthStore();
const router = useRouter();
const { toast } = useToast();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast('Passwords do not match', 'error');
return;
}
if (newPassword.length < 8) {
toast('Password must be at least 8 characters', 'error');
return;
}
setLoading(true);
try {
await api.post('/auth/change-password', { currentPassword, newPassword });
toast('Password updated successfully', 'success');
// Refresh profile to clear mustChangePassword flag
const res = await api.get<{ data: any }>('/auth/profile');
setUser(res.data.data);
router.push('/dashboard');
} catch (err: any) {
toast(err.response?.data?.error || err.response?.data?.message || 'Failed to change password', 'error');
} finally {
setLoading(false);
}
}
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="w-full max-w-md bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-8">
<div className="mb-6">
<h1 className="text-xl font-semibold text-surface-900 dark:text-surface-200">Change Your Password</h1>
<p className="text-sm text-surface-500 dark:text-surface-400 mt-1">
{user?.mustChangePassword
? 'For security, please set a new password before continuing.'
: 'Update your account password.'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Current Password</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Enter current password"
/>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Min. 8 characters"
/>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Re-enter new password"
/>
</div>
<button
type="submit"
disabled={loading || !currentPassword || !newPassword || !confirmPassword}
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 text-sm font-medium"
>
{loading ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,320 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { api } from '@/lib/api';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Modal } from '@/components/ui/modal';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/components/ui/toast';
import { PaymentModal } from '@/components/modals/payment-modal';
import { CreateSubscriptionModal } from '@/components/modals/create-subscription-modal';
import { CreateTicketModal } from '@/components/modals/create-ticket-modal';
import { TicketDetailModal } from '@/components/modals/ticket-detail-modal';
type Tab = 'profile' | 'subscriptions' | 'tickets' | 'invoices' | 'payments';
export default function ClientDetailPage() {
const params = useParams();
const { toast } = useToast();
const [client, setClient] = useState<any>(null);
const [payments, setPayments] = useState<any[]>([]);
const [invoices, setInvoices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>('profile');
// Modals
const [showPayment, setShowPayment] = useState(false);
const [payInvoice, setPayInvoice] = useState<any>(null);
const [showSubscription, setShowSubscription] = useState(false);
const [suspendTarget, setSuspendTarget] = useState<any>(null);
const [cancelTarget, setCancelTarget] = useState<any>(null);
const [showCreateTicket, setShowCreateTicket] = useState(false);
const [detailTicketId, setDetailTicketId] = useState<string | null>(null);
const loadData = useCallback(() => {
Promise.all([
api.get(`/clients/${params.id}`).then((r) => setClient(r.data.data)),
api.get(`/payments?clientId=${params.id}`).then((r) => setPayments(r.data.data)).catch(() => {}),
api.get(`/invoices?clientId=${params.id}`).then((r) => {
const d = r.data.data;
setInvoices(Array.isArray(d) ? d : d.items);
}).catch(() => {}),
]).finally(() => setLoading(false));
}, [params.id]);
useEffect(() => { loadData(); }, [loadData]);
async function handleSubAction(subId: string, action: string) {
try {
await api.patch(`/subscriptions/${subId}/${action}`);
toast(`Subscription ${action}d`, 'success');
setSuspendTarget(null);
setCancelTarget(null);
loadData();
} catch (err: any) { toast(err.response?.data?.error || `Failed to ${action}`, 'error'); }
}
if (loading) {
return <div className="space-y-4"><Skeleton className="h-8 w-48" /><Skeleton className="h-32 w-full" /><Skeleton className="h-64 w-full" /></div>;
}
if (!client) {
return <EmptyState title="Client not found" description="The client you're looking for doesn't exist." />;
}
const hasActiveSub = client.subscriptions?.some((s: any) => s.status === 'active' || s.status === 'pending');
const unpaidInvoices = invoices.filter((i: any) => i.status === 'sent' || i.status === 'partial');
const tabs: { key: Tab; label: string; count?: number }[] = [
{ key: 'profile', label: 'Profile' },
{ key: 'subscriptions', label: 'Subscriptions', count: client.subscriptions?.length },
{ key: 'tickets', label: 'Tickets', count: client.tickets?.length },
{ key: 'invoices', label: 'Invoices', count: invoices.length },
{ key: 'payments', label: 'Payments', count: payments.length },
];
return (
<div>
<Link href="/dashboard/clients" className="inline-flex items-center gap-1.5 text-sm text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 transition-colors mb-4">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M9 3L5 7l4 4" /></svg>
Back to Clients
</Link>
{/* Header */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center text-primary-700 dark:text-primary-400 font-bold text-lg">
{client.firstName[0]}{client.lastName[0]}
</div>
<div>
<h1 className="text-xl font-bold text-surface-900 dark:text-surface-100">{client.firstName} {client.lastName}</h1>
<p className="text-sm text-surface-400 font-mono">{client.accountNumber}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge label={client.status} variant={statusBadgeVariant(client.status)} />
<Button size="sm" variant="secondary" onClick={() => setShowPayment(true)}>Record Payment</Button>
{!hasActiveSub && (
<Button size="sm" onClick={() => setShowSubscription(true)}>New Subscription</Button>
)}
</div>
</div>
</div>
{/* Tabs */}
<nav aria-label="Client details" className="flex gap-1 border-b border-surface-200 dark:border-surface-700 mt-6 mb-6">
{tabs.map((t) => (
<button key={t.key} onClick={() => setTab(t.key)} aria-current={tab === t.key ? 'page' : undefined}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors duration-200 cursor-pointer ${
tab === t.key ? 'border-primary-600 text-primary-700' : 'border-transparent text-surface-500 dark:text-surface-400 hover:text-surface-700 hover:border-surface-300'
}`}>
{t.label}
{t.count !== undefined && t.count > 0 && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-surface-100 dark:bg-surface-700 text-surface-500 dark:text-surface-400 rounded-full">{t.count}</span>
)}
</button>
))}
</nav>
{/* Tab content */}
{tab === 'profile' && <ProfileTab client={client} />}
{tab === 'subscriptions' && (
<SubscriptionsTab
subscriptions={client.subscriptions || []}
onSuspend={(s: any) => setSuspendTarget(s)}
onCancel={(s: any) => setCancelTarget(s)}
onReactivate={(subId: string) => handleSubAction(subId, 'reactivate')}
onCreateNew={() => setShowSubscription(true)}
hasActive={hasActiveSub}
/>
)}
{tab === 'tickets' && <TicketsTab tickets={client.tickets || []} onOpenTicket={setDetailTicketId} onCreateTicket={() => setShowCreateTicket(true)} />}
{tab === 'invoices' && <InvoicesTab invoices={invoices} onPay={(inv: any) => setPayInvoice(inv)} />}
{tab === 'payments' && <PaymentsTab payments={payments} />}
{/* Modals */}
<PaymentModal open={showPayment} onClose={() => setShowPayment(false)} onSuccess={loadData}
prefillClientId={client.id} prefillClientName={`${client.firstName} ${client.lastName}`} />
{payInvoice && (
<PaymentModal open={!!payInvoice} onClose={() => setPayInvoice(null)} onSuccess={loadData}
prefillClientId={client.id} prefillClientName={`${client.firstName} ${client.lastName}`} prefillInvoice={payInvoice} />
)}
<CreateSubscriptionModal open={showSubscription} onClose={() => setShowSubscription(false)} onSuccess={loadData}
clientId={client.id} clientName={`${client.firstName} ${client.lastName}`} />
<Modal open={!!suspendTarget} onClose={() => setSuspendTarget(null)} title="Suspend Subscription"
description={`Suspend ${client.firstName}'s subscription? They will lose service access.`}
variant="danger" confirmLabel="Suspend" onConfirm={() => handleSubAction(suspendTarget.id, 'suspend')} />
<Modal open={!!cancelTarget} onClose={() => setCancelTarget(null)} title="Cancel Subscription"
description={`Cancel ${client.firstName}'s subscription? This cannot be undone.`}
variant="danger" confirmLabel="Cancel Subscription" onConfirm={() => handleSubAction(cancelTarget.id, 'cancel')} />
<CreateTicketModal open={showCreateTicket} onClose={() => setShowCreateTicket(false)} onSuccess={loadData}
prefillClientId={client.id} prefillClientName={`${client.firstName} ${client.lastName}`} />
<TicketDetailModal open={!!detailTicketId} onClose={() => setDetailTicketId(null)} onUpdated={loadData} ticketId={detailTicketId} />
</div>
);
}
function ProfileTab({ client }: { client: any }) {
const fields = [
{ label: 'Email', value: client.email },
{ label: 'Phone', value: client.phone },
{ label: 'Address', value: client.address },
{ label: 'Area', value: client.area?.name },
{ label: 'Joined', value: new Date(client.createdAt).toLocaleDateString() },
];
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-4">Client Information</h2>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{fields.map((f) => (
<div key={f.label}>
<dt className="text-xs font-medium text-surface-400 uppercase tracking-wider">{f.label}</dt>
<dd className="mt-1 text-sm text-surface-800 dark:text-surface-200">{f.value || <span className="text-surface-300 dark:text-surface-500">Not provided</span>}</dd>
</div>
))}
</dl>
</div>
);
}
function SubscriptionsTab({ subscriptions, onSuspend, onCancel, onReactivate, onCreateNew, hasActive }: any) {
if (subscriptions.length === 0) {
return <EmptyState title="No subscriptions" description="Create a subscription to assign an internet plan."
action={<Button onClick={onCreateNew}>New Subscription</Button>} />;
}
return (
<div className="space-y-3">
{subscriptions.map((sub: any) => (
<div key={sub.id} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<span className="font-medium text-surface-800 dark:text-surface-200">{sub.plan.name}</span>
<span className="ml-2 text-sm text-surface-500 dark:text-surface-400">{sub.plan.speedDown}/{sub.plan.speedUp} Mbps</span>
</div>
<Badge label={sub.type} variant={statusBadgeVariant(sub.type)} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-surface-700 dark:text-surface-300">PHP {Number(sub.plan.price).toLocaleString()}</span>
<Badge label={sub.status} variant={statusBadgeVariant(sub.status)} />
{sub.status === 'active' && (
<Button size="sm" variant="ghost" onClick={() => onSuspend(sub)}>Suspend</Button>
)}
{sub.status === 'suspended' && (
<Button size="sm" variant="ghost" onClick={() => onReactivate(sub.id)}>Reactivate</Button>
)}
{['pending', 'active', 'suspended'].includes(sub.status) && (
<Button size="sm" variant="ghost" onClick={() => onCancel(sub)}>Cancel</Button>
)}
</div>
</div>
))}
</div>
);
}
function TicketsTab({ tickets, onOpenTicket, onCreateTicket }: { tickets: any[]; onOpenTicket: (id: string) => void; onCreateTicket: () => void }) {
if (tickets.length === 0) {
return <EmptyState title="No tickets" description="No tickets for this client."
action={<Button onClick={onCreateTicket}>Create Ticket</Button>} />;
}
return (
<div>
<div className="flex justify-end mb-3">
<Button size="sm" onClick={onCreateTicket}>New Ticket</Button>
</div>
<div className="space-y-3">
{tickets.map((t: any) => (
<button key={t.id} onClick={() => onOpenTicket(t.id)}
className="w-full text-left bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-4 flex items-center justify-between hover:border-primary-300 hover:shadow-sm transition-all duration-200 cursor-pointer">
<div className="flex items-center gap-3">
<span className="font-medium text-surface-800 dark:text-surface-200">{t.title}</span>
<Badge label={t.type} />
</div>
<div className="flex items-center gap-3">
{t.assignee && <span className="text-xs text-surface-400">{t.assignee.firstName} {t.assignee.lastName}</span>}
<Badge label={t.status} variant={statusBadgeVariant(t.status)} />
</div>
</button>
))}
</div>
</div>
);
}
function InvoicesTab({ invoices, onPay }: { invoices: any[]; onPay: (inv: any) => void }) {
if (invoices.length === 0) return <EmptyState title="No invoices" description="No invoices for this client." />;
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 overflow-hidden">
<table className="min-w-full divide-y divide-surface-200 dark:divide-surface-700">
<thead className="bg-surface-50/50 dark:bg-surface-900/50">
<tr>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Invoice #</th>
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Amount</th>
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Balance</th>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Due Date</th>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Status</th>
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-100 dark:divide-surface-700/50">
{invoices.map((inv: any) => (
<tr key={inv.id} className="hover:bg-surface-50/50 dark:hover:bg-surface-700/30 transition-colors">
<td className="px-5 py-3 text-sm font-mono text-surface-700 dark:text-surface-300">{inv.number}</td>
<td className="px-5 py-3 text-sm text-right text-surface-700 dark:text-surface-300">PHP {Number(inv.amount).toLocaleString()}</td>
<td className="px-5 py-3 text-sm text-right font-medium text-surface-900 dark:text-surface-100">PHP {Number(inv.balance).toLocaleString()}</td>
<td className="px-5 py-3 text-sm text-surface-500 dark:text-surface-400">{new Date(inv.dueDate).toLocaleDateString()}</td>
<td className="px-5 py-3"><Badge label={inv.status} variant={statusBadgeVariant(inv.status)} /></td>
<td className="px-5 py-3 text-right">
{(inv.status === 'sent' || inv.status === 'partial') && (
<Button size="sm" onClick={() => onPay(inv)}>Pay</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function PaymentsTab({ payments }: { payments: any[] }) {
if (payments.length === 0) return <EmptyState title="No payments" description="No payments recorded." />;
const methodLabels: Record<string, string> = { gcash: 'GCash', maya: 'Maya', cash: 'Cash', bank_transfer: 'Bank Transfer' };
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 overflow-hidden">
<table className="min-w-full divide-y divide-surface-200 dark:divide-surface-700">
<thead className="bg-surface-50/50 dark:bg-surface-900/50">
<tr>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Date</th>
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Amount</th>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Method</th>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Invoice</th>
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Collected By</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-100 dark:divide-surface-700/50">
{payments.map((p: any) => (
<tr key={p.id} className="hover:bg-surface-50/50 dark:hover:bg-surface-700/30 transition-colors">
<td className="px-5 py-3 text-sm text-surface-500 dark:text-surface-400">{new Date(p.createdAt).toLocaleDateString()}</td>
<td className="px-5 py-3 text-sm text-right font-medium text-surface-900 dark:text-surface-100">PHP {Number(p.amount).toLocaleString()}</td>
<td className="px-5 py-3 text-sm text-surface-600 dark:text-surface-300">{methodLabels[p.method] || p.method}</td>
<td className="px-5 py-3 text-sm font-mono text-surface-500 dark:text-surface-400">{p.invoice?.number || '—'}</td>
<td className="px-5 py-3 text-sm text-surface-500 dark:text-surface-400">{p.collectedBy ? `${p.collectedBy.firstName} ${p.collectedBy.lastName}` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { DataTable } from '@/components/ui/data-table';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ActionIcon } from '@/components/ui/action-icon';
import { CreateClientModal } from '@/components/modals/create-client-modal';
interface Client {
id: string;
accountNumber: string;
firstName: string;
lastName: string;
email: string | null;
phone: string | null;
status: string;
area: { id: string; name: string } | null;
_count: { subscriptions: number; tickets: number };
}
export default function ClientsPage() {
const router = useRouter();
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [filters, setFilters] = useState<Record<string, string>>({});
const loadClients = useCallback(async () => {
try {
const res = await api.get('/clients?limit=100');
const d = res.data.data;
setClients(Array.isArray(d) ? d : d.items);
} finally { setLoading(false); }
}, []);
useEffect(() => { loadClients(); }, [loadClients]);
const filtered = clients.filter((c) => {
if (search && !`${c.firstName} ${c.lastName} ${c.accountNumber} ${c.phone || ''} ${c.email || ''}`.toLowerCase().includes(search.toLowerCase())) return false;
if (filters.status && c.status !== filters.status) return false;
return true;
});
return (
<div>
<PageHeader title="Clients" description="Manage subscriber accounts"
action={<Button onClick={() => setShowCreate(true)}>New Client</Button>} />
<div className="mt-5">
<DataTable
data={filtered}
loading={loading}
keyExtractor={(c) => c.id}
emptyTitle="No clients found"
emptyDescription="Create your first client to get started."
searchPlaceholder="Search by name, account #, phone, email..."
searchValue={search}
onSearchChange={setSearch}
quickFilters={[
{ key: 'status', label: 'Status', options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Suspended', value: 'suspended' },
]},
]}
activeFilters={filters}
onFilterChange={(k, v) => setFilters((f) => ({ ...f, [k]: v }))}
onRowClick={(c) => router.push(`/dashboard/clients/${c.id}`)}
columns={[
{ key: 'accountNumber', label: 'Account', sortable: true, render: (c) => <span className="font-mono text-surface-700 dark:text-surface-300">{c.accountNumber}</span> },
{ key: 'firstName', label: 'Name', sortable: true, render: (c) => (
<Link href={`/dashboard/clients/${c.id}`} className="font-medium text-surface-800 dark:text-surface-200 hover:text-primary-600 transition-colors">{c.firstName} {c.lastName}</Link>
)},
{ key: 'area', label: 'Area', render: (c) => <span className="text-surface-500 dark:text-surface-400">{c.area?.name || '—'}</span> },
{ key: 'phone', label: 'Contact', render: (c) => <span className="text-surface-500 dark:text-surface-400">{c.phone || c.email || '—'}</span> },
{ key: 'status', label: 'Status', sortable: true, render: (c) => <Badge label={c.status} variant={statusBadgeVariant(c.status)} /> },
{ key: 'actions', label: '', align: 'right', render: (c) => (
<Link href={`/dashboard/clients/${c.id}`} onClick={(e) => e.stopPropagation()}>
<ActionIcon icon="eye" variant="ghost" label="View details" />
</Link>
)},
]}
/>
</div>
<CreateClientModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={loadClients} />
</div>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { DataTable } from '@/components/ui/data-table';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { FormModal } from '@/components/ui/form-modal';
import { useToast } from '@/components/ui/toast';
export default function EmployeesPage() {
const { toast } = useToast();
const [employees, setEmployees] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [editTarget, setEditTarget] = useState<any>(null);
const load = useCallback(async () => {
try { const r = await api.get('/employees'); setEmployees(r.data.data); }
catch { toast('Failed to load employees', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { load(); }, [load]);
const filtered = search ? employees.filter((e: any) => `${e.firstName} ${e.lastName} ${e.position}`.toLowerCase().includes(search.toLowerCase())) : employees;
return (
<div>
<PageHeader title="Employees" description="Manage your team members" action={<Button onClick={() => setShowCreate(true)}>Add Employee</Button>} />
<div className="mt-5">
<DataTable data={filtered} loading={loading} keyExtractor={(e: any) => e.id}
emptyTitle="No employees" emptyDescription="Add your first team member."
searchPlaceholder="Search by name or position..." searchValue={search} onSearchChange={setSearch}
onRowClick={(e: any) => setEditTarget(e)}
columns={[
{ key: 'employeeNo', label: 'ID', sortable: true, render: (e: any) => <span className="font-mono text-surface-700 dark:text-surface-300">{e.employeeNo}</span> },
{ key: 'firstName', label: 'Name', sortable: true, render: (e: any) => <span className="font-medium text-surface-800 dark:text-surface-200">{e.firstName} {e.lastName}</span> },
{ key: 'position', label: 'Position', sortable: true, render: (e: any) => <span className="text-surface-600 dark:text-surface-400">{e.position}</span> },
{ key: 'department', label: 'Department', render: (e: any) => <span className="text-surface-500 dark:text-surface-400">{e.department || '—'}</span> },
{ key: 'status', label: 'Status', sortable: true, render: (e: any) => <Badge label={e.status} variant={statusBadgeVariant(e.status)} /> },
{ key: 'salary', label: 'Salary', align: 'right' as const, render: (e: any) => e.salary ? <span className="text-surface-700 dark:text-surface-300">PHP {Number(e.salary).toLocaleString()}</span> : <span className="text-surface-300"></span> },
]}
/>
</div>
<CreateEmployeeModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={load} />
{editTarget && <EditEmployeeModal employee={editTarget} onClose={() => setEditTarget(null)} onSuccess={() => { setEditTarget(null); load(); }} />}
</div>
);
}
// ─── Create Employee Modal ──────────────────────────────────
function CreateEmployeeModal({ open, onClose, onSuccess }: { open: boolean; onClose: () => void; onSuccess: () => void }) {
const { toast } = useToast();
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', phone: '', position: '', department: '', salary: 0, userId: '' });
const [users, setUsers] = useState<any[]>([]);
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({ firstName: '', lastName: '', email: '', phone: '', position: '', department: '', salary: 0, userId: '' });
api.get('/users').then((r) => setUsers(r.data.data)).catch(() => {});
}
}, [open]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try {
await api.post('/employees', { ...form, email: form.email || undefined, phone: form.phone || undefined, department: form.department || undefined, salary: form.salary || undefined, userId: form.userId || undefined });
toast('Employee added', 'success'); onSuccess(); onClose();
} catch (err: any) { toast(err.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="Add Employee">
<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">First Name</label><input type="text" required value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} className={ic} /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Last Name</label><input type="text" required value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} className={ic} /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Position</label><input type="text" required value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} className={ic} placeholder="e.g. Technician" /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Department</label><input type="text" value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} className={ic} placeholder="e.g. Operations" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Email</label><input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={ic} /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Salary (PHP)</label><input type="number" min={0} value={form.salary || ''} onChange={(e) => setForm({ ...form, salary: parseFloat(e.target.value) || 0 })} className={ic} /></div>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Link to User Account <span className="text-surface-400 font-normal">(optional)</span></label>
<select value={form.userId} onChange={(e) => setForm({ ...form, userId: e.target.value })} className={ic}>
<option value="">No user account linked</option>
{users.map((u: any) => <option key={u.id} value={u.id}>{u.firstName} {u.lastName} ({u.email}) {u.roles.join(', ')}</option>)}
</select>
<p className="text-xs text-surface-400 mt-1">Links this employee record to a system user for login access</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}>Add Employee</Button></div>
</form>
</FormModal>
);
}
// ─── Edit Employee Modal ────────────────────────────────────
function EditEmployeeModal({ employee, onClose, onSuccess }: { employee: any; onClose: () => void; onSuccess: () => void }) {
const { toast } = useToast();
const [form, setForm] = useState({
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email || '',
phone: employee.phone || '',
position: employee.position,
department: employee.department || '',
salary: employee.salary ? Number(employee.salary) : 0,
status: employee.status,
notes: employee.notes || '',
userId: employee.userId || '',
});
const [users, setUsers] = useState<any[]>([]);
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(() => {
api.get('/users').then((r) => setUsers(r.data.data)).catch(() => {});
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try {
await api.patch(`/employees/${employee.id}`, {
firstName: form.firstName,
lastName: form.lastName,
email: form.email || undefined,
phone: form.phone || undefined,
position: form.position,
department: form.department || undefined,
salary: form.salary || undefined,
status: form.status,
notes: form.notes || undefined,
userId: form.userId || undefined,
});
toast('Employee updated', 'success'); onSuccess();
} catch (err: any) { toast(err.response?.data?.error || 'Failed to update', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={true} onClose={onClose} title={`Edit — ${employee.firstName} ${employee.lastName}`}>
<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">First Name</label><input type="text" required value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} className={ic} /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Last Name</label><input type="text" required value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} className={ic} /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Position</label><input type="text" required value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} className={ic} placeholder="e.g. Technician" /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Department</label><input type="text" value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} className={ic} placeholder="e.g. Operations" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Email</label><input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={ic} /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Phone</label><input type="text" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={ic} /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Salary (PHP)</label><input type="number" min={0} value={form.salary || ''} onChange={(e) => setForm({ ...form, salary: parseFloat(e.target.value) || 0 })} className={ic} /></div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Status</label>
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })} className={ic}>
<option value="active">Active</option>
<option value="on_leave">On Leave</option>
<option value="terminated">Terminated</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Link to User Account <span className="text-surface-400 font-normal">(optional)</span></label>
<select value={form.userId} onChange={(e) => setForm({ ...form, userId: e.target.value })} className={ic}>
<option value="">No user account linked</option>
{users.map((u: any) => <option key={u.id} value={u.id}>{u.firstName} {u.lastName} ({u.email}) {u.roles.join(', ')}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Notes</label>
<textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} className={ic} rows={3} placeholder="Internal notes about this employee..." />
</div>
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting}>Save Changes</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { DataTable } from '@/components/ui/data-table';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ActionIcon } from '@/components/ui/action-icon';
import { Modal } from '@/components/ui/modal';
import { FormModal } from '@/components/ui/form-modal';
import { useToast } from '@/components/ui/toast';
import { useAuthStore } from '@/stores/auth.store';
import { CreateExpenseModal } from '@/components/modals/create-expense-modal';
const CATEGORIES = ['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other'];
export default function ExpensesPage() {
const [tab, setTab] = useState<'expenses' | 'recurring'>('expenses');
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0">
<PageHeader title="Expenses" description="Track, approve, and manage business expenses" />
<div className="mt-4 flex gap-1 border-b border-surface-200 dark:border-surface-700 mb-5">
{([['expenses', 'Expenses'], ['recurring', 'Recurring Setup']] as const).map(([key, label]) => (
<button key={key} onClick={() => setTab(key as any)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors cursor-pointer ${
tab === key ? 'border-primary-600 text-primary-700' : 'border-transparent text-surface-500 hover:text-surface-700 dark:text-surface-400'
}`}>{label}</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{tab === 'expenses' && <ExpensesTab />}
{tab === 'recurring' && <RecurringTab />}
</div>
</div>
);
}
function ExpensesTab() {
const { toast } = useToast();
const currentUser = useAuthStore((s) => s.user);
const [expenses, setExpenses] = useState<any[]>([]);
const [summary, setSummary] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [detailTarget, setDetailTarget] = useState<any>(null);
const [approveTarget, setApproveTarget] = useState<any>(null);
const [rejectTarget, setRejectTarget] = useState<any>(null);
const load = useCallback(async () => {
try {
const [r, s] = await Promise.all([api.get('/expenses'), api.get('/expenses/summary')]);
setExpenses(r.data.data); setSummary(s.data.data);
} catch { toast('Failed to load', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { load(); }, [load]);
async function handleApprove() {
try { await api.patch(`/expenses/${approveTarget.id}/approve`); toast('Expense approved', 'success'); load(); }
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setApproveTarget(null); }
}
async function handleReject() {
try { await api.patch(`/expenses/${rejectTarget.id}/reject`); toast('Expense rejected', 'success'); load(); }
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setRejectTarget(null); }
}
const filtered = search ? expenses.filter((e: any) => e.description.toLowerCase().includes(search.toLowerCase()) || e.category.toLowerCase().includes(search.toLowerCase())) : expenses;
return (
<div>
<PageHeader title="Expenses" description="Track and approve business expenses" action={<Button onClick={() => setShowCreate(true)}>New Expense</Button>} />
{summary && (
<div className="mt-5 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Pending Approval</p>
<p className="mt-2 text-2xl font-bold text-amber-600">PHP {summary.pending.total.toLocaleString()}</p>
<p className="text-xs text-surface-400">{summary.pending.count} expenses</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Total Approved</p>
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-100">PHP {summary.approved.total.toLocaleString()}</p>
<p className="text-xs text-surface-400">{summary.approved.count} expenses</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Top Category</p>
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-100">{summary.byCategory[0]?.category || '—'}</p>
<p className="text-xs text-surface-400">{summary.byCategory[0] ? `PHP ${summary.byCategory[0].total.toLocaleString()}` : 'No data'}</p>
</div>
</div>
)}
<div className="mt-5">
<DataTable data={filtered} loading={loading} keyExtractor={(e: any) => e.id}
emptyTitle="No expenses" searchPlaceholder="Search by description or category..."
searchValue={search} onSearchChange={setSearch}
onRowClick={(e: any) => setDetailTarget(e)}
columns={[
{ key: 'expenseDate', label: 'Date', sortable: true, render: (e: any) => <span className="text-surface-500">{new Date(e.expenseDate).toLocaleDateString()}</span> },
{ key: 'category', label: 'Category', sortable: true, render: (e: any) => <Badge label={e.category} /> },
{ key: 'description', label: 'Description', render: (e: any) => <span className="text-surface-800 dark:text-surface-200">{e.description}</span> },
{ key: 'amount', label: 'Amount', align: 'right' as const, sortable: true, render: (e: any) => <span className="font-medium text-surface-900 dark:text-surface-100">PHP {Number(e.amount).toLocaleString()}</span> },
{ key: 'status', label: 'Status', sortable: true, render: (e: any) => <Badge label={e.status} variant={statusBadgeVariant(e.status)} /> },
{ key: 'actions', label: '', align: 'right' as const, render: (e: any) => e.status === 'pending' && e.createdById !== currentUser?.id ? (
<div className="flex gap-1 justify-end" onClick={(ev) => ev.stopPropagation()}>
<ActionIcon icon="check" variant="primary" label="Approve" onClick={() => setApproveTarget(e)} />
<ActionIcon icon="x" variant="danger" label="Reject" onClick={() => setRejectTarget(e)} />
</div>
) : null },
]}
/>
</div>
<CreateExpenseModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={load} />
<Modal open={!!approveTarget} onClose={() => setApproveTarget(null)} title="Approve Expense" description={`Approve PHP ${Number(approveTarget?.amount || 0).toLocaleString()} for "${approveTarget?.description}"?`} confirmLabel="Approve" onConfirm={handleApprove} />
<Modal open={!!rejectTarget} onClose={() => setRejectTarget(null)} title="Reject Expense" description={`Reject "${rejectTarget?.description}"?`} variant="danger" confirmLabel="Reject" onConfirm={handleReject} />
{/* Expense Detail Modal */}
<Modal open={!!detailTarget} onClose={() => setDetailTarget(null)}
title="Expense Details" description={detailTarget?.description}>
{detailTarget && (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div><span className="text-surface-500 dark:text-surface-400">Date:</span> <span className="text-surface-700 dark:text-surface-300">{new Date(detailTarget.expenseDate).toLocaleDateString()}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Category:</span> <Badge label={detailTarget.category} /></div>
<div><span className="text-surface-500 dark:text-surface-400">Amount:</span> <span className="font-bold text-surface-900 dark:text-surface-100">PHP {Number(detailTarget.amount).toLocaleString()}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Status:</span> <Badge label={detailTarget.status} variant={statusBadgeVariant(detailTarget.status)} /></div>
{detailTarget.notes && <div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Notes:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.notes}</span></div>}
{detailTarget.approvedBy && <div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Approved by:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.approvedBy.firstName} {detailTarget.approvedBy.lastName}</span></div>}
</div>
<div className="flex justify-end pt-2 border-t border-surface-200">
<Button size="sm" variant="ghost" onClick={() => setDetailTarget(null)}>Close</Button>
</div>
</div>
)}
</Modal>
</div>
);
}
// ─── Recurring Expenses Tab ──────────────────────────────────
function RecurringTab() {
const { toast } = useToast();
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
try { const r = await api.get('/expenses/recurring'); setItems(r.data.data); }
catch { toast('Failed to load', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { load(); }, [load]);
async function toggle(id: string) {
try { await api.patch(`/expenses/recurring/${id}/toggle`); load(); }
catch { toast('Failed', 'error'); }
}
async function remove(id: string) {
try { await api.delete(`/expenses/recurring/${id}`); toast('Deleted', 'success'); load(); }
catch { toast('Failed', 'error'); }
}
const freqLabels: Record<string, string> = { monthly: 'Monthly', quarterly: 'Quarterly', yearly: 'Yearly' };
return (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-surface-500">Recurring expenses auto-generate pending expenses on schedule. They still require approval before creating an accounting entry.</p>
<Button onClick={() => setShowCreate(true)}>Add Recurring</Button>
</div>
{loading ? <p className="text-surface-400">Loading...</p> : items.length === 0 ? (
<div className="text-center py-12 text-surface-400">No recurring expenses set up yet.</div>
) : (
<div className="space-y-3">
{items.map((item: any) => (
<div key={item.id} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-5 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium text-surface-900 dark:text-surface-100">{item.description}</h3>
<Badge label={item.category} />
<Badge label={freqLabels[item.frequency] || item.frequency} variant="info" />
{!item.isActive && <Badge label="Paused" variant="warning" />}
</div>
<p className="text-xs text-surface-400 mt-1">Next run: {new Date(item.nextRunDate).toLocaleDateString()}</p>
</div>
<div className="flex items-center gap-3">
<span className="font-bold text-surface-900 dark:text-surface-100">PHP {Number(item.amount).toLocaleString()}</span>
<Button size="sm" variant="ghost" onClick={() => toggle(item.id)}>{item.isActive ? 'Pause' : 'Resume'}</Button>
<Button size="sm" variant="ghost" onClick={() => remove(item.id)}>Delete</Button>
</div>
</div>
))}
</div>
)}
<FormModal open={showCreate} onClose={() => setShowCreate(false)} title="Add Recurring Expense" description="Auto-generates a pending expense on schedule.">
<RecurringForm onSuccess={() => { setShowCreate(false); load(); }} onClose={() => setShowCreate(false)} />
</FormModal>
</div>
);
}
function RecurringForm({ onSuccess, onClose }: { onSuccess: () => void; onClose: () => void }) {
const { toast } = useToast();
const [form, setForm] = useState({ category: 'utilities', description: '', amount: 0, frequency: 'monthly' });
const [submitting, setSubmitting] = useState(false);
const ic = '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-100 bg-white dark:bg-surface-800 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try { await api.post('/expenses/recurring', form); toast('Recurring expense created', 'success'); onSuccess(); }
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<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">Frequency</label>
<select value={form.frequency} onChange={(e) => setForm({ ...form, frequency: e.target.value })} className={ic}>
<option value="monthly">Monthly</option><option value="quarterly">Quarterly</option><option value="yearly">Yearly</option>
</select></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="e.g. Electricity bill" /></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 className="flex justify-end gap-3 pt-2"><Button type="button" variant="secondary" onClick={onClose}>Cancel</Button><Button type="submit" loading={submitting}>Create Recurring</Button></div>
</form>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { DataTable } from '@/components/ui/data-table';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Modal } from '@/components/ui/modal';
import { useToast } from '@/components/ui/toast';
import { PaymentModal } from '@/components/modals/payment-modal';
import { ActionIcon } from '@/components/ui/action-icon';
import { useRouter } from 'next/navigation';
interface Invoice {
id: string;
number: string;
amount: string;
balance: string;
status: string;
dueDate: string;
client: { id: string; firstName: string; lastName: string; accountNumber: string };
}
export default function InvoicesPage() {
const { toast } = useToast();
const router = useRouter();
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<Record<string, string>>({});
const [voidTarget, setVoidTarget] = useState<Invoice | null>(null);
const [voiding, setVoiding] = useState(false);
const [payTarget, setPayTarget] = useState<Invoice | null>(null);
const [detailTarget, setDetailTarget] = useState<Invoice | null>(null);
const loadInvoices = useCallback(async () => {
try {
const res = await api.get('/invoices');
const d = res.data.data;
setInvoices(Array.isArray(d) ? d : d.items);
} catch { toast('Failed to load invoices', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { loadInvoices(); }, [loadInvoices]);
async function handleVoid() {
if (!voidTarget) return;
setVoiding(true);
try {
await api.patch(`/invoices/${voidTarget.id}/void`);
toast(`Invoice ${voidTarget.number} voided`, 'success');
setVoidTarget(null);
loadInvoices();
} catch (err: any) { toast(err.response?.data?.error || 'Failed to void', 'error'); }
finally { setVoiding(false); }
}
const filtered = invoices.filter((i) => {
if (search && !`${i.number} ${i.client.firstName} ${i.client.lastName}`.toLowerCase().includes(search.toLowerCase())) return false;
if (filters.status && i.status !== filters.status) return false;
return true;
});
return (
<div>
<PageHeader title="Invoices" description="Track billing and payment status" />
<div className="mt-5">
<DataTable data={filtered} loading={loading} keyExtractor={(i) => i.id}
emptyTitle="No invoices found" searchPlaceholder="Search by invoice # or client..."
searchValue={search} onSearchChange={setSearch}
onRowClick={(i) => setDetailTarget(i)}
quickFilters={[
{ key: 'status', label: 'Status', options: [
{ label: 'Sent', value: 'sent' }, { label: 'Partial', value: 'partial' },
{ label: 'Paid', value: 'paid' }, { label: 'Overdue', value: 'overdue' }, { label: 'Void', value: 'void' },
]},
]}
activeFilters={filters}
onFilterChange={(k, v) => setFilters((f) => ({ ...f, [k]: v }))}
columns={[
{ key: 'number', label: 'Invoice #', sortable: true, render: (i) => <span className="font-mono text-surface-700 dark:text-surface-300">{i.number}</span> },
{ key: 'client', label: 'Client', sortable: true, render: (i) => <span className="text-surface-800 dark:text-surface-200">{i.client.firstName} {i.client.lastName}</span> },
{ key: 'amount', label: 'Amount', align: 'right', sortable: true, render: (i) => <span className="text-surface-700 dark:text-surface-300">PHP {Number(i.amount).toLocaleString()}</span> },
{ key: 'balance', label: 'Balance', align: 'right', sortable: true, render: (i) => <span className="font-medium text-surface-900 dark:text-surface-200">PHP {Number(i.balance).toLocaleString()}</span> },
{ key: 'dueDate', label: 'Due Date', sortable: true, render: (i) => <span className="text-surface-500 dark:text-surface-400">{new Date(i.dueDate).toLocaleDateString()}</span> },
{ key: 'status', label: 'Status', sortable: true, render: (i) => <Badge label={i.status} variant={statusBadgeVariant(i.status)} /> },
{ key: 'actions', label: '', align: 'right', render: (i) => (
<div className="flex gap-1 justify-end" onClick={(e) => e.stopPropagation()}>
{(i.status === 'sent' || i.status === 'partial' || i.status === 'overdue') && (
<ActionIcon icon="credit-card" variant="primary" label="Pay" onClick={() => setPayTarget(i)} />
)}
{i.status !== 'paid' && i.status !== 'void' && (
<ActionIcon icon="x-circle" variant="danger" label="Void" onClick={() => setVoidTarget(i)} />
)}
</div>
)},
]}
/>
</div>
<Modal open={!!voidTarget} onClose={() => setVoidTarget(null)} title="Void Invoice"
description={`Void invoice ${voidTarget?.number}? This cannot be undone.`}
variant="danger" confirmLabel="Void Invoice" onConfirm={handleVoid} loading={voiding} />
{payTarget && (
<PaymentModal
open={!!payTarget}
onClose={() => setPayTarget(null)}
onSuccess={loadInvoices}
prefillClientId={payTarget.client.id}
prefillClientName={`${payTarget.client.firstName} ${payTarget.client.lastName}`}
prefillInvoice={payTarget}
/>
)}
{/* Invoice Detail Modal */}
<Modal
open={!!detailTarget}
onClose={() => setDetailTarget(null)}
title={`Invoice ${detailTarget?.number || ''}`}
description={`Status: ${detailTarget?.status || ''}`}
>
{detailTarget && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-surface-500 dark:text-surface-400">Client:</span> <span className="font-medium text-surface-800 dark:text-surface-200">{detailTarget.client.firstName} {detailTarget.client.lastName}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Account:</span> <span className="font-mono text-surface-700 dark:text-surface-300">{detailTarget.client.accountNumber}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Amount:</span> <span className="font-medium text-surface-900 dark:text-surface-200">PHP {Number(detailTarget.amount).toLocaleString()}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Balance:</span> <span className="font-bold text-surface-900 dark:text-surface-200">PHP {Number(detailTarget.balance).toLocaleString()}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Due Date:</span> <span className="text-surface-700 dark:text-surface-300">{new Date(detailTarget.dueDate).toLocaleDateString()}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Status:</span> <Badge label={detailTarget.status} variant={statusBadgeVariant(detailTarget.status)} /></div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-surface-200 dark:border-surface-700">
{(detailTarget.status === 'sent' || detailTarget.status === 'partial' || detailTarget.status === 'overdue') && (
<Button size="sm" variant="primary" onClick={() => { setPayTarget(detailTarget); setDetailTarget(null); }}>Pay Now</Button>
)}
{detailTarget.status !== 'paid' && detailTarget.status !== 'void' && (
<Button size="sm" variant="secondary" onClick={() => { setVoidTarget(detailTarget); setDetailTarget(null); }}>Void</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setDetailTarget(null)}>Close</Button>
</div>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -0,0 +1,566 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
import { api } from '@/lib/api';
import { useToast } from '@/components/ui/toast';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Skeleton, CardSkeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { CreateClientModal } from '@/components/modals/create-client-modal';
import { PaymentModal } from '@/components/modals/payment-modal';
import { CreateExpenseModal } from '@/components/modals/create-expense-modal';
import { TransferModal } from '@/components/modals/transfer-modal';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface Kpis {
activeSubscribers: number;
totalClients: number;
todayCollections: { amount: number; count: number };
overdueAccounts: number;
newSignups: number;
pendingTickets: number;
}
interface RevenuePoint {
month: string;
revenue: number;
count: number;
}
interface Payment {
id: string;
amount: number | string;
method: string;
createdAt: string;
client?: { firstName: string; lastName: string };
}
interface Ticket {
id: string;
title: string;
priority: string;
status: string;
client?: { firstName: string; lastName: string };
}
interface Activity {
recentPayments: Payment[];
recentTickets: Ticket[];
recentClients: { id: string; firstName: string; lastName: string; accountNumber: string; createdAt: string }[];
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function greeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
}
function formatPHP(value: number): string {
return `${value.toLocaleString('en-PH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString('en-PH', { month: 'short', day: 'numeric' });
} catch {
return dateStr;
}
}
/* ------------------------------------------------------------------ */
/* Quick Action Definitions */
/* ------------------------------------------------------------------ */
interface QuickAction {
label: string;
modal: 'client' | 'payment' | 'expense' | 'transfer';
icon: React.ReactNode;
/** Module name for canCreate check — button hidden if user lacks create permission */
module?: string;
}
const QUICK_ACTIONS: QuickAction[] = [
{
label: 'Onboard Client',
modal: 'client',
module: 'clients',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="8" cy="6" r="3.5" />
<path d="M2 18c0-3.3 2.7-6 6-6s6 2.7 6 6" />
<path d="M16 8v6M13 11h6" />
</svg>
),
},
{
label: 'Record Payment',
modal: 'payment',
module: 'payments',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<rect x="2" y="4" width="16" height="12" rx="2" />
<path d="M2 9h16" />
<path d="M6 13h3" />
</svg>
),
},
{
label: 'Record Expense',
modal: 'expense',
module: 'expenses',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M3 17V5a2 2 0 012-2h10a2 2 0 012 2v12" />
<path d="M7 8h6M7 11h4" />
<path d="M3 17h14" />
</svg>
),
},
{
label: 'Fund Transfer',
modal: 'transfer',
module: 'fund_transfers',
icon: (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M4 10h12M12 6l4 4-4 4" />
<path d="M16 14H4M8 18l-4-4 4-4" />
</svg>
),
},
];
/* ------------------------------------------------------------------ */
/* KPI Card Definitions */
/* ------------------------------------------------------------------ */
interface KpiCard {
key: string;
label: string;
sublabel?: string;
getValue: (k: Kpis) => string;
getSubtitle?: (k: Kpis) => string;
borderColor: string;
iconColor: string;
icon: React.ReactNode;
alertWhen?: (k: Kpis) => boolean;
}
const KPI_CARDS: KpiCard[] = [
{
key: 'revenue',
label: 'Monthly Revenue',
sublabel: 'This Month',
getValue: (k) => formatPHP(k.todayCollections?.amount ?? 0),
borderColor: 'border-l-emerald-500',
iconColor: 'text-emerald-600 bg-emerald-50',
icon: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M3 17l4-6 3 3 4-5 3 4" />
<path d="M17 3v4h-4" />
</svg>
),
},
{
key: 'subscribers',
label: 'Active Subscribers',
getValue: (k) => (k.activeSubscribers ?? 0).toLocaleString(),
getSubtitle: (k) => `${k.totalClients ?? 0} total clients`,
borderColor: 'border-l-primary-500',
iconColor: 'text-primary-600 bg-primary-50',
icon: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="10" cy="6" r="3.5" />
<path d="M3 18c0-3.866 3.134-7 7-7s7 3.134 7 7" />
</svg>
),
},
{
key: 'collections',
label: "Today's Collections",
getValue: (k) => formatPHP(k.todayCollections?.amount ?? 0),
getSubtitle: (k) => `${k.todayCollections?.count ?? 0} payments`,
borderColor: 'border-l-emerald-500',
iconColor: 'text-emerald-600 bg-emerald-50',
icon: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="10" cy="10" r="8" />
<path d="M10 6v8M8 8h3a1.5 1.5 0 010 3H8h3.5a1.5 1.5 0 010 3H8" />
</svg>
),
},
{
key: 'overdue',
label: 'Overdue Accounts',
getValue: (k) => (k.overdueAccounts ?? 0).toLocaleString(),
borderColor: 'border-l-red-500',
iconColor: 'text-red-600 bg-red-50',
alertWhen: (k) => (k.overdueAccounts ?? 0) > 0,
icon: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="10" cy="10" r="8" />
<path d="M10 6v4l3 2" />
</svg>
),
},
{
key: 'signups',
label: 'New Signups',
sublabel: 'This Month',
getValue: (k) => (k.newSignups ?? 0).toLocaleString(),
borderColor: 'border-l-blue-500',
iconColor: 'text-blue-600 bg-blue-50',
icon: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="7" cy="6" r="3" />
<path d="M1 17c0-2.76 2.69-5 6-5s6 2.24 6 5" />
<path d="M15 3v5M12.5 5.5h5" />
</svg>
),
},
{
key: 'tickets',
label: 'Pending Tickets',
getValue: (k) => (k.pendingTickets ?? 0).toLocaleString(),
borderColor: 'border-l-amber-500',
iconColor: 'text-amber-600 bg-amber-50',
alertWhen: (k) => (k.pendingTickets ?? 0) > 0,
icon: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<rect x="2" y="4" width="16" height="12" rx="2" />
<path d="M7 4v12M2 10h5M13 10h5" />
</svg>
),
},
];
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function QuickActionsBar({ actions, onAction }: { actions: QuickAction[]; onAction: (modal: QuickAction['modal']) => void }) {
const canAccess = useAuthStore((s) => s.canAccess);
const filtered = actions.filter((a) => !a.module || canAccess(a.module, 'canCreate'));
if (filtered.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
{filtered.map((a) => (
<button
key={a.label}
type="button"
onClick={() => onAction(a.modal)}
className="inline-flex items-center gap-2 rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-4 py-2.5 text-sm font-medium text-surface-700 dark:text-surface-300 transition-all duration-200 hover:bg-surface-50 dark:hover:bg-surface-700 hover:border-surface-300 hover:shadow-sm cursor-pointer"
>
<span className="text-surface-400 dark:text-surface-500">{a.icon}</span>
{a.label}
</button>
))}
</div>
);
}
function KpiCardComponent({ card, kpis }: { card: KpiCard; kpis: Kpis | null }) {
if (!kpis) {
return <CardSkeleton />;
}
const isAlert = card.alertWhen?.(kpis) ?? false;
return (
<div
className={`bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 border-l-4 ${card.borderColor} p-5 hover:shadow-md hover:shadow-surface-100 transition-all duration-200`}
>
<div className="flex items-center justify-between">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">{card.label}</p>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${card.iconColor}`}>
{card.icon}
</div>
</div>
<p
className={`mt-3 text-2xl font-bold tracking-tight ${isAlert ? 'text-red-600' : 'text-surface-900 dark:text-surface-100'}`}
>
{card.getValue(kpis)}
</p>
{card.sublabel && (
<p className="mt-1 text-xs text-surface-400 dark:text-surface-500">{card.sublabel}</p>
)}
{card.getSubtitle && (
<p className="mt-1 text-xs text-surface-400 dark:text-surface-500">{card.getSubtitle(kpis)}</p>
)}
</div>
);
}
function RevenueChart({ data, loading, denied }: { data: RevenuePoint[]; loading: boolean; denied?: boolean }) {
if (denied) {
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-8 text-center">
<p className="text-sm text-surface-400">You do not have permission to view revenue data.</p>
</div>
);
}
const maxRevenue = useMemo(
() => (data.length > 0 ? Math.max(...data.map((d) => d.revenue)) : 0),
[data],
);
if (loading) {
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-6">
<Skeleton className="h-5 w-40 mb-6" />
<div className="flex items-end gap-3 h-48">
{Array.from({ length: 6 }).map((_, i) => {
const height = `${30 + Math.random() * 60}%`;
return (
<div key={i} className="flex-1 flex flex-col items-center gap-2">
<div className="w-full rounded-t bg-surface-100 dark:bg-surface-700" style={{ height }} />
<Skeleton className="h-3 w-10" />
</div>
);
})}
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-6">Revenue Trend (Last 6 Months)</h2>
{data.length === 0 ? (
<p className="text-sm text-surface-400 py-12 text-center">No revenue data available</p>
) : (
<div className="flex items-end gap-3 h-52">
{data.map((point) => {
const heightPct = maxRevenue > 0 ? (point.revenue / maxRevenue) * 100 : 0;
return (
<div key={point.month} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
<span className="text-[11px] font-medium text-surface-500 dark:text-surface-400 truncate w-full text-center">
{formatPHP(point.revenue)}
</span>
<div className="w-full flex items-end" style={{ height: '10rem' }}>
<div
className="w-full bg-primary-600 rounded-t-md transition-all duration-500 hover:bg-primary-500"
style={{ height: `${Math.max(heightPct, 4)}%` }}
title={`${point.month}: ${formatPHP(point.revenue)} (${point.count} payments)`}
/>
</div>
<span className="text-[11px] text-surface-400 font-medium">{point.month}</span>
</div>
);
})}
</div>
)}
</div>
);
}
function RecentPaymentsTable({ payments }: { payments: Payment[] }) {
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-4">Recent Payments</h2>
{payments.length === 0 ? (
<p className="text-sm text-surface-400 py-6 text-center">No recent payments</p>
) : (
<div className="space-y-3 max-h-80 overflow-y-auto">
{payments.slice(0, 10).map((p) => (
<div key={p.id} className="flex items-center justify-between text-sm gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-surface-800 dark:text-surface-200 font-medium truncate">
{p.client ? `${p.client.firstName} ${p.client.lastName}` : 'Unknown'}
</span>
<Badge label={p.method} variant="info" />
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="font-mono text-surface-700 dark:text-surface-300 text-xs">
{formatPHP(Number(p.amount))}
</span>
<span className="text-surface-400 text-xs">{formatDate(p.createdAt)}</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
function RecentClientsSection({ clients }: { clients: Activity['recentClients'] }) {
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-4">Recent Clients</h2>
{(!clients || clients.length === 0) ? (
<p className="text-sm text-surface-400 py-6 text-center">No recent signups</p>
) : (
<div className="space-y-3 max-h-80 overflow-y-auto">
{clients.map((c) => (
<div key={c.id} className="flex items-center justify-between text-sm">
<div className="min-w-0 flex-1">
<p className="text-surface-800 dark:text-surface-200 font-medium truncate">{c.firstName} {c.lastName}</p>
<p className="text-xs text-surface-400 font-mono">{c.accountNumber}</p>
</div>
<span className="text-surface-400 text-xs shrink-0">{formatDate(c.createdAt)}</span>
</div>
))}
</div>
)}
</div>
);
}
function OpenTicketsTable({ tickets }: { tickets: Ticket[] }) {
return (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-4">Open Tickets</h2>
{tickets.length === 0 ? (
<p className="text-sm text-surface-400 py-6 text-center">No open tickets</p>
) : (
<div className="space-y-3 max-h-80 overflow-y-auto">
{tickets.map((t) => (
<div key={t.id} className="flex items-start justify-between text-sm gap-2">
<div className="min-w-0 flex-1">
<p className="text-surface-800 dark:text-surface-200 font-medium truncate">{t.title}</p>
<p className="text-xs text-surface-400 mt-0.5 truncate">
{t.client ? `${t.client.firstName} ${t.client.lastName}` : 'Unassigned'}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Badge label={t.priority} variant={priorityVariant(t.priority)} />
<Badge label={t.status} variant={statusBadgeVariant(t.status)} />
</div>
</div>
))}
</div>
)}
</div>
);
}
function priorityVariant(priority: string): 'error' | 'warning' | 'info' | 'default' {
const map: Record<string, 'error' | 'warning' | 'info' | 'default'> = {
critical: 'error',
high: 'error',
medium: 'warning',
low: 'info',
};
return map[priority?.toLowerCase()] ?? 'default';
}
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function DashboardPage() {
const user = useAuthStore((s) => s.user);
const { toast } = useToast();
const [kpis, setKpis] = useState<Kpis | null>(null);
const [kpiDenied, setKpiDenied] = useState(false);
const [revenue, setRevenue] = useState<RevenuePoint[]>([]);
const [revenueLoading, setRevenueLoading] = useState(true);
const [revenueDenied, setRevenueDenied] = useState(false);
const [activity, setActivity] = useState<Activity | null>(null);
const [activityDenied, setActivityDenied] = useState(false);
// Modal states for quick actions
const [modalOpen, setModalOpen] = useState<QuickAction['modal'] | null>(null);
function refreshData() {
api.get('/dashboard/kpis').then((r) => setKpis(r.data.data)).catch(() => {});
api.get('/dashboard/activity').then((r) => setActivity(r.data.data)).catch(() => {});
}
function isForbidden(err: unknown): boolean {
return (err as any)?.response?.status === 403;
}
useEffect(() => {
api
.get('/dashboard/kpis')
.then((r) => setKpis(r.data.data))
.catch((err) => {
if (isForbidden(err)) setKpiDenied(true);
else toast('Failed to load KPIs', 'error');
});
api
.get('/dashboard/revenue-chart')
.then((r) => setRevenue(r.data.data ?? []))
.catch((err) => {
if (isForbidden(err)) setRevenueDenied(true);
else toast('Failed to load revenue chart', 'error');
})
.finally(() => setRevenueLoading(false));
api
.get('/dashboard/activity')
.then((r) => setActivity(r.data.data))
.catch((err) => {
if (isForbidden(err)) setActivityDenied(true);
else toast('Failed to load activity', 'error');
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="flex flex-col h-full">
{/* Fixed: Header + Quick Actions */}
<div className="flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-surface-900 dark:text-surface-100">Dashboard</h1>
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">
{greeting()}, {user?.firstName}. Here is your overview.
</p>
</div>
<div className="mt-4">
<QuickActionsBar actions={QUICK_ACTIONS} onAction={(m) => setModalOpen(m)} />
</div>
</div>
{/* Scrollable: KPI Cards, Revenue, Activity */}
<div className="flex-1 overflow-y-auto min-h-0 mt-6 space-y-6 pr-1">
{/* Row 1 — KPI Cards */}
{kpiDenied ? (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-8 text-center">
<p className="text-sm text-surface-400">You do not have permission to view dashboard KPIs.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{KPI_CARDS.map((card) => (
<KpiCardComponent key={card.key} card={card} kpis={kpis} />
))}
</div>
)}
{/* Row 2 — Revenue Chart */}
<RevenueChart data={revenue} loading={revenueLoading} denied={revenueDenied} />
{/* Row 3 — Activity Tables */}
{!activityDenied && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<RecentPaymentsTable payments={activity?.recentPayments ?? []} />
<RecentClientsSection clients={activity?.recentClients ?? []} />
<OpenTicketsTable tickets={activity?.recentTickets ?? []} />
</div>
)}
</div>
{/* Quick action modals */}
<CreateClientModal open={modalOpen === 'client'} onClose={() => setModalOpen(null)} onSuccess={refreshData} />
<PaymentModal open={modalOpen === 'payment'} onClose={() => setModalOpen(null)} onSuccess={refreshData} />
<CreateExpenseModal open={modalOpen === 'expense'} onClose={() => setModalOpen(null)} onSuccess={refreshData} />
<TransferModal open={modalOpen === 'transfer'} onClose={() => setModalOpen(null)} onSuccess={refreshData} />
</div>
);
}

View File

@@ -0,0 +1,309 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { DataTable } from '@/components/ui/data-table';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Modal } from '@/components/ui/modal';
import { FormModal } from '@/components/ui/form-modal';
import { useToast } from '@/components/ui/toast';
import { PaymentModal } from '@/components/modals/payment-modal';
import { useAuthStore } from '@/stores/auth.store';
type Tab = 'payments' | 'remittances';
export default function PaymentsPage() {
const [tab, setTab] = useState<Tab>('payments');
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0">
<PageHeader title="Payments & Remittances" description="Collection history and remittance clearing" />
<div className="mt-4 flex gap-1 border-b border-surface-200 dark:border-surface-700 mb-5">
{([['payments', 'Payments'], ['remittances', 'Remittances']] as const).map(([key, label]) => (
<button key={key} onClick={() => setTab(key as Tab)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors cursor-pointer ${
tab === key ? 'border-primary-600 text-primary-700 dark:text-primary-400' : 'border-transparent text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200'
}`}>{label}</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{tab === 'payments' && <PaymentsTab />}
{tab === 'remittances' && <RemittancesTab />}
</div>
</div>
);
}
// ─── Payments Tab ────────────────────────────────────────────
function PaymentsTab() {
const [payments, setPayments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showRecord, setShowRecord] = useState(false);
const [detailTarget, setDetailTarget] = useState<any>(null);
const methodLabels: Record<string, string> = { gcash: 'GCash', maya: 'Maya', cash: 'Cash', bank_transfer: 'Bank Transfer' };
const load = useCallback(async () => {
try { const r = await api.get('/payments'); setPayments(r.data.data); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const filtered = search ? payments.filter((p: any) =>
`${p.client.firstName} ${p.client.lastName}`.toLowerCase().includes(search.toLowerCase()) ||
p.client.accountNumber.toLowerCase().includes(search.toLowerCase()) ||
p.invoice?.number?.toLowerCase().includes(search.toLowerCase()),
) : payments;
return (
<div>
<DataTable data={filtered} loading={loading} keyExtractor={(p: any) => p.id}
emptyTitle="No payments" searchPlaceholder="Search by client, account #, or invoice..."
searchValue={search} onSearchChange={setSearch}
onRowClick={(p: any) => setDetailTarget(p)}
headerActions={<Button onClick={() => setShowRecord(true)}>Record Payment</Button>}
columns={[
{ key: 'createdAt', label: 'Date', sortable: true, render: (p: any) => <span className="text-surface-500 dark:text-surface-400">{new Date(p.createdAt).toLocaleDateString()}</span> },
{ key: 'client', label: 'Client', sortable: true, render: (p: any) => <span className="text-surface-800 dark:text-surface-200">{p.client.firstName} {p.client.lastName}</span> },
{ key: 'invoice', label: 'Invoice', render: (p: any) => <span className="font-mono text-surface-500 dark:text-surface-400">{p.invoice?.number || '—'}</span> },
{ key: 'amount', label: 'Amount', align: 'right' as const, sortable: true, render: (p: any) => <span className="font-medium text-surface-900 dark:text-surface-200">PHP {Number(p.amount).toLocaleString()}</span> },
{ key: 'method', label: 'Method', render: (p: any) => <span className="text-surface-600 dark:text-surface-300">{methodLabels[p.method] || p.method}</span> },
{ key: 'collectedBy', label: 'Collected By', render: (p: any) => <span className="text-surface-500 dark:text-surface-400">{p.collectedBy ? `${p.collectedBy.firstName} ${p.collectedBy.lastName}` : '—'}</span> },
]}
/>
<PaymentModal open={showRecord} onClose={() => setShowRecord(false)} onSuccess={load} />
{/* Payment Detail Modal */}
<Modal open={!!detailTarget} onClose={() => setDetailTarget(null)}
title="Payment Details" description={detailTarget ? `Ref: ${detailTarget.id?.slice(0, 8)}...` : ''}>
{detailTarget && (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div><span className="text-surface-500 dark:text-surface-400">Client:</span> <span className="font-medium text-surface-800 dark:text-surface-200">{detailTarget.client?.firstName} {detailTarget.client?.lastName}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Account:</span> <span className="font-mono text-surface-700 dark:text-surface-300">{detailTarget.client?.accountNumber}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Amount:</span> <span className="font-bold text-surface-900 dark:text-surface-200">PHP {Number(detailTarget.amount).toLocaleString()}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Method:</span> <span className="text-surface-700 dark:text-surface-300">{methodLabels[detailTarget.method] || detailTarget.method}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Invoice:</span> <span className="font-mono text-surface-700 dark:text-surface-300">{detailTarget.invoice?.number || '—'}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Date:</span> <span className="text-surface-700 dark:text-surface-300">{new Date(detailTarget.createdAt).toLocaleDateString()}</span></div>
{detailTarget.collectedBy && (
<div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Collected By:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.collectedBy.firstName} {detailTarget.collectedBy.lastName}</span></div>
)}
</div>
<div className="flex justify-end pt-2 border-t border-surface-200 dark:border-surface-700">
<Button size="sm" variant="ghost" onClick={() => setDetailTarget(null)}>Close</Button>
</div>
</div>
)}
</Modal>
</div>
);
}
// ─── Remittances Tab ─────────────────────────────────────────
function RemittancesTab() {
const { toast } = useToast();
const currentUser = useAuthStore((s) => s.user);
const [remittances, setRemittances] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showSubmit, setShowSubmit] = useState(false);
const [confirmTarget, setConfirmTarget] = useState<any>(null);
const [rejectTarget, setRejectTarget] = useState<any>(null);
const load = useCallback(async () => {
try { const r = await api.get('/payments/remittances'); setRemittances(r.data.data); }
catch { toast('Failed to load remittances', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { load(); }, [load]);
async function handleConfirm() {
if (!confirmTarget) return;
try {
await api.patch(`/payments/remittances/${confirmTarget.id}/confirm`);
toast('Remittance confirmed — funds cleared to company accounts', 'success');
load();
} catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setConfirmTarget(null); }
}
async function handleReject() {
if (!rejectTarget) return;
try {
await api.patch(`/payments/remittances/${rejectTarget.id}/reject`);
toast('Remittance rejected', 'success');
load();
} catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setRejectTarget(null); }
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-surface-500 dark:text-surface-400">Collectors submit collected payments for approval. Approved remittances clear funds from collector custody to company accounts.</p>
<Button onClick={() => setShowSubmit(true)}>Submit Remittance</Button>
</div>
<div className="space-y-3">
{loading ? <p className="text-surface-400 dark:text-surface-500">Loading...</p> : remittances.length === 0 ? (
<div className="text-center py-12 text-surface-400 dark:text-surface-500">No remittances yet</div>
) : remittances.map((r: any) => (
<div key={r.id} 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">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900/40 flex items-center justify-center text-primary-700 dark:text-primary-300 text-sm font-bold">
{r.collector.firstName[0]}{r.collector.lastName[0]}
</div>
<div>
<p className="font-medium text-surface-800 dark:text-surface-200">{r.collector.firstName} {r.collector.lastName}</p>
<p className="text-xs text-surface-400 dark:text-surface-500">{new Date(r.submittedAt).toLocaleString()} {r.payments?.length || 0} payments</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-lg font-bold text-surface-900 dark:text-surface-200">PHP {Number(r.totalAmount).toLocaleString()}</span>
<Badge label={r.status} variant={statusBadgeVariant(r.status)} />
</div>
</div>
{r.confirmedBy && (
<p className="mt-2 text-xs text-surface-400 dark:text-surface-500">
{r.status === 'confirmed' ? 'Confirmed' : 'Reviewed'} by {r.confirmedBy.firstName} {r.confirmedBy.lastName} on {new Date(r.confirmedAt).toLocaleDateString()}
</p>
)}
{r.notes && <p className="mt-2 text-sm text-surface-500 dark:text-surface-400">{r.notes}</p>}
{r.status === 'pending' && r.collectorId !== currentUser?.id && (
<div className="mt-3 flex gap-2 justify-end">
<Button size="sm" onClick={() => setConfirmTarget(r)}>Approve</Button>
<Button size="sm" variant="ghost" onClick={() => setRejectTarget(r)}>Reject</Button>
</div>
)}
{r.status === 'pending' && r.collectorId === currentUser?.id && (
<p className="mt-3 text-xs text-surface-400 text-right">You submitted this another admin/manager must approve</p>
)}
</div>
))}
</div>
<SubmitRemittanceModal open={showSubmit} onClose={() => setShowSubmit(false)} onSuccess={load} />
<Modal open={!!confirmTarget} onClose={() => setConfirmTarget(null)} title="Approve Remittance"
description={`Approve PHP ${Number(confirmTarget?.totalAmount || 0).toLocaleString()} from ${confirmTarget?.collector?.firstName} ${confirmTarget?.collector?.lastName}? Funds will be cleared from their custody to company accounts. A journal entry will be created.`}
confirmLabel="Approve & Clear" onConfirm={handleConfirm} />
<Modal open={!!rejectTarget} onClose={() => setRejectTarget(null)} title="Reject Remittance"
description={`Reject this remittance from ${rejectTarget?.collector?.firstName} ${rejectTarget?.collector?.lastName}?`}
variant="danger" confirmLabel="Reject" onConfirm={handleReject} />
</div>
);
}
// ─── Submit Remittance Modal ─────────────────────────────────
function SubmitRemittanceModal({ open, onClose, onSuccess }: { open: boolean; onClose: () => void; onSuccess: () => void }) {
const { toast } = useToast();
const [payments, setPayments] = useState<any[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const methodLabels: Record<string, string> = { gcash: 'GCash', maya: 'Maya', cash: 'Cash', bank_transfer: 'Bank Transfer' };
useEffect(() => {
if (!open) return;
setSelected(new Set()); setNotes(''); setLoading(true);
api.get('/payments/unremitted').then((r) => setPayments(r.data.data || r.data))
.catch(() => toast('Failed to load unremitted payments', 'error'))
.finally(() => setLoading(false));
}, [open, toast]);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function selectAll() {
if (selected.size === payments.length) setSelected(new Set());
else setSelected(new Set(payments.map((p: any) => p.id)));
}
const total = payments.filter((p: any) => selected.has(p.id)).reduce((s: number, p: any) => s + Number(p.amount), 0);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (selected.size === 0) { toast('Select at least one payment', 'error'); return; }
setSubmitting(true);
try {
await api.post('/payments/remittances', { paymentIds: [...selected], notes: notes || undefined });
toast(`Remittance submitted — PHP ${total.toLocaleString()} for approval`, 'success');
onSuccess(); onClose();
} catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="Submit Remittance" description="Select collected payments to submit for approval. Once approved, funds move from your custody to company accounts." wide>
<form onSubmit={handleSubmit} className="space-y-4">
{loading ? <p className="text-surface-400 py-4">Loading unremitted payments...</p> : payments.length === 0 ? (
<div className="bg-surface-50 rounded-lg p-6 text-center text-surface-500">No unremitted payments. All collections have been submitted.</div>
) : (
<>
<div className="flex items-center justify-between">
<button type="button" onClick={selectAll} className="text-sm text-primary-600 hover:text-primary-700 cursor-pointer">
{selected.size === payments.length ? 'Deselect all' : 'Select all'}
</button>
<span className="text-sm text-surface-500 dark:text-surface-400">{selected.size} selected <span className="font-bold text-surface-900 dark:text-surface-200">PHP {total.toLocaleString()}</span></span>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{payments.map((p: any) => (
<button key={p.id} type="button" onClick={() => toggle(p.id)}
className={`w-full text-left px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer ${
selected.has(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-700 hover:border-surface-300'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${selected.has(p.id) ? 'bg-primary-600 border-primary-600' : 'border-surface-300'}`}>
{selected.has(p.id) && <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round"><path d="M3 6l2 2 4-4" /></svg>}
</div>
<div>
<span className="text-sm text-surface-800 dark:text-surface-200">{p.client?.firstName} {p.client?.lastName}</span>
{p.invoice?.number && <span className="ml-2 text-xs font-mono text-surface-400">{p.invoice.number}</span>}
</div>
</div>
<div className="text-right">
<span className="text-sm font-medium text-surface-900 dark:text-surface-200">PHP {Number(p.amount).toLocaleString()}</span>
<span className="ml-2 text-xs text-surface-400">{methodLabels[p.method] || p.method}</span>
</div>
</div>
<p className="mt-1 text-xs text-surface-400 pl-8">{new Date(p.createdAt).toLocaleDateString()}</p>
</button>
))}
</div>
</>
)}
<div>
<label htmlFor="rem-notes" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Notes <span className="text-surface-400 dark:text-surface-500 font-normal">(optional)</span></label>
<input id="rem-notes" type="text" value={notes} onChange={(e) => setNotes(e.target.value)}
className="mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-200 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200"
placeholder="e.g. Daily collection April 4" />
</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={selected.size === 0}>
Submit Remittance (PHP {total.toLocaleString()})
</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,266 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { FormModal } from '@/components/ui/form-modal';
import { Modal } from '@/components/ui/modal';
import { useToast } from '@/components/ui/toast';
export default function PayrollPage() {
const { toast } = useToast();
const [runs, setRuns] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [selectedRun, setSelectedRun] = useState<any>(null);
const [processTarget, setProcessTarget] = useState<any>(null);
const [editingPayslip, setEditingPayslip] = useState<any>(null);
const load = useCallback(async () => {
try { const r = await api.get('/payroll'); setRuns(r.data.data); }
catch { toast('Failed to load payroll', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { load(); }, [load]);
async function loadRunDetail(id: string) {
try { const r = await api.get(`/payroll/${id}`); setSelectedRun(r.data.data); }
catch { toast('Failed to load', 'error'); }
}
async function handleUpdatePayslip(id: string, data: { deductions: number; bonuses: number; notes: string }) {
try {
const r = await api.patch(`/payroll/payslips/${id}`, data);
toast('Payslip updated', 'success');
setEditingPayslip(null);
if (selectedRun) {
const updated = { ...selectedRun };
updated.payslips = updated.payslips.map((p: any) => p.id === id ? r.data.data : p);
const totalNet = updated.payslips.reduce((sum: number, p: any) => sum + Number(p.netPay), 0);
updated.totalAmount = totalNet;
setSelectedRun(updated);
}
} catch (e: any) { toast(e.response?.data?.error || 'Failed to update', 'error'); }
}
async function handleProcess() {
if (!processTarget) return;
try {
await api.post(`/payroll/${processTarget.id}/process`);
toast(`Payroll ${processTarget.period} processed — journal entry created`, 'success');
setProcessTarget(null); setSelectedRun(null); load();
} catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
}
const isDraft = selectedRun?.status === 'draft';
return (
<div>
<PageHeader title="Payroll" description="Salary processing and payslip management"
action={<Button onClick={() => setShowCreate(true)}>New Payroll Run</Button>} />
{loading ? <p className="mt-5 text-surface-400">Loading...</p> : (
<div className="mt-5 space-y-3">
{runs.map((r: any) => (
<button key={r.id} onClick={() => loadRunDetail(r.id)}
className="w-full text-left bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-5 hover:border-primary-300 hover:shadow-sm transition-all duration-200 cursor-pointer">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-surface-900 dark:text-surface-200">Period: {r.period}</h3>
<p className="text-xs text-surface-400 mt-1">{r._count?.payslips || 0} employees</p>
</div>
<div className="flex items-center gap-3">
<span className="text-lg font-bold text-surface-900 dark:text-surface-200">PHP {Number(r.totalAmount).toLocaleString()}</span>
<Badge label={r.status} variant={statusBadgeVariant(r.status)} />
</div>
</div>
</button>
))}
{runs.length === 0 && <div className="text-center py-12 text-surface-400">No payroll runs yet. Create one to generate payslips for all active employees.</div>}
</div>
)}
<CreatePayrollModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={load} />
{/* Payroll Detail Modal */}
<FormModal open={!!selectedRun} onClose={() => setSelectedRun(null)} title={`Payroll: ${selectedRun?.period || ''}`} wide>
{selectedRun && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Badge label={selectedRun.status} variant={statusBadgeVariant(selectedRun.status)} />
<span className="font-bold text-surface-900 dark:text-surface-200">Total: PHP {Number(selectedRun.totalAmount).toLocaleString()}</span>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 overflow-hidden">
<table className="min-w-full divide-y divide-surface-200 dark:divide-surface-700">
<thead className="bg-surface-50/50 dark:bg-surface-700"><tr>
<th className="px-4 py-2.5 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Employee</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Base</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Deductions</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Bonuses</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Net Pay</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase">Status</th>
{isDraft && <th className="px-2 py-2.5"></th>}
</tr></thead>
<tbody className="divide-y divide-surface-100 dark:divide-surface-700">
{selectedRun.payslips?.map((p: any) => (
<tr key={p.id}>
<td className="px-4 py-2.5 text-sm text-surface-800 dark:text-surface-300">{p.employee?.firstName} {p.employee?.lastName} <span className="text-xs text-surface-400 font-mono">{p.employee?.employeeNo}</span></td>
<td className="px-4 py-2.5 text-sm text-right text-surface-700 dark:text-surface-300">PHP {Number(p.baseSalary).toLocaleString()}</td>
<td className="px-4 py-2.5 text-sm text-right text-red-600">{Number(p.deductions) > 0 ? `-${Number(p.deductions).toLocaleString()}` : '—'}</td>
<td className="px-4 py-2.5 text-sm text-right text-emerald-600">{Number(p.bonuses) > 0 ? `+${Number(p.bonuses).toLocaleString()}` : '—'}</td>
<td className="px-4 py-2.5 text-sm text-right font-bold text-surface-900 dark:text-surface-200">PHP {Number(p.netPay).toLocaleString()}</td>
<td className="px-4 py-2.5"><Badge label={p.status} variant={statusBadgeVariant(p.status)} /></td>
{isDraft && (
<td className="px-2 py-2.5 text-center">
<button onClick={() => setEditingPayslip(p)} title="Edit payslip"
className="p-1.5 text-surface-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-surface-700 rounded-lg transition-colors">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M11.5 1.5l3 3L5 14H2v-3z" />
</svg>
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{selectedRun.status === 'draft' && (
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setSelectedRun(null)}>Close</Button>
<Button onClick={() => { setProcessTarget(selectedRun); }}>Process Payroll</Button>
</div>
)}
{selectedRun.status === 'completed' && (
<div className="bg-emerald-50 dark:bg-emerald-900/30 rounded-lg p-3 text-sm text-emerald-700 dark:text-emerald-400">
Processed{selectedRun.processedAt ? ` on ${new Date(selectedRun.processedAt).toLocaleDateString()}` : ''}. Journal entry created: DR Salaries Expense, CR Cash on Hand.
</div>
)}
</div>
)}
</FormModal>
{/* Edit Payslip Modal */}
<EditPayslipModal
payslip={editingPayslip}
onClose={() => setEditingPayslip(null)}
onSave={handleUpdatePayslip}
/>
<Modal open={!!processTarget} onClose={() => setProcessTarget(null)} title="Process Payroll"
description={`Process payroll for ${processTarget?.period}? This will mark all payslips as paid and create an accounting journal entry (DR Salaries Expense, CR Cash on Hand) for PHP ${Number(processTarget?.totalAmount || 0).toLocaleString()}.`}
confirmLabel="Process & Pay" onConfirm={handleProcess} />
</div>
);
}
function EditPayslipModal({ payslip, onClose, onSave }: {
payslip: any | null;
onClose: () => void;
onSave: (id: string, data: { deductions: number; bonuses: number; notes: string }) => void;
}) {
const [deductions, setDeductions] = useState('0');
const [bonuses, setBonuses] = useState('0');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (payslip) {
setDeductions(String(payslip.deductions || 0));
setBonuses(String(payslip.bonuses || 0));
setNotes(payslip.notes || '');
}
}, [payslip]);
if (!payslip) return null;
const base = Number(payslip.baseSalary) || 0;
const ded = parseFloat(deductions) || 0;
const bon = parseFloat(bonuses) || 0;
const net = base - ded + bon;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
onSave(payslip.id, { deductions: parseFloat(deductions) || 0, bonuses: parseFloat(bonuses) || 0, notes });
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white dark:bg-surface-800 rounded-xl p-6 w-full max-w-md shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-1 dark:text-surface-200">Edit Payslip</h3>
<p className="text-sm text-surface-500 dark:text-surface-400 mb-4">
{payslip.employee?.firstName} {payslip.employee?.lastName} Base: PHP {base.toLocaleString()}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Deductions (PHP)</label>
<input type="number" min="0" step="0.01" value={deductions}
onChange={(e) => setDeductions(e.target.value)}
className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 dark:bg-surface-800 dark:text-surface-200 rounded-lg text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Bonuses (PHP)</label>
<input type="number" min="0" step="0.01" value={bonuses}
onChange={(e) => setBonuses(e.target.value)}
className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 dark:bg-surface-800 dark:text-surface-200 rounded-lg text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Notes</label>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 dark:bg-surface-800 dark:text-surface-200 rounded-lg text-sm resize-none" placeholder="Optional notes..." />
</div>
<div className="bg-surface-50 dark:bg-surface-700 rounded-lg p-3 text-sm">
<div className="flex justify-between">
<span className="text-surface-500 dark:text-surface-400">Net Pay:</span>
<span className="font-bold text-surface-900 dark:text-surface-200">PHP {net.toLocaleString()}</span>
</div>
</div>
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-surface-600 dark:text-surface-300 border border-surface-300 dark:border-surface-600 rounded-lg">Cancel</button>
<button type="submit" disabled={submitting}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 disabled:opacity-50">
{submitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}
function CreatePayrollModal({ open, onClose, onSuccess }: { open: boolean; onClose: () => void; onSuccess: () => void }) {
const { toast } = useToast();
const now = new Date();
const [period, setPeriod] = useState(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`);
const [submitting, setSubmitting] = useState(false);
useEffect(() => { if (open) setPeriod(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`); }, [open]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try { await api.post('/payroll', { period }); toast('Payroll run created with payslips', 'success'); onSuccess(); onClose(); }
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="New Payroll Run" description="Creates payslips for all active employees with salary.">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="pay-period" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Pay Period</label>
<input id="pay-period" type="month" required value={period} onChange={(e) => setPeriod(e.target.value)}
className="mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-200 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200" />
</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 Payroll</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Modal } from '@/components/ui/modal';
import { EmptyState } from '@/components/ui/empty-state';
import { CardSkeleton } from '@/components/ui/skeleton';
import { useToast } from '@/components/ui/toast';
interface Plan {
id: string;
name: string;
description: string | null;
speedDown: number;
speedUp: number;
price: string;
billingCycle: number;
isActive: boolean;
_count: { subscriptions: number };
}
export default function PlansPage() {
const { toast } = useToast();
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Plan | null>(null);
const [deleting, setDeleting] = useState(false);
const loadPlans = useCallback(async () => {
try {
const res = await api.get<{ data: Plan[] }>('/plans');
setPlans(res.data.data);
} catch { toast('Failed to load plans', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { loadPlans(); }, [loadPlans]);
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(`/plans/${deleteTarget.id}`);
toast(`Plan "${deleteTarget.name}" deleted`, 'success');
setDeleteTarget(null);
loadPlans();
} catch (err: any) { toast(err.response?.data?.error || 'Failed to delete', 'error'); }
finally { setDeleting(false); }
}
return (
<div>
<PageHeader title="Plan / Package Management" description="Configure internet plans with speed tiers and pricing"
action={<Button onClick={() => setShowCreate(!showCreate)} variant={showCreate ? 'secondary' : 'primary'}>{showCreate ? 'Cancel' : 'Add Plan'}</Button>} />
{showCreate && <CreatePlanForm onCreated={() => { setShowCreate(false); loadPlans(); toast('Plan created', 'success'); }} />}
{loading ? (
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">{[1, 2, 3].map((i) => <CardSkeleton key={i} />)}</div>
) : plans.length === 0 ? (
<div className="mt-6"><EmptyState title="No plans yet" description="Create your first internet plan to start onboarding clients."
icon={<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3" /><path d="M9 9h6v6H9z" /></svg>}
action={<Button onClick={() => setShowCreate(true)}>Create First Plan</Button>} /></div>
) : (
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{plans.map((plan) => (
<article key={plan.id} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-5 hover:shadow-md hover:shadow-surface-100 transition-all duration-200">
<div className="flex items-start justify-between">
<h3 className="font-semibold text-surface-900 dark:text-surface-200">{plan.name}</h3>
<Badge label={plan.isActive ? 'Active' : 'Inactive'} variant={plan.isActive ? 'success' : 'error'} />
</div>
{plan.description && <p className="text-sm text-surface-500 dark:text-surface-400 mt-1">{plan.description}</p>}
<div className="mt-3 space-y-1.5 text-sm">
<div className="flex justify-between"><span className="text-surface-500 dark:text-surface-400">Speed</span><span className="font-medium text-surface-900 dark:text-surface-200">{plan.speedDown}/{plan.speedUp} Mbps</span></div>
<div className="flex justify-between"><span className="text-surface-500 dark:text-surface-400">Price</span><span className="font-medium text-surface-900 dark:text-surface-200">PHP {Number(plan.price).toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-surface-500 dark:text-surface-400">Billing Cycle</span><span className="text-surface-700 dark:text-surface-300">{plan.billingCycle} days</span></div>
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-surface-100 dark:border-surface-700">
<span className="text-xs text-surface-500 dark:text-surface-400">{plan._count.subscriptions} subscriber{plan._count.subscriptions !== 1 ? 's' : ''}</span>
{plan._count.subscriptions === 0 && <Button size="sm" variant="ghost" onClick={() => setDeleteTarget(plan)}>Delete</Button>}
</div>
</article>
))}
</div>
)}
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="Delete Plan"
description={`Are you sure you want to delete "${deleteTarget?.name}"? This action cannot be undone.`}
variant="danger" confirmLabel="Delete Plan" onConfirm={handleDelete} loading={deleting} />
</div>
);
}
function CreatePlanForm({ onCreated }: { onCreated: () => void }) {
const { toast } = useToast();
const [form, setForm] = useState({ name: '', description: '', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 });
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
await api.post('/plans', { ...form, description: form.description || undefined });
onCreated();
} catch (err: any) { toast(err.response?.data?.error || 'Failed to create plan', 'error'); }
finally { setSubmitting(false); }
}
return (
<form onSubmit={handleSubmit} className="mt-4 bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6 space-y-4 max-w-lg">
<div>
<label htmlFor="plan-name" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Plan Name</label>
<input id="plan-name" type="text" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
className="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"
placeholder="e.g. Basic 25" />
</div>
<div>
<label htmlFor="plan-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>
<input id="plan-desc" type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })}
className="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" />
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Download (Mbps)</label>
<input type="number" required min={1} value={form.speedDown} onChange={(e) => setForm({ ...form, speedDown: parseInt(e.target.value) || 0 })}
className="mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200" /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Upload (Mbps)</label>
<input type="number" required min={1} value={form.speedUp} onChange={(e) => setForm({ ...form, speedUp: parseInt(e.target.value) || 0 })}
className="mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Price (PHP)</label>
<input type="number" required min={1} step={0.01} value={form.price} onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })}
className="mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200" /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Billing Cycle (days)</label>
<input type="number" required min={1} value={form.billingCycle} onChange={(e) => setForm({ ...form, billingCycle: parseInt(e.target.value) || 30 })}
className="mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200" /></div>
</div>
<Button type="submit" loading={submitting}>Create Plan</Button>
</form>
);
}

View File

@@ -0,0 +1,740 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { api } from '@/lib/api';
import { DataTable } from '@/components/ui/data-table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
/* ── Types ──────────────────────────────────────────────────── */
type Tab = 'overview' | 'collections' | 'expenses' | 'subscribers' | 'aging' | 'plans';
interface DateRange {
from: string;
to: string;
}
interface CollectionsData {
payments: Array<{
id: string;
amount: number;
method: string;
createdAt: string;
client: { firstName: string; lastName: string; accountNumber: string };
invoice: { number: string } | null;
collectedBy: { firstName: string; lastName: string } | null;
}>;
summary: {
total: number;
count: number;
byMethod: Record<string, number>;
};
}
interface ExpensesData {
expenses: Array<{
id: string;
amount: number;
category: string;
expenseDate: string;
description: string;
}>;
summary: {
total: number;
count: number;
byCategory: Record<string, number>;
};
}
interface Subscriber {
id: string;
firstName: string;
lastName: string;
accountNumber: string;
area: { name: string } | null;
subscriptions: Array<{ plan: { name: string; price: number } }>;
status: string;
}
interface AgingData {
buckets: {
current: AgingBucket[];
days30: AgingBucket[];
days60: AgingBucket[];
days90: AgingBucket[];
};
summary: {
current: number;
days30: number;
days60: number;
days90Plus: number;
total: number;
};
}
type AgingBucket = { id: string; number: string; amount: number; client: { firstName: string; lastName: string } };
interface PlanData {
id: string;
name: string;
price: number;
speedDown: number;
speedUp: number;
totalSubscriptions: number;
activeSubscriptions: number;
}
interface FinancialSummary {
cashOnHand: number;
monthlyIncome: number;
monthlyExpenses: number;
netIncome: number;
}
interface Kpis {
activeSubscribers: number;
totalClients: number;
todayCollections: { amount: number; count: number };
overdueAccounts: number;
newSignups: number;
pendingTickets: number;
}
interface RevenuePoint {
month: string;
revenue: number;
count: number;
}
/* ── Helpers ────────────────────────────────────────────────── */
function formatPHP(value: number): string {
return `${value.toLocaleString('en-PH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function defaultDateRange(): DateRange {
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth(), 1);
return {
from: from.toISOString().split('T')[0],
to: now.toISOString().split('T')[0],
};
}
function downloadCSV(filename: string, rows: (string | number)[][]) {
const csv = rows.map((r) => r.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/* ── Tab definitions ────────────────────────────────────────── */
const TABS: { key: Tab; label: string; hasDateFilter: boolean }[] = [
{ key: 'overview', label: 'Company Overview', hasDateFilter: false },
{ key: 'collections', label: 'Collections', hasDateFilter: true },
{ key: 'expenses', label: 'Expenses', hasDateFilter: true },
{ key: 'subscribers', label: 'Subscribers', hasDateFilter: false },
{ key: 'aging', label: 'Aging', hasDateFilter: false },
{ key: 'plans', label: 'Plan Distribution', hasDateFilter: false },
];
/* ── Shared sub-components ──────────────────────────────────── */
function Stat({ label, value, highlight, sub }: { label: string; value: string | number; highlight?: boolean; sub?: string }) {
return (
<div className={`rounded-xl border p-4 ${highlight ? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-700' : 'bg-white dark:bg-surface-800 border-surface-200/80 dark:border-surface-700'}`}>
<p className="text-[12px] font-medium text-surface-500 dark:text-surface-400">{label}</p>
<p className={`mt-1 text-lg font-bold ${highlight ? 'text-amber-700 dark:text-amber-400' : 'text-surface-900 dark:text-surface-200'}`}>{value}</p>
{sub && <p className="text-xs text-surface-400 dark:text-surface-500 mt-0.5">{sub}</p>}
</div>
);
}
function DateFilter({ range, onChange, onRefresh }: {
range: DateRange;
onChange: (r: DateRange) => void;
onRefresh: () => void;
}) {
return (
<div className="flex items-end gap-3">
<div>
<label className="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">From</label>
<input type="date" value={range.from}
onChange={(e) => onChange({ ...range, from: e.target.value })}
className="rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
</div>
<div>
<label className="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">To</label>
<input type="date" value={range.to}
onChange={(e) => onChange({ ...range, to: e.target.value })}
className="rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
</div>
<Button size="sm" onClick={onRefresh}>Apply</Button>
</div>
);
}
/* ── Page ───────────────────────────────────────────────────── */
export default function ReportsPage() {
const [tab, setTab] = useState<Tab>('overview');
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-surface-900 dark:text-surface-200">Reports</h1>
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">Financial and operational analytics</p>
</div>
<nav className="flex gap-1 border-b border-surface-200 dark:border-surface-700 mt-4">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-all duration-200 cursor-pointer ${
tab === t.key
? 'border-primary-600 text-primary-700 dark:text-primary-400'
: 'border-transparent text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300 hover:border-surface-300 dark:hover:border-surface-600'
}`}
>
{t.label}
</button>
))}
</nav>
</div>
<div className="flex-1 overflow-y-auto min-h-0 mt-5 pr-1">
{tab === 'overview' && <CompanyOverview />}
{tab === 'collections' && <CollectionReport />}
{tab === 'expenses' && <ExpenseReport />}
{tab === 'subscribers' && <SubscriberReport />}
{tab === 'aging' && <AgingReport />}
{tab === 'plans' && <PlanReport />}
</div>
</div>
);
}
/* ── Company Overview ───────────────────────────────────────── */
function CompanyOverview() {
const [kpis, setKpis] = useState<Kpis | null>(null);
const [financial, setFinancial] = useState<FinancialSummary | null>(null);
const [revenue, setRevenue] = useState<RevenuePoint[]>([]);
const [unremitted, setUnremitted] = useState<{ total: number; count: number }>({ total: 0, count: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.get('/dashboard/kpis').then((r) => setKpis(r.data.data)).catch(() => {}),
api.get('/dashboard/financial-summary').then((r) => setFinancial(r.data.data)).catch(() => {}),
api.get('/dashboard/revenue-chart').then((r) => setRevenue(r.data.data ?? [])).catch(() => {}),
api.get('/payments/unremitted').then((r) => {
const payments = r.data.data || r.data || [];
setUnremitted({
total: payments.reduce((s: number, p: any) => s + Number(p.amount), 0),
count: payments.length,
});
}).catch(() => {}),
]).finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-5">
<Skeleton className="h-4 w-24 mb-3" />
<Skeleton className="h-7 w-32" />
</div>
))}
</div>
</div>
);
}
const maxRevenue = revenue.length > 0 ? Math.max(...revenue.map((d) => d.revenue)) : 0;
return (
<div className="space-y-6">
{/* Row 1 — Key Financial Metrics */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-emerald-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Cash on Hand</p>
<p className="mt-2 text-2xl font-bold text-emerald-700 dark:text-emerald-400">{formatPHP(financial?.cashOnHand ?? 0)}</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">All company accounts</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-primary-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Monthly Revenue</p>
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-200">{formatPHP(financial?.monthlyIncome ?? 0)}</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">{kpis?.todayCollections.count ?? 0} payments today</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-blue-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Today&apos;s Collections</p>
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-200">{formatPHP(kpis?.todayCollections?.amount ?? 0)}</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">{kpis?.todayCollections?.count ?? 0} payments</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-amber-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Unremitted Funds</p>
<p className="mt-2 text-2xl font-bold text-amber-700 dark:text-amber-400">{formatPHP(unremitted.total)}</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">{unremitted.count} unremitted payments</p>
</div>
</div>
{/* Row 2 — Operational KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-primary-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Active Subscribers</p>
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-200">{(kpis?.activeSubscribers ?? 0).toLocaleString()}</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">{kpis?.totalClients ?? 0} total clients</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-emerald-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Net Income (MTD)</p>
<p className={`mt-2 text-2xl font-bold ${(financial?.netIncome ?? 0) >= 0 ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
{formatPHP(Math.abs(financial?.netIncome ?? 0))}
{(financial?.netIncome ?? 0) < 0 && ' loss'}
</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">
Income {formatPHP(financial?.monthlyIncome ?? 0)} Expenses {formatPHP(financial?.monthlyExpenses ?? 0)}
</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-red-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Overdue Accounts</p>
<p className={`mt-2 text-2xl font-bold ${(kpis?.overdueAccounts ?? 0) > 0 ? 'text-red-600 dark:text-red-400' : 'text-surface-900 dark:text-surface-200'}`}>
{(kpis?.overdueAccounts ?? 0).toLocaleString()}
</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">Invoices past due</p>
</div>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 border-l-4 border-l-blue-500 p-5">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">New Signups</p>
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-200">{(kpis?.newSignups ?? 0).toLocaleString()}</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">Last 30 days</p>
</div>
</div>
{/* Row 3 — Income vs Expenses comparison */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Revenue Trend Chart */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-6">Revenue Trend (6 Months)</h2>
{revenue.length === 0 ? (
<p className="text-sm text-surface-400 py-12 text-center">No revenue data</p>
) : (
<div className="flex items-end gap-3 h-48">
{revenue.map((point) => {
const heightPct = maxRevenue > 0 ? (point.revenue / maxRevenue) * 100 : 0;
return (
<div key={point.month} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
<span className="text-[11px] font-medium text-surface-500 truncate w-full text-center">
{formatPHP(point.revenue)}
</span>
<div className="w-full flex items-end" style={{ height: '8rem' }}>
<div
className="w-full bg-primary-600 rounded-t-md transition-all duration-500 hover:bg-primary-500"
style={{ height: `${Math.max(heightPct, 4)}%` }}
title={`${point.month}: ${formatPHP(point.revenue)} (${point.count} payments)`}
/>
</div>
<span className="text-[11px] text-surface-400 font-medium">{point.month}</span>
</div>
);
})}
</div>
)}
</div>
{/* Income vs Expenses */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6">
<h2 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-6">Monthly Income vs Expenses</h2>
<div className="space-y-6">
{[
{ label: 'Monthly Income', value: financial?.monthlyIncome ?? 0, color: 'bg-emerald-500', max: Math.max(financial?.monthlyIncome ?? 0, financial?.monthlyExpenses ?? 0) || 1 },
{ label: 'Monthly Expenses', value: financial?.monthlyExpenses ?? 0, color: 'bg-red-400', max: Math.max(financial?.monthlyIncome ?? 0, financial?.monthlyExpenses ?? 0) || 1 },
{ label: 'Net Income', value: financial?.netIncome ?? 0, color: (financial?.netIncome ?? 0) >= 0 ? 'bg-primary-600' : 'bg-red-600', max: Math.max(financial?.monthlyIncome ?? 0, financial?.monthlyExpenses ?? 0) || 1 },
].map((item) => (
<div key={item.label}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-surface-600 dark:text-surface-300">{item.label}</span>
<span className={`text-sm font-bold ${item.value < 0 ? 'text-red-600 dark:text-red-400' : 'text-surface-900 dark:text-surface-200'}`}>{formatPHP(Math.abs(item.value))}</span>
</div>
<div className="h-3 bg-surface-100 dark:bg-surface-700 rounded-full overflow-hidden">
<div
className={`h-full ${item.color} rounded-full transition-all duration-500`}
style={{ width: `${Math.min(Math.abs(item.value / item.max) * 100, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
</div>
{/* Row 4 — Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<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">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Pending Tickets</p>
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-amber-50 text-amber-600">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<rect x="2" y="4" width="16" height="12" rx="2" /><path d="M7 4v12M2 10h5M13 10h5" />
</svg>
</div>
</div>
<p className={`mt-3 text-2xl font-bold ${(kpis?.pendingTickets ?? 0) > 0 ? 'text-amber-600' : 'text-surface-900 dark:text-surface-200'}`}>
{(kpis?.pendingTickets ?? 0).toLocaleString()}
</p>
</div>
<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">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Expense Ratio</p>
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-red-50 text-red-600">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M3 17V5a2 2 0 012-2h10a2 2 0 012 2v12" /><path d="M7 8h6M7 11h4" /><path d="M3 17h14" />
</svg>
</div>
</div>
<p className="mt-3 text-2xl font-bold text-surface-900 dark:text-surface-200">
{financial?.monthlyIncome ? Math.round(((financial?.monthlyExpenses ?? 0) / financial.monthlyIncome) * 100) : 0}%
</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">Of monthly income</p>
</div>
<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">
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Collection Efficiency</p>
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-emerald-50 text-emerald-600">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M3 17l4-6 3 3 4-5 3 4" /><path d="M17 3v4h-4" />
</svg>
</div>
</div>
<p className="mt-3 text-2xl font-bold text-emerald-700 dark:text-emerald-400">
{kpis?.totalClients ? Math.round(((kpis?.activeSubscribers ?? 0) / kpis.totalClients) * 100) : 0}%
</p>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-1">Active subscriber rate</p>
</div>
</div>
</div>
);
}
/* ── Collections Report ─────────────────────────────────────── */
function CollectionReport() {
const [data, setData] = useState<CollectionsData | null>(null);
const [loading, setLoading] = useState(true);
const [range, setRange] = useState<DateRange>(defaultDateRange());
const fetch = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ from: range.from, to: range.to });
const res = await api.get<{ data: CollectionsData }>(`/reports/collections?${params}`);
setData(res.data.data);
} catch { setData(null); }
finally { setLoading(false); }
}, [range]);
useEffect(() => { fetch(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<DateFilter range={range} onChange={setRange} onRefresh={fetch} />
<Button size="sm" variant="secondary" onClick={() => {
if (!data) return;
downloadCSV('collections.csv', [
['Date', 'Client', 'Account #', 'Invoice', 'Amount', 'Method', 'Collected By'],
...data.payments.map((p) => [
new Date(p.createdAt).toLocaleDateString(),
`${p.client.firstName} ${p.client.lastName}`,
p.client.accountNumber,
p.invoice?.number || '',
p.amount,
p.method,
p.collectedBy ? `${p.collectedBy.firstName} ${p.collectedBy.lastName}` : '',
]),
]);
}}>Export CSV</Button>
</div>
{data && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Stat label="Total Collected" value={formatPHP(data.summary.total)} />
<Stat label="Transactions" value={data.summary.count} />
{Object.entries(data.summary.byMethod).map(([method, amount]) => (
<Stat key={method} label={method.toUpperCase()} value={formatPHP(amount)} />
))}
</div>
)}
<DataTable
data={data?.payments ?? []}
loading={loading}
keyExtractor={(p) => p.id}
emptyTitle="No collections"
columns={[
{ key: 'createdAt', label: 'Date', sortable: true, render: (p) => <span className="text-surface-500">{new Date(p.createdAt).toLocaleDateString()}</span> },
{ key: 'client', label: 'Client', sortable: true, render: (p) => <span className="text-surface-800 dark:text-surface-200">{p.client.firstName} {p.client.lastName}</span> },
{ key: 'accountNumber', label: 'Account #', render: (p) => <span className="font-mono text-surface-500 text-xs">{p.client.accountNumber}</span> },
{ key: 'invoice', label: 'Invoice', render: (p) => <span className="font-mono text-surface-500 text-xs">{p.invoice?.number || '—'}</span> },
{ key: 'amount', label: 'Amount', align: 'right', sortable: true, render: (p) => <span className="font-medium text-surface-900 dark:text-surface-100">{formatPHP(p.amount)}</span> },
{ key: 'method', label: 'Method', render: (p) => <Badge label={p.method} variant="info" /> },
{ key: 'collectedBy', label: 'Collected By', render: (p) => <span className="text-surface-500 text-xs">{p.collectedBy ? `${p.collectedBy.firstName} ${p.collectedBy.lastName}` : '—'}</span> },
]}
/>
</div>
);
}
/* ── Expense Report ─────────────────────────────────────────── */
function ExpenseReport() {
const [data, setData] = useState<ExpensesData | null>(null);
const [loading, setLoading] = useState(true);
const [range, setRange] = useState<DateRange>(defaultDateRange());
const fetch = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ from: range.from, to: range.to });
const res = await api.get<{ data: ExpensesData }>(`/reports/expenses?${params}`);
setData(res.data.data);
} catch { setData(null); }
finally { setLoading(false); }
}, [range]);
useEffect(() => { fetch(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<DateFilter range={range} onChange={setRange} onRefresh={fetch} />
<Button size="sm" variant="secondary" onClick={() => {
if (!data) return;
downloadCSV('expenses.csv', [
['Date', 'Category', 'Description', 'Amount'],
...data.expenses.map((e) => [
new Date(e.expenseDate).toLocaleDateString(),
e.category,
e.description,
e.amount,
]),
]);
}}>Export CSV</Button>
</div>
{data && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Stat label="Total Expenses" value={formatPHP(data.summary.total)} />
<Stat label="Transactions" value={data.summary.count} />
{Object.entries(data.summary.byCategory).slice(0, 2).map(([cat, amount]) => (
<Stat key={cat} label={cat.charAt(0).toUpperCase() + cat.slice(1)} value={formatPHP(amount)} />
))}
</div>
)}
<DataTable
data={data?.expenses ?? []}
loading={loading}
keyExtractor={(e) => e.id}
emptyTitle="No expenses"
columns={[
{ key: 'expenseDate', label: 'Date', sortable: true, render: (e) => <span className="text-surface-500">{new Date(e.expenseDate).toLocaleDateString()}</span> },
{ key: 'category', label: 'Category', sortable: true, render: (e) => <Badge label={e.category} /> },
{ key: 'description', label: 'Description', render: (e) => <span className="text-surface-800 dark:text-surface-200">{e.description}</span> },
{ key: 'amount', label: 'Amount', align: 'right', sortable: true, render: (e) => <span className="font-medium text-surface-900 dark:text-surface-100">{formatPHP(e.amount)}</span> },
]}
/>
</div>
);
}
/* ── Subscriber Report ──────────────────────────────────────── */
function SubscriberReport() {
const [data, setData] = useState<Subscriber[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
useEffect(() => {
api.get('/reports/subscribers')
.then((r) => setData(r.data.data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const filtered = search
? data.filter((s) =>
`${s.firstName} ${s.lastName} ${s.accountNumber} ${s.area?.name || ''} ${s.status}`
.toLowerCase().includes(search.toLowerCase())
)
: data;
return (
<div className="space-y-4">
<DataTable
data={filtered}
loading={loading}
keyExtractor={(s) => s.id}
emptyTitle="No subscribers"
searchPlaceholder="Search by name, account #, area, or status..."
searchValue={search}
onSearchChange={setSearch}
columns={[
{ key: 'accountNumber', label: 'Account #', sortable: true, render: (s) => <span className="font-mono text-surface-700 dark:text-surface-300">{s.accountNumber}</span> },
{ key: 'name', label: 'Name', sortable: true, render: (s) => <span className="text-surface-800 dark:text-surface-200">{s.firstName} {s.lastName}</span> },
{ key: 'area', label: 'Area', render: (s) => <span className="text-surface-500">{s.area?.name || '—'}</span> },
{ key: 'plan', label: 'Plan', render: (s) => <span className="text-surface-700 dark:text-surface-300">{s.subscriptions?.[0]?.plan?.name || '—'}</span> },
{ key: 'price', label: 'Price', align: 'right', render: (s) => (
<span className="font-medium text-surface-900 dark:text-surface-100">
{s.subscriptions?.[0]?.plan?.price ? formatPHP(Number(s.subscriptions[0].plan.price)) : '—'}
</span>
)},
{ key: 'status', label: 'Status', sortable: true, render: (s) => <Badge label={s.status} variant={s.status === 'active' ? 'success' : s.status === 'suspended' ? 'warning' : 'default'} /> },
]}
/>
</div>
);
}
/* ── Aging Report ───────────────────────────────────────────── */
function AgingReport() {
const [data, setData] = useState<AgingData | null>(null);
const [loading, setLoading] = useState(true);
const [activeBucket, setActiveBucket] = useState<string>('all');
useEffect(() => {
api.get('/reports/aging')
.then((r) => setData(r.data.data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <p className="text-surface-400 py-8 text-center">Loading...</p>;
if (!data) return <p className="text-surface-400 py-8 text-center">Failed to load aging data</p>;
const allBuckets = [
...data.buckets.current.map((b) => ({ ...b, bucket: 'Current' })),
...data.buckets.days30.map((b) => ({ ...b, bucket: '1-30 days' })),
...data.buckets.days60.map((b) => ({ ...b, bucket: '31-60 days' })),
...data.buckets.days90.map((b) => ({ ...b, bucket: '90+ days' })),
];
const displayed = activeBucket === 'all'
? allBuckets
: allBuckets.filter((b) => b.bucket === activeBucket);
return (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
<button onClick={() => setActiveBucket('all')} className={`text-left rounded-xl border p-4 transition-colors cursor-pointer ${activeBucket === 'all' ? 'bg-primary-50 border-primary-200 ring-2 ring-primary-500/20' : 'bg-white border-surface-200/80 hover:border-surface-300'}`}>
<p className="text-[12px] font-medium text-surface-500">Total Outstanding</p>
<p className="mt-1 text-lg font-bold text-surface-900 dark:text-surface-100">{formatPHP(data.summary.total)}</p>
</button>
<button onClick={() => setActiveBucket('Current')} className={`text-left rounded-xl border p-4 transition-colors cursor-pointer ${activeBucket === 'Current' ? 'bg-emerald-50 border-emerald-200 ring-2 ring-emerald-500/20' : 'bg-white border-surface-200/80 hover:border-surface-300'}`}>
<p className="text-[12px] font-medium text-surface-500">Current</p>
<p className="mt-1 text-lg font-bold text-emerald-700">{formatPHP(data.summary.current)}</p>
</button>
<button onClick={() => setActiveBucket('1-30 days')} className={`text-left rounded-xl border p-4 transition-colors cursor-pointer ${activeBucket === '1-30 days' ? 'bg-amber-50 border-amber-200 ring-2 ring-amber-500/20' : 'bg-white border-surface-200/80 hover:border-surface-300'}`}>
<p className="text-[12px] font-medium text-surface-500">1-30 Days</p>
<p className="mt-1 text-lg font-bold text-amber-700">{formatPHP(data.summary.days30)}</p>
</button>
<button onClick={() => setActiveBucket('31-60 days')} className={`text-left rounded-xl border p-4 transition-colors cursor-pointer ${activeBucket === '31-60 days' ? 'bg-orange-50 border-orange-200 ring-2 ring-orange-500/20' : 'bg-white border-surface-200/80 hover:border-surface-300'}`}>
<p className="text-[12px] font-medium text-surface-500">31-60 Days</p>
<p className="mt-1 text-lg font-bold text-orange-700">{formatPHP(data.summary.days60)}</p>
</button>
<button onClick={() => setActiveBucket('90+ days')} className={`text-left rounded-xl border p-4 transition-colors cursor-pointer ${activeBucket === '90+ days' ? 'bg-red-50 border-red-200 ring-2 ring-red-500/20' : 'bg-white border-surface-200/80 hover:border-surface-300'}`}>
<p className="text-[12px] font-medium text-surface-500">90+ Days</p>
<p className="mt-1 text-lg font-bold text-red-700">{formatPHP(data.summary.days90Plus)}</p>
</button>
</div>
<DataTable
data={displayed}
loading={false}
keyExtractor={(b) => b.id}
emptyTitle="No invoices in this bucket"
columns={[
{ key: 'number', label: 'Invoice #', render: (b) => <span className="font-mono text-surface-700 dark:text-surface-300">{b.number}</span> },
{ key: 'client', label: 'Client', render: (b) => <span className="text-surface-800 dark:text-surface-200">{b.client.firstName} {b.client.lastName}</span> },
...(activeBucket === 'all' ? [{ key: 'bucket', label: 'Bucket', render: (b: any) => <Badge label={b.bucket} variant={b.bucket === 'Current' ? 'success' : b.bucket === '90+ days' ? 'error' : 'warning'} /> }] : []),
{ key: 'amount', label: 'Amount Outstanding', align: 'right' as const, sortable: true, render: (b) => <span className="font-medium text-surface-900 dark:text-surface-100">{formatPHP(b.amount)}</span> },
]}
/>
</div>
);
}
/* ── Plan Distribution Report ───────────────────────────────── */
function PlanReport() {
const [data, setData] = useState<PlanData[]>([]);
const [loading, setLoading] = useState(true);
const fetch = useCallback(async () => {
try {
const res = await api.get<{ data: PlanData[] }>('/reports/plan-distribution');
setData(res.data.data);
} catch { setData([]); }
finally { setLoading(false); }
}, []);
useEffect(() => { fetch(); }, [fetch]);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
const total = data.reduce((s, p) => s + p.activeSubscriptions, 0);
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button size="sm" variant="secondary" onClick={handleExport}>Export CSV</Button>
</div>
{data.map((plan) => {
const pct = total > 0 ? Math.round((plan.activeSubscriptions / total) * 100) : 0;
return (
<div key={plan.id} 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">
<div>
<p className="font-medium text-surface-900 dark:text-surface-100">{plan.name}</p>
<p className="text-sm text-surface-500 dark:text-surface-400">{plan.speedDown}/{plan.speedUp} Mbps {formatPHP(plan.price)}</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-surface-900 dark:text-surface-100">{plan.activeSubscriptions}</p>
<p className="text-xs text-surface-400 dark:text-surface-500">active ({pct}%)</p>
</div>
</div>
<div className="mt-3 h-2 bg-surface-100 dark:bg-surface-700 rounded-full overflow-hidden">
<div className="h-full bg-primary-500 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
{data.length === 0 && <p className="text-sm text-surface-400 py-8 text-center">No plans yet</p>}
</div>
);
function handleExport() {
downloadCSV('plans.csv', [
['Plan', 'Speed', 'Price', 'Total Subscriptions', 'Active', '%'],
...data.map((p) => [p.name, `${p.speedDown}/${p.speedUp} Mbps`, p.price, p.totalSubscriptions, p.activeSubscriptions, total > 0 ? Math.round((p.activeSubscriptions / total) * 100) + '%' : '0%']),
]);
}
}

View File

@@ -0,0 +1,162 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { ActionIcon } from '@/components/ui/action-icon';
import { Modal } from '@/components/ui/modal';
import { FormModal } from '@/components/ui/form-modal';
import { useToast } from '@/components/ui/toast';
interface Area {
id: string;
name: string;
description: string | null;
isActive: boolean;
_count: { clients: number };
}
export default function AreasPage() {
const { toast } = useToast();
const [areas, setAreas] = useState<Area[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Area | null>(null);
const [deleting, setDeleting] = useState(false);
const [search, setSearch] = useState('');
const loadAreas = useCallback(async () => {
try {
const res = await api.get<{ data: Area[] }>('/areas');
setAreas(res.data.data);
} catch {
toast('Failed to load areas', 'error');
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => { loadAreas(); }, [loadAreas]);
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(`/areas/${deleteTarget.id}`);
toast(`Area "${deleteTarget.name}" deleted`, 'success');
setDeleteTarget(null);
loadAreas();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to delete area', 'error');
} finally {
setDeleting(false);
}
}
const filtered = search
? areas.filter((a) => a.name.toLowerCase().includes(search.toLowerCase()) || (a.description || '').toLowerCase().includes(search.toLowerCase()))
: areas;
return (
<div>
<div className="flex items-center justify-between mb-5">
<p className="text-sm text-surface-500">Define service areas and zones for client assignment</p>
<Button onClick={() => setShowCreate(!showCreate)} variant={showCreate ? 'secondary' : 'primary'}>
{showCreate ? 'Cancel' : 'Add Area'}
</Button>
</div>
<DataTable
data={filtered}
loading={loading}
keyExtractor={(a) => a.id}
emptyTitle="No areas yet"
emptyDescription="Create your first service area to start organizing clients by location."
searchPlaceholder="Search areas..."
searchValue={search}
onSearchChange={setSearch}
columns={[
{ key: 'name', label: 'Name', sortable: true, render: (a: Area) => <span className="font-medium text-surface-800 dark:text-surface-200">{a.name}</span> },
{ key: 'description', label: 'Description', render: (a: Area) => <span className="text-sm text-surface-500 dark:text-surface-400">{a.description || '—'}</span> },
{ key: 'isActive', label: 'Status', sortable: true, render: (a: Area) => <Badge label={a.isActive ? 'Active' : 'Inactive'} variant={a.isActive ? 'success' : 'error'} /> },
{ key: 'clients', label: 'Clients', align: 'right' as const, render: (a: Area) => <span className="text-sm text-surface-600 dark:text-surface-300">{a._count.clients}</span> },
{
key: 'actions', label: '', align: 'right',
render: (a: Area) => (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
{a._count.clients === 0 && (
<ActionIcon icon="trash" variant="danger" label="Delete" onClick={() => setDeleteTarget(a)} />
)}
</div>
),
},
]}
/>
<Modal
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title="Delete Area"
description={`Are you sure you want to delete "${deleteTarget?.name}"? This action cannot be undone.`}
variant="danger"
confirmLabel="Delete Area"
onConfirm={handleDelete}
loading={deleting}
/>
{showCreate && (
<CreateAreaModal onCreated={() => { setShowCreate(false); loadAreas(); toast('Area created', 'success'); }} onClose={() => setShowCreate(false)} />
)}
</div>
);
}
function CreateAreaModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
const { toast } = useToast();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [error, setError] = useState('');
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';
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await api.post('/areas', { name, description: description || undefined });
onCreated();
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to create area');
toast(err.response?.data?.error || 'Failed to create area', 'error');
} finally {
setSubmitting(false);
}
}
return (
<FormModal open onClose={onClose} title="Create Area" description="Define a new service area for client assignment">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm" role="alert">{error}</div>
)}
<div>
<label htmlFor="area-name" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Area Name</label>
<input id="area-name" type="text" required minLength={2} value={name} onChange={(e) => setName(e.target.value)}
className={ic} placeholder="e.g. Barangay 1 - Centro" />
</div>
<div>
<label htmlFor="area-desc" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Description <span className="text-surface-400 dark:text-surface-500 font-normal">(optional)</span></label>
<input id="area-desc" type="text" value={description} onChange={(e) => setDescription(e.target.value)}
className={ic} placeholder="Description of the service area" />
</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 Area</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
import { Skeleton } from '@/components/ui/skeleton';
export default function BillingSettingsPage() {
const { toast } = useToast();
const [settings, setSettings] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ autoGenerate: true, gracePeriodDays: 7, dueDateOffsetDays: 15, lateFeePercent: 0, invoicePrefix: 'INV' });
useEffect(() => {
api.get('/billing-settings').then((r) => {
const s = r.data.data;
setSettings(s);
setForm({
autoGenerate: s.autoGenerate,
gracePeriodDays: s.gracePeriodDays,
dueDateOffsetDays: s.dueDateOffsetDays,
lateFeePercent: Number(s.lateFeePercent),
invoicePrefix: s.invoicePrefix,
});
}).catch(() => toast('Failed to load', 'error'))
.finally(() => setLoading(false));
}, [toast]);
async function handleSave(e: React.FormEvent) {
e.preventDefault(); setSaving(true);
try {
await api.patch('/billing-settings', form);
toast('Billing settings saved', 'success');
} catch { toast('Failed to save', 'error'); }
finally { setSaving(false); }
}
if (loading) return <div className="max-w-lg space-y-4">{[1,2,3].map(i => <Skeleton key={i} className="h-16 w-full" />)}</div>;
const ic = '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 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<form onSubmit={handleSave} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6 space-y-5 max-w-lg">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-surface-700 dark:text-surface-300">Auto-Generate Invoices</label>
<p className="text-xs text-surface-400 dark:text-surface-500 mt-0.5">Automatically create invoices when billing cycle ends</p>
</div>
<button type="button" onClick={() => setForm({ ...form, autoGenerate: !form.autoGenerate })}
className={`relative w-11 h-6 rounded-full transition-colors duration-200 cursor-pointer ${form.autoGenerate ? 'bg-primary-600' : 'bg-surface-300 dark:bg-surface-600'}`}>
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${form.autoGenerate ? 'translate-x-5.5' : 'translate-x-0.5'}`} />
</button>
</div>
<div>
<label htmlFor="grace" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Grace Period (days)</label>
<p className="text-xs text-surface-400 dark:text-surface-500 mb-1">Days after due date before invoice is marked overdue</p>
<input id="grace" type="number" min={0} max={90} value={form.gracePeriodDays} onChange={(e) => setForm({ ...form, gracePeriodDays: parseInt(e.target.value) || 0 })} className={ic} />
</div>
<div>
<label htmlFor="offset" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Due Date Offset (days)</label>
<p className="text-xs text-surface-400 dark:text-surface-500 mb-1">Days from invoice generation to payment due date</p>
<input id="offset" type="number" min={1} max={60} value={form.dueDateOffsetDays} onChange={(e) => setForm({ ...form, dueDateOffsetDays: parseInt(e.target.value) || 15 })} className={ic} />
</div>
<div>
<label htmlFor="fee" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Late Fee (%)</label>
<p className="text-xs text-surface-400 dark:text-surface-500 mb-1">Percentage surcharge on overdue invoices (0 = disabled)</p>
<input id="fee" type="number" min={0} max={100} step={0.5} value={form.lateFeePercent} onChange={(e) => setForm({ ...form, lateFeePercent: parseFloat(e.target.value) || 0 })} className={ic} />
</div>
<div>
<label htmlFor="prefix" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Invoice Prefix</label>
<input id="prefix" type="text" value={form.invoicePrefix} onChange={(e) => setForm({ ...form, invoicePrefix: e.target.value })} className={ic} />
</div>
<Button type="submit" loading={saving}>Save Billing Settings</Button>
</form>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { FormModal } from '@/components/ui/form-modal';
import { Modal } from '@/components/ui/modal';
import { ActionIcon } from '@/components/ui/action-icon';
import { useToast } from '@/components/ui/toast';
const typeColors: Record<string, 'info' | 'success' | 'purple' | 'warning' | 'error'> = {
asset: 'info', liability: 'error', equity: 'purple', revenue: 'success', expense: 'warning',
};
export default function ChartOfAccountsPage() {
const { toast } = useToast();
const [accounts, setAccounts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<any>(null);
const load = useCallback(async () => {
try { const r = await api.get('/accounting/chart-of-accounts'); setAccounts(r.data.data); }
catch { toast('Failed to load', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { load(); }, [load]);
async function handleDelete() {
if (!deleteTarget) return;
try { await api.delete(`/accounting/chart-of-accounts/${deleteTarget.id}`); toast('Account deleted', 'success'); setDeleteTarget(null); load(); }
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
}
// Group by type
const grouped: Record<string, any[]> = {};
for (const a of accounts) {
(grouped[a.type] = grouped[a.type] || []).push(a);
}
const typeOrder = ['asset', 'liability', 'equity', 'revenue', 'expense'];
const typeLabels: Record<string, string> = { asset: 'Assets', liability: 'Liabilities', equity: 'Equity', revenue: 'Revenue', expense: 'Expenses' };
return (
<div>
<div className="flex items-center justify-between mb-5">
<p className="text-sm text-surface-500 dark:text-surface-400">Manage your chart of accounts for double-entry bookkeeping</p>
<Button onClick={() => setShowCreate(true)}>Add Account</Button>
</div>
{loading ? <p className="text-surface-400 dark:text-surface-500">Loading...</p> : (
<div className="space-y-6">
{typeOrder.map((type) => {
const accts = grouped[type];
if (!accts) return null;
return (
<div key={type}>
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-2 flex items-center gap-2">
<Badge label={typeLabels[type]} variant={typeColors[type]} />
</h3>
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 overflow-hidden">
<table className="min-w-full divide-y divide-surface-200 dark:divide-surface-700">
<tbody className="divide-y divide-surface-100 dark:divide-surface-700/50">
{accts.map((a: any) => (
<tr key={a.id} className="cursor-pointer hover:bg-primary-50/40 dark:hover:bg-surface-700/50 border-l-2 border-l-transparent hover:border-l-primary-400 transition-all duration-150">
<td className="px-5 py-3 text-sm font-mono text-surface-600 dark:text-surface-300 w-24">{a.code}</td>
<td className="px-5 py-3 text-sm text-surface-800 dark:text-surface-200 font-medium">{a.name}</td>
<td className="px-5 py-3 text-sm text-right">
{a.isSystem ? <span className="text-xs text-surface-300 dark:text-surface-500">System</span> : (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
<ActionIcon icon="trash" variant="danger" label="Delete" onClick={() => setDeleteTarget(a)} />
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
</div>
)}
<CreateCoAModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={load} />
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="Delete Account"
description={`Delete "${deleteTarget?.code}${deleteTarget?.name}"?`} variant="danger"
confirmLabel="Delete" onConfirm={handleDelete} />
</div>
);
}
function CreateCoAModal({ open, onClose, onSuccess }: { open: boolean; onClose: () => void; onSuccess: () => void }) {
const { toast } = useToast();
const [form, setForm] = useState({ code: '', name: '', type: 'asset' });
const [submitting, setSubmitting] = useState(false);
const ic = '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 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
useEffect(() => { if (open) setForm({ code: '', name: '', type: 'asset' }); }, [open]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try { await api.post('/accounting/chart-of-accounts', form); toast('Account created', 'success'); onSuccess(); onClose(); }
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="Add Account">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Code</label><input type="text" required value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} className={ic} placeholder="1050" /></div>
<div className="col-span-2"><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Name</label><input type="text" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={ic} placeholder="Account name" /></div>
</div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Type</label>
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} className={ic}>
<option value="asset">Asset</option><option value="liability">Liability</option>
<option value="equity">Equity</option><option value="revenue">Revenue</option>
<option value="expense">Expense</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 Account</Button></div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { ActionIcon } from '@/components/ui/action-icon';
import { FormModal } from '@/components/ui/form-modal';
import { Modal } from '@/components/ui/modal';
import { useToast } from '@/components/ui/toast';
const typeLabels: Record<string, string> = { bank: 'Bank', e_wallet: 'E-Wallet', cash: 'Cash' };
const typeBadgeVariant: Record<string, 'info' | 'purple' | 'default'> = { bank: 'info', e_wallet: 'purple', cash: 'default' };
export default function CompanyAccountsSettings() {
const { toast } = useToast();
const [accounts, setAccounts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<any>(null);
const [deleting, setDeleting] = useState(false);
const [search, setSearch] = useState('');
const load = useCallback(async () => {
try { const r = await api.get('/accounts'); setAccounts(r.data.data); }
catch { toast('Failed to load', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { load(); }, [load]);
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
try { await api.delete(`/accounts/${deleteTarget.id}`); toast('Account deleted', 'success'); setDeleteTarget(null); load(); }
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setDeleting(false); }
}
const filtered = search
? accounts.filter((a: any) => a.name.toLowerCase().includes(search.toLowerCase()) || (a.accountNo || '').toLowerCase().includes(search.toLowerCase()))
: accounts;
return (
<div>
<div className="flex items-center justify-between mb-5">
<p className="text-sm text-surface-500 dark:text-surface-400">Manage company bank accounts, e-wallets, and cash accounts.</p>
<Button onClick={() => setShowCreate(true)}>Add Account</Button>
</div>
<DataTable
data={filtered}
loading={loading}
keyExtractor={(a: any) => a.id}
emptyTitle="No accounts yet"
emptyDescription="Add your first company account."
searchPlaceholder="Search by name or account #..."
searchValue={search}
onSearchChange={setSearch}
columns={[
{ key: 'name', label: 'Name', sortable: true, render: (a: any) => (
<div className="flex items-center gap-2">
<span className="font-medium text-surface-800 dark:text-surface-200">{a.name}</span>
{a.isSystem && <Badge label="System" variant="default" />}
</div>
)},
{ key: 'type', label: 'Type', render: (a: any) => <Badge label={typeLabels[a.type] || a.type} variant={typeBadgeVariant[a.type] || 'default'} /> },
{ key: 'accountNo', label: 'Account #', render: (a: any) => <span className="font-mono text-xs text-surface-400">{a.accountNo || '—'}</span> },
{ key: 'balance', label: 'Balance', align: 'right' as const, render: (a: any) => <span className="text-sm font-bold text-surface-900 dark:text-surface-100">PHP {Number(a.balance).toLocaleString()}</span> },
{
key: 'actions', label: '', align: 'right',
render: (a: any) => (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
{!a.isSystem && (
<ActionIcon icon="trash" variant="danger" label="Delete" onClick={() => setDeleteTarget(a)} />
)}
</div>
),
},
]}
/>
<div className="mt-4 bg-surface-50 dark:bg-surface-800 rounded-lg p-4 text-sm text-surface-500 dark:text-surface-400">
<strong className="text-surface-700 dark:text-surface-300">Note:</strong> "Cash on Hand" is a system default account and cannot be deleted or renamed.
New accounts automatically create a corresponding Chart of Accounts entry under Assets.
</div>
<CreateAccountModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={load} />
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="Delete Account"
description={`Delete "${deleteTarget?.name}"? The linked Chart of Accounts entry will also be removed. Transfer funds out first if balance > 0.`}
variant="danger" confirmLabel="Delete Account" onConfirm={handleDelete} loading={deleting} />
</div>
);
}
function CreateAccountModal({ open, onClose, onSuccess }: { open: boolean; onClose: () => void; onSuccess: () => void }) {
const { toast } = useToast();
const [form, setForm] = useState({ name: '', type: 'bank', accountNo: '', initialBalance: 0 });
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 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({ name: '', type: 'bank', accountNo: '', initialBalance: 0 }); }, [open]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try { await api.post('/accounts', { ...form, accountNo: form.accountNo || undefined, initialBalance: form.initialBalance || undefined }); toast('Account created with linked CoA entry', 'success'); onSuccess(); onClose(); }
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="Add Company Account" description="A Chart of Accounts entry will be auto-created under Assets.">
<form onSubmit={handleSubmit} className="space-y-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Account Name</label><input type="text" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={ic} placeholder="e.g. BDO Savings, GCash Business" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Type</label><select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} className={ic}><option value="bank">Bank</option><option value="e_wallet">E-Wallet</option><option value="cash">Cash</option></select></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Account Number</label><input type="text" value={form.accountNo} onChange={(e) => setForm({ ...form, accountNo: e.target.value })} className={ic} placeholder="Optional" /></div>
</div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Initial Balance (PHP)</label><input type="number" min={0} step={0.01} value={form.initialBalance || ''} onChange={(e) => setForm({ ...form, initialBalance: parseFloat(e.target.value) || 0 })} 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}>Create Account</Button></div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
const SETTINGS_TABS = [
{ label: 'General', href: '/dashboard/settings', icon: settingsIcon(), roles: ['tenant_admin'] },
{ label: 'Billing', href: '/dashboard/settings/billing', icon: settingsIcon(), roles: ['tenant_admin'] },
{ label: 'Company Accounts', href: '/dashboard/settings/company-accounts', icon: settingsIcon(), roles: ['tenant_admin'] },
{ label: 'Chart of Accounts', href: '/dashboard/settings/chart-of-accounts', icon: settingsIcon(), roles: ['tenant_admin'] },
{ label: 'Users', href: '/dashboard/settings/users', icon: usersIcon(), roles: ['tenant_admin'] },
{ label: 'Roles', href: '/dashboard/settings/roles', icon: rolesIcon(), roles: ['tenant_admin'] },
{ label: 'Plans', href: '/dashboard/settings/plans', icon: plansIcon(), roles: ['manager'] },
{ label: 'Areas', href: '/dashboard/settings/areas', icon: areasIcon(), roles: ['manager'] },
{ label: 'Support', href: '/dashboard/settings/support', icon: supportIcon(), roles: ['tenant_admin'] },
];
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const hasAnyRole = useAuthStore((s) => s.hasAnyRole);
const visibleTabs = SETTINGS_TABS.filter((t) => hasAnyRole(t.roles));
return (
<div>
<div className="mb-6">
<h1 className="text-xl font-bold text-surface-900 dark:text-surface-100">Settings</h1>
<p className="mt-1 text-sm text-surface-500">Manage your ISP configuration</p>
</div>
<nav aria-label="Settings navigation" className="flex gap-1 border-b border-surface-200 mb-6">
{visibleTabs.map((tab) => {
const isActive =
tab.href === '/dashboard/settings'
? pathname === '/dashboard/settings'
: pathname.startsWith(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
aria-current={isActive ? 'page' : undefined}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors duration-200 ${
isActive
? 'border-primary-600 text-primary-700'
: 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300'
}`}
>
<span aria-hidden="true" className={isActive ? 'text-primary-600' : 'text-surface-400'}>
{tab.icon}
</span>
{tab.label}
</Link>
);
})}
</nav>
{children}
</div>
);
}
function settingsIcon() {
return (
<svg width="16" height="16" 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>
);
}
function usersIcon() {
return (
<svg width="16" height="16" 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>
);
}
function plansIcon() {
return (
<svg width="16" height="16" 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>
);
}
function areasIcon() {
return (
<svg width="16" height="16" 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>
);
}
function rolesIcon() {
return (
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M11.25 3.75h3.75a.75.75 0 01.75.75v3.75" /><path d="M15.75 3.75L9.75 9.75" /><path d="M7.5 2.25H4.5a1.5 1.5 0 00-1.5 1.5v10.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V9" />
</svg>
);
}
function supportIcon() {
return (
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="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>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
import { Skeleton } from '@/components/ui/skeleton';
interface TenantSettings {
id: string;
name: string;
slug: string;
settings: Record<string, unknown>;
}
const INPUT_CLASS = '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';
export default function SettingsPage() {
const { toast } = useToast();
const hasRole = useAuthStore((s) => s.hasRole);
const [tenant, setTenant] = useState<TenantSettings | null>(null);
const [name, setName] = useState('');
const [companyName, setCompanyName] = useState('');
const [currency, setCurrency] = useState('PHP');
const [timezone, setTimezone] = useState('Asia/Manila');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get<{ data: TenantSettings }>('/tenant')
.then((res) => {
const t = res.data.data;
setTenant(t);
setName(t.name);
const s = t.settings as Record<string, string>;
setCompanyName(s.companyName || '');
setCurrency(s.currency || 'PHP');
setTimezone(s.timezone || 'Asia/Manila');
})
.catch(() => toast('Failed to load settings', 'error'))
.finally(() => setLoading(false));
}, [toast]);
if (!hasRole('tenant_admin')) {
return <div className="text-red-600 font-medium" role="alert">Access denied. Admin role required.</div>;
}
if (loading) {
return (
<div className="max-w-lg space-y-4">
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
);
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
await api.patch('/tenant', { name, settings: { companyName, currency, timezone } });
toast('Settings saved successfully', 'success');
} catch {
toast('Failed to save settings', 'error');
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSave} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700 p-6 space-y-5 max-w-lg">
<div>
<label htmlFor="slug" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Tenant Slug</label>
<input id="slug" type="text" disabled value={tenant?.slug || ''} aria-describedby="slug-hint"
className="mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-700 px-3.5 py-2.5 text-sm text-surface-400 cursor-not-allowed" />
<p id="slug-hint" className="mt-1 text-xs text-surface-400 dark:text-surface-500">Cannot be changed after creation</p>
</div>
<div>
<label htmlFor="tenant-name" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Tenant Name</label>
<input id="tenant-name" type="text" required value={name} onChange={(e) => setName(e.target.value)} className={INPUT_CLASS} />
</div>
<div>
<label htmlFor="company-name" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Company Name</label>
<input id="company-name" type="text" value={companyName} onChange={(e) => setCompanyName(e.target.value)} className={INPUT_CLASS} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="currency" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Currency</label>
<select id="currency" value={currency} onChange={(e) => setCurrency(e.target.value)} className={INPUT_CLASS}>
<option value="PHP">PHP (Philippine Peso)</option>
<option value="USD">USD (US Dollar)</option>
</select>
</div>
<div>
<label htmlFor="timezone" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Timezone</label>
<select id="timezone" value={timezone} onChange={(e) => setTimezone(e.target.value)} className={INPUT_CLASS}>
<option value="Asia/Manila">Asia/Manila (PHT)</option>
<option value="UTC">UTC</option>
</select>
</div>
</div>
<Button type="submit" loading={saving}>Save Settings</Button>
</form>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { ActionIcon } from '@/components/ui/action-icon';
import { Modal } from '@/components/ui/modal';
import { FormModal } from '@/components/ui/form-modal';
import { useToast } from '@/components/ui/toast';
interface Plan {
id: string;
name: string;
description: string | null;
speedDown: number;
speedUp: number;
price: string;
billingCycle: number;
isActive: boolean;
_count: { subscriptions: number };
}
export default function PlansPage() {
const { toast } = useToast();
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Plan | null>(null);
const [deleting, setDeleting] = useState(false);
const [search, setSearch] = useState('');
const loadPlans = useCallback(async () => {
try {
const res = await api.get<{ data: Plan[] }>('/plans');
setPlans(res.data.data);
} catch { toast('Failed to load plans', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { loadPlans(); }, [loadPlans]);
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(`/plans/${deleteTarget.id}`);
toast(`Plan "${deleteTarget.name}" deleted`, 'success');
setDeleteTarget(null);
loadPlans();
} catch (err: any) { toast(err.response?.data?.error || 'Failed to delete', 'error'); }
finally { setDeleting(false); }
}
const filtered = search
? plans.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()) || (p.description || '').toLowerCase().includes(search.toLowerCase()))
: plans;
return (
<div>
<div className="flex items-center justify-between mb-5">
<p className="text-sm text-surface-500">Configure internet plans with speed tiers and pricing</p>
<Button onClick={() => setShowCreate(!showCreate)} variant="primary">Add Plan</Button>
</div>
<DataTable
data={filtered}
loading={loading}
keyExtractor={(p) => p.id}
emptyTitle="No plans yet"
emptyDescription="Create your first internet plan to start onboarding clients."
searchPlaceholder="Search plans..."
searchValue={search}
onSearchChange={setSearch}
columns={[
{ key: 'name', label: 'Name', sortable: true, render: (p: Plan) => (
<div>
<span className="font-medium text-surface-800 dark:text-surface-200">{p.name}</span>
{p.description && <p className="text-xs text-surface-400 dark:text-surface-500">{p.description}</p>}
</div>
)},
{ key: 'speed', label: 'Speed', render: (p: Plan) => <span className="text-sm text-surface-600 dark:text-surface-300">{p.speedDown}/{p.speedUp} Mbps</span> },
{ key: 'price', label: 'Price', align: 'right' as const, sortable: true, render: (p: Plan) => <span className="text-sm font-medium text-surface-900 dark:text-surface-100">PHP {Number(p.price).toLocaleString()}</span> },
{ key: 'billingCycle', label: 'Cycle', render: (p: Plan) => <span className="text-sm text-surface-500 dark:text-surface-400">{p.billingCycle} days</span> },
{ key: 'subscriptions', label: 'Subscribers', align: 'right' as const, render: (p: Plan) => <span className="text-sm text-surface-600 dark:text-surface-300">{p._count.subscriptions}</span> },
{ key: 'isActive', label: 'Status', sortable: true, render: (p: Plan) => <Badge label={p.isActive ? 'Active' : 'Inactive'} variant={p.isActive ? 'success' : 'error'} /> },
{
key: 'actions', label: '', align: 'right',
render: (p: Plan) => (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
{p._count.subscriptions === 0 && (
<ActionIcon icon="trash" variant="danger" label="Delete" onClick={() => setDeleteTarget(p)} />
)}
</div>
),
},
]}
/>
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="Delete Plan"
description={`Are you sure you want to delete "${deleteTarget?.name}"? This action cannot be undone.`}
variant="danger" confirmLabel="Delete Plan" onConfirm={handleDelete} loading={deleting} />
{showCreate && (
<CreatePlanModal onCreated={() => { setShowCreate(false); loadPlans(); toast('Plan created', 'success'); }} onClose={() => setShowCreate(false)} />
)}
</div>
);
}
function CreatePlanModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
const { toast } = useToast();
const [form, setForm] = useState({ name: '', description: '', speedDown: 25, speedUp: 25, price: 999, billingCycle: 30 });
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';
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
await api.post('/plans', { ...form, description: form.description || undefined });
onCreated();
} catch (err: any) { toast(err.response?.data?.error || 'Failed to create plan', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open onClose={onClose} title="Create Plan" description="Configure a new internet plan with speed tiers and pricing">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="plan-name" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Plan Name</label>
<input id="plan-name" type="text" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
className={ic} placeholder="e.g. Basic 25" />
</div>
<div>
<label htmlFor="plan-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>
<input id="plan-desc" type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className={ic} />
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Download (Mbps)</label>
<input type="number" required min={1} value={form.speedDown} onChange={(e) => setForm({ ...form, speedDown: parseInt(e.target.value) || 0 })} className={ic} /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Upload (Mbps)</label>
<input type="number" required min={1} value={form.speedUp} onChange={(e) => setForm({ ...form, speedUp: parseInt(e.target.value) || 0 })} className={ic} /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Price (PHP)</label>
<input type="number" required min={1} step={0.01} value={form.price} onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} className={ic} /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Billing Cycle (days)</label>
<input type="number" required min={1} value={form.billingCycle} onChange={(e) => setForm({ ...form, billingCycle: parseInt(e.target.value) || 30 })} className={ic} /></div>
</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 Plan</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,406 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Modal } from '@/components/ui/modal';
import { DataTable } from '@/components/ui/data-table';
import { ActionIcon } from '@/components/ui/action-icon';
import { useToast } from '@/components/ui/toast';
/* ── Constants ────────────────────────────────────────────── */
const MODULES = [
'dashboard', 'clients', 'subscriptions', 'invoices', 'payments', 'tickets',
'employees', 'payroll', 'expenses', 'assets', 'accounts', 'fund_transfers',
'accounting', 'reports', 'areas', 'plans', 'settings', 'users',
] as const;
const MODULE_LABELS: Record<string, string> = {
dashboard: 'Dashboard', clients: 'Clients', subscriptions: 'Subscriptions',
invoices: 'Invoices', payments: 'Payments', tickets: 'Tickets',
employees: 'Employees', payroll: 'Payroll', expenses: 'Expenses',
assets: 'Assets', accounts: 'Accounts', fund_transfers: 'Fund Transfers',
accounting: 'Accounting', reports: 'Reports', areas: 'Areas',
plans: 'Plans', settings: 'Settings', users: 'Users',
};
const ACTIONS = ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'] as const;
const ACTION_LABELS: Record<string, string> = {
canView: 'View', canCreate: 'Create', canUpdate: 'Update',
canArchive: 'Archive', canApprove: 'Approve', canExport: 'Export',
};
/* ── Types ─────────────────────────────────────────────────── */
interface TenantRole {
id: string;
name: string;
slug: string;
description: string;
isSystem: boolean;
isActive: boolean;
permissions: RolePermission[];
userCount: number;
}
interface RolePermission {
id: string;
module: string;
canView: boolean;
canCreate: boolean;
canUpdate: boolean;
canArchive: boolean;
canApprove: boolean;
canExport: boolean;
}
interface RoleFormData {
name: string;
slug: string;
description: string;
permissions: Record<string, Record<string, boolean>>;
}
/* ── Helpers ───────────────────────────────────────────────── */
function emptyPermissions(): Record<string, Record<string, boolean>> {
const perms: Record<string, Record<string, boolean>> = {};
for (const m of MODULES) {
perms[m] = { canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}
return perms;
}
function permissionsFromRole(role: TenantRole): Record<string, Record<string, boolean>> {
const perms = emptyPermissions();
for (const p of role.permissions) {
if (!perms[p.module]) perms[p.module] = { canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
perms[p.module]['canView'] = p.canView;
perms[p.module]['canCreate'] = p.canCreate;
perms[p.module]['canUpdate'] = p.canUpdate;
perms[p.module]['canArchive'] = p.canArchive;
perms[p.module]['canApprove'] = p.canApprove;
perms[p.module]['canExport'] = p.canExport;
}
return perms;
}
function permissionCount(perms: Record<string, Record<string, boolean>>): number {
return Object.values(perms).reduce((sum, actions) => sum + Object.values(actions).filter(Boolean).length, 0);
}
/* ── Page Component ────────────────────────────────────────── */
export default function RolesPage() {
const { toast } = useToast();
const [roles, setRoles] = useState<TenantRole[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [editingRole, setEditingRole] = useState<TenantRole | null>(null);
const [showCreate, setShowCreate] = useState(false);
const loadRoles = useCallback(async () => {
try {
const res = await api.get<{ data: TenantRole[] }>('/roles');
setRoles(res.data.data);
} catch {
toast('Failed to load roles', 'error');
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => { loadRoles(); }, [loadRoles]);
/* Actions */
async function handleDuplicate(id: string, name: string) {
const newName = `${name} (Copy)`;
const input = prompt('Enter a name for the duplicated role:', newName);
if (!input) return;
try {
await api.post(`/roles/${id}/duplicate`, { name: input });
toast(`Role "${input}" created`, 'success');
await loadRoles();
} catch { toast('Failed to duplicate role', 'error'); }
}
async function handleDelete(id: string, name: string) {
if (!confirm(`Are you sure you want to delete "${name}"?`)) return;
try {
await api.delete(`/roles/${id}`);
toast('Role deleted', 'success');
await loadRoles();
} catch { toast('Failed to delete role', 'error'); }
}
function openEdit(role: TenantRole) {
setEditingRole(role);
setShowCreate(false);
}
function openCreate() {
setEditingRole(null);
setShowCreate(true);
}
function closeModal() {
setEditingRole(null);
setShowCreate(false);
}
const filtered = search
? roles.filter((r) => r.name.toLowerCase().includes(search.toLowerCase()) || r.slug.toLowerCase().includes(search.toLowerCase()) || (r.description || '').toLowerCase().includes(search.toLowerCase()))
: roles;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-sm text-surface-500">Manage tenant roles and their permission matrix</p>
<Button onClick={openCreate}>Add Role</Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin h-8 w-8 border-4 border-surface-200 rounded-full" />
</div>
) : (
<DataTable
data={filtered}
loading={loading}
keyExtractor={(r) => r.id}
emptyTitle="No roles found"
searchPlaceholder="Search roles..."
searchValue={search}
onSearchChange={setSearch}
onRowClick={(r: TenantRole) => openEdit(r)}
columns={[
{
key: 'name', label: 'Role', sortable: true,
render: (r: TenantRole) => (
<div>
<div className="font-medium text-surface-800 dark:text-surface-200">{r.name}</div>
{r.description && <div className="text-xs text-surface-400 dark:text-surface-500">{r.description}</div>}
</div>
),
},
{ key: 'slug', label: 'Slug', sortable: true, render: (r: TenantRole) => <span className="text-surface-500 dark:text-surface-400 text-sm">{r.slug}</span> },
{
key: 'users', label: 'Users', sortable: true,
render: (r: TenantRole) => <span className="text-sm text-surface-600 dark:text-surface-300">{r.userCount || 0}</span>,
},
{
key: 'permissions', label: 'Permissions', sortable: false,
render: (r: TenantRole) => (
<span className="text-xs text-surface-400 dark:text-surface-500">
{permissionCount(permissionsFromRole(r))} / {MODULES.length * ACTIONS.length}
</span>
),
},
{
key: 'status', label: 'Status',
render: (r: TenantRole) => (
<div className="flex gap-1">
{r.isSystem && <Badge label="System" variant="info" />}
<Badge label={r.isActive ? 'Active' : 'Inactive'} variant={r.isActive ? 'success' : 'error'} />
</div>
),
},
{
key: 'actions', label: '', align: 'right',
render: (r: TenantRole) => (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<ActionIcon icon="copy" label="Duplicate" onClick={() => handleDuplicate(r.id, r.name)} />
{!r.isSystem && (
<ActionIcon icon="trash" label="Delete" variant="danger" onClick={() => handleDelete(r.id, r.name)} />
)}
</div>
),
},
]}
/>
)}
{/* Create / Edit Modal */}
{(showCreate || editingRole) && (
<RoleEditorModal
role={editingRole}
onClose={closeModal}
onSaved={() => { closeModal(); loadRoles(); }}
/>
)}
</div>
);
}
/* ── Role Editor Modal ─────────────────────────────────────── */
function RoleEditorModal({ role, onClose, onSaved }: {
role: TenantRole | null;
onClose: () => void;
onSaved: () => void;
}) {
const { toast } = useToast();
const isEdit = !!role;
const [formData, setFormData] = useState<RoleFormData>({
name: role?.name ?? '',
slug: role?.slug ?? '',
description: role?.description ?? '',
permissions: role ? permissionsFromRole(role) : emptyPermissions(),
});
const [saving, setSaving] = useState(false);
function togglePerm(module: string, action: string) {
setFormData((prev) => ({
...prev,
permissions: {
...prev.permissions,
[module]: { ...prev.permissions[module], [action]: !prev.permissions[module][action] },
},
}));
}
function toggleAll(module: string, value: boolean) {
setFormData((prev) => ({
...prev,
permissions: {
...prev.permissions,
[module]: { canView: value, canCreate: value, canUpdate: value, canArchive: value, canApprove: value, canExport: value },
},
}));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
const permissionData = Object.entries(formData.permissions).flatMap(([module, actions]) =>
Object.entries(actions).map(([action, value]) => ({ module, [action]: value })),
);
const payload = {
name: formData.name,
slug: formData.slug.toLowerCase().replace(/\s+/g, '_'),
description: formData.description,
permissions: permissionData,
};
try {
if (isEdit && role) {
await api.patch(`/roles/${role.id}`, payload);
toast('Role updated', 'success');
} else {
await api.post('/roles', payload);
toast('Role created', 'success');
}
onSaved();
} catch {
toast(`Failed to ${isEdit ? 'update' : 'create'} role`, 'error');
} finally {
setSaving(false);
}
}
return (
<Modal
open
onClose={onClose}
title={isEdit ? `Edit Role: ${role.name}` : 'Create New Role'}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-5 max-h-[80vh] overflow-y-auto pr-1">
{/* Name / Slug */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Name</label>
<input type="text" required value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3 py-2 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"
placeholder="Sales Manager" />
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Slug</label>
<input type="text" required value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3 py-2 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"
placeholder="sales_manager" />
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Description</label>
<textarea value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3 py-2 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"
rows={2} placeholder="Brief description" />
</div>
{/* Permission Matrix */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-surface-700 dark:text-surface-300">Permissions</label>
<div className="flex gap-2">
<Button size="sm" variant="ghost" type="button"
onClick={() => {
const perms = emptyPermissions();
for (const m of MODULES) perms[m] = { canView: true, canCreate: true, canUpdate: true, canArchive: true, canApprove: true, canExport: true };
setFormData((prev) => ({ ...prev, permissions: perms }));
}}>
Select All
</Button>
<Button size="sm" variant="ghost" type="button"
onClick={() => setFormData((prev) => ({ ...prev, permissions: emptyPermissions() }))}>
Clear All
</Button>
</div>
</div>
<div className="overflow-x-auto border border-surface-200 dark:border-surface-600 rounded-lg">
<table className="w-full text-xs">
<thead className="bg-surface-50 dark:bg-surface-800 sticky top-0">
<tr className="border-b border-surface-200 dark:border-surface-600">
<th className="px-3 py-2 text-left font-semibold text-surface-600 dark:text-surface-300 min-w-[120px]">Module</th>
{ACTIONS.map((a) => (
<th key={a} className="px-2 py-2 text-center font-semibold text-surface-600 dark:text-surface-300 w-16">{ACTION_LABELS[a]}</th>
))}
<th className="px-2 py-2 text-center w-14 text-surface-600 dark:text-surface-300">All</th>
</tr>
</thead>
<tbody>
{MODULES.map((module) => {
const allChecked = ACTIONS.every((a) => formData.permissions[module]?.[a]);
return (
<tr key={module} className="border-b border-surface-100 dark:border-surface-700 last:border-b-0 hover:bg-surface-50/50 dark:hover:bg-surface-700/50">
<td className="px-3 py-1.5 font-medium text-surface-700 dark:text-surface-300">{MODULE_LABELS[module]}</td>
{ACTIONS.map((action) => (
<td key={action} className="px-2 py-1.5 text-center">
<input type="checkbox"
checked={formData.permissions[module]?.[action] ?? false}
onChange={() => togglePerm(module, action)}
className="w-3.5 h-3.5 cursor-pointer rounded" />
</td>
))}
<td className="px-2 py-1.5 text-center">
<input type="checkbox"
checked={allChecked}
onChange={() => toggleAll(module, !allChecked)}
className="w-3.5 h-3.5 cursor-pointer rounded" />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-surface-200 dark:border-surface-700">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={saving} disabled={!formData.name || !formData.slug}>
{isEdit ? 'Update' : 'Create'} Role
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,235 @@
'use client';
import { useEffect, useState, useRef, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/ui/data-table';
import { FormModal } from '@/components/ui/form-modal';
import { useToast } from '@/components/ui/toast';
const ADMIN_API_URL = process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api';
interface Ticket {
id: string;
subject: string;
category: string;
priority: string;
status: string;
createdAt: string;
assignee: { firstName: string; lastName: string } | null;
_count: { comments: number };
}
const statusColors: Record<string, string> = {
open: 'bg-yellow-50 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400',
in_progress: 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
waiting_tenant: 'bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400',
resolved: 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400',
closed: 'bg-surface-100 dark:bg-surface-700 text-surface-500 dark:text-surface-400',
};
const priorityColors: Record<string, string> = {
low: 'text-surface-500 dark:text-surface-400',
normal: 'text-blue-600 dark:text-blue-400',
high: 'text-orange-600 dark:text-orange-400',
urgent: 'text-red-600 dark:text-red-400',
};
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';
}
const STATUS_OPTIONS = [
{ label: 'Open', value: 'open' },
{ label: 'In Progress', value: 'in_progress' },
{ label: 'Waiting', value: 'waiting_tenant' },
{ label: 'Resolved', value: 'resolved' },
{ label: 'Closed', value: 'closed' },
];
export default function SupportSettingsPage() {
const { toast } = useToast();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [showNew, setShowNew] = useState(false);
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 [search, setSearch] = useState('');
const [activeFilters, setActiveFilters] = useState<Record<string, string>>({});
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { loadTickets(); }, []);
function authHeaders() {
const token = localStorage.getItem('accessToken');
return { Authorization: `Bearer ${token}` };
}
async function loadTickets() {
setLoading(true);
try {
const res = await fetch(`${ADMIN_API_URL}/public/support/tickets`, { headers: authHeaders() });
if (!res.ok) { setTickets([]); return; }
const data = await res.json();
setTickets(Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : []);
} catch { setTickets([]); }
finally { setLoading(false); }
}
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');
setShowNew(false); setSubject(''); setDescription(''); setCategory('general'); setPriority('normal'); setSelectedFiles([]);
loadTickets();
} catch { toast('Failed to create ticket', 'error'); }
finally { setSubmitting(false); }
}
function handleFilterChange(key: string, value: string) {
setActiveFilters((prev) => {
const next = { ...prev };
if (value) next[key] = value;
else delete next[key];
return next;
});
}
const filtered = useMemo(() => {
let result = tickets;
if (search) {
const q = search.toLowerCase();
result = result.filter((t) =>
t.subject.toLowerCase().includes(q) ||
t.category.replace(/_/g, ' ').toLowerCase().includes(q)
);
}
if (activeFilters.status) {
result = result.filter((t) => t.status === activeFilters.status);
}
return result;
}, [tickets, search, activeFilters]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-surface-900 dark:text-surface-100">Support Tickets</h2>
<p className="text-sm text-surface-500 dark:text-surface-400">Get help from the FiberOps team</p>
</div>
<Button onClick={() => setShowNew(true)}>New Ticket</Button>
</div>
{showNew && (
<FormModal open onClose={() => { setShowNew(false); setSelectedFiles([]); }} title="New Support Ticket" description="Describe your issue and we'll get back to you">
<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 dark:border-surface-600 bg-white dark:bg-surface-800 rounded-lg text-sm text-surface-900 dark:text-surface-100 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-4 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)]); 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 dark:text-surface-500 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 dark:text-surface-300">{f.name}</span>
<span className="text-surface-400 dark:text-surface-500">({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 pt-2">
<Button type="button" variant="secondary" onClick={() => { setShowNew(false); setSelectedFiles([]); }}>Cancel</Button>
<Button type="submit" loading={submitting}>Submit Ticket</Button>
</div>
</form>
</FormModal>
)}
<DataTable
data={filtered}
loading={loading}
keyExtractor={(t) => t.id}
emptyTitle="No support tickets yet"
emptyDescription="Create a ticket to get help from the FiberOps team"
searchPlaceholder="Search tickets"
searchValue={search}
onSearchChange={setSearch}
quickFilters={[{ key: 'status', label: 'Status', options: STATUS_OPTIONS }]}
activeFilters={activeFilters}
onFilterChange={handleFilterChange}
columns={[
{ key: 'subject', label: 'Subject', sortable: true, render: (t: Ticket) => <span className="font-medium text-surface-800 dark:text-surface-200">{t.subject}</span> },
{ key: 'category', label: 'Category', render: (t: Ticket) => <span className="text-surface-500 dark:text-surface-400 capitalize">{t.category.replace(/_/g, ' ')}</span> },
{ key: 'priority', label: 'Priority', align: 'center' as const, render: (t: Ticket) => <span className={`text-xs font-semibold capitalize ${priorityColors[t.priority] || ''}`}>{t.priority}</span> },
{ key: 'status', label: 'Status', align: 'center' as const, render: (t: Ticket) => (
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusColors[t.status] || ''}`}>
{t.status.replace(/_/g, ' ')}
</span>
)},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,327 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { DataTable } from '@/components/ui/data-table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Modal } from '@/components/ui/modal';
import { FormModal } from '@/components/ui/form-modal';
import { ActionIcon } from '@/components/ui/action-icon';
import { useToast } from '@/components/ui/toast';
interface TenantRole {
id: string;
name: string;
slug: string;
isSystem: boolean;
}
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
isActive: boolean;
tenantRoles: TenantRole[];
createdAt: string;
}
export default function UsersSettingsPage() {
const { toast } = useToast();
const canAccess = useAuthStore((s) => s.canAccess);
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<TenantRole[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [toggleTarget, setToggleTarget] = useState<User | null>(null);
const loadData = useCallback(async () => {
try {
const [usersRes, rolesRes] = await Promise.all([
api.get<{ data: User[] }>('/users'),
api.get<{ data: TenantRole[] }>('/roles'),
]);
setUsers(usersRes.data.data);
setRoles(rolesRes.data.data);
} catch {
toast('Failed to load data', 'error');
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
loadData();
}, [loadData]);
async function handleToggle() {
if (!toggleTarget) return;
try {
await api.patch(`/users/${toggleTarget.id}/toggle-active`);
toast(`${toggleTarget.firstName} ${toggleTarget.lastName} ${toggleTarget.isActive ? 'deactivated' : 'activated'}`, 'success');
setToggleTarget(null);
loadData();
} catch {
toast('Failed to update user', 'error');
}
}
async function handleUpdateRoles(userId: string, roleIds: string[]) {
try {
await api.patch(`/users/${userId}`, { tenantRoleIds: roleIds });
toast('User roles updated', 'success');
setEditUser(null);
loadData();
} catch {
toast('Failed to update user roles', 'error');
}
}
const filtered = search
? users.filter((u) =>
`${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase()),
)
: users;
return (
<div className="space-y-6">
<div className="flex items-center justify-between mb-5">
<p className="text-sm text-surface-500">Manage user accounts and assign tenant roles</p>
<Button onClick={() => setShowCreate(true)}>Add User</Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin h-8 w-8 border-4 border-surface-200 rounded-full" />
</div>
) : (
<DataTable
data={filtered}
loading={loading}
keyExtractor={(u) => u.id}
emptyTitle="No users found"
searchPlaceholder="Search by name or email..."
searchValue={search}
onSearchChange={setSearch}
onRowClick={(u: User) => setEditUser(u)}
columns={[
{ key: 'name', label: 'Name', sortable: true, render: (u: User) => <span className="font-medium text-surface-800 dark:text-surface-200">{u.firstName} {u.lastName}</span> },
{ key: 'email', label: 'Email', sortable: true, render: (u: User) => <span className="text-surface-500 dark:text-surface-400">{u.email}</span> },
{
key: 'roles',
label: 'Roles',
sortable: false,
render: (u: User) => (
<div className="flex flex-wrap gap-1">
{u.tenantRoles && u.tenantRoles.length > 0 ? (
u.tenantRoles.map((tr) => (
<Badge key={tr.id} label={tr.name} variant="info" />
))
) : (
<span className="text-xs text-surface-400 dark:text-surface-500">No roles</span>
)}
</div>
),
},
{ key: 'isActive', label: 'Status', render: (u: User) => <Badge label={u.isActive ? 'Active' : 'Inactive'} variant={u.isActive ? 'success' : 'error'} /> },
{
key: 'actions',
label: '',
align: 'right',
render: (u: User) => (
<div className="flex justify-end gap-1" onClick={(e) => e.stopPropagation()}>
<ActionIcon icon="edit" variant="ghost" label="Edit Roles" onClick={() => setEditUser(u)} />
<ActionIcon icon={u.isActive ? 'user-x' : 'user-check'} variant={u.isActive ? 'danger' : 'primary'} label={u.isActive ? 'Deactivate' : 'Activate'} onClick={() => setToggleTarget(u)} />
</div>
),
},
]}
/>
)}
{/* Create User Modal */}
{showCreate && (
<CreateUserModal
availableRoles={roles}
onCreated={() => { setShowCreate(false); loadData(); toast('User created', 'success'); }}
onClose={() => setShowCreate(false)}
/>
)}
{/* Edit User Roles Modal */}
{editUser && (
<EditRolesModal
user={editUser}
availableRoles={roles}
onSave={(roleIds) => handleUpdateRoles(editUser.id, roleIds)}
onClose={() => setEditUser(null)}
/>
)}
{/* Toggle Active Modal */}
<Modal
open={!!toggleTarget}
onClose={() => setToggleTarget(null)}
title={toggleTarget?.isActive ? 'Deactivate User' : 'Activate User'}
description={toggleTarget ? `${toggleTarget.isActive ? 'Deactivate' : 'Activate'} ${toggleTarget.firstName} ${toggleTarget.lastName}? They will no longer be able to log in.` : ''}
variant={toggleTarget?.isActive ? 'danger' : 'default'}
confirmLabel={toggleTarget?.isActive ? 'Deactivate' : 'Activate'}
onConfirm={handleToggle}
/>
</div>
);
}
/* ── Edit Roles Modal ────────────────────────────────────── */
function EditRolesModal({ user, availableRoles, onSave, onClose }: {
user: User;
availableRoles: TenantRole[];
onSave: (roleIds: string[]) => void;
onClose: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(() =>
new Set(user.tenantRoles.map((r) => r.id)),
);
function toggle(roleId: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(roleId)) next.delete(roleId);
else next.add(roleId);
return next;
});
}
return (
<Modal open onClose={onClose} title={`Edit Roles: ${user.firstName} ${user.lastName}`}>
<div className="space-y-4">
<div className="space-y-2">
{availableRoles.length > 0 ? availableRoles.map((role) => {
const isAssigned = selected.has(role.id);
return (
<label
key={role.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
isAssigned ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30' : 'border-surface-200 dark:border-surface-600 hover:border-surface-300 dark:hover:border-surface-500'
}`}
>
<input
type="checkbox"
checked={isAssigned}
onChange={() => toggle(role.id)}
className="w-4 h-4 cursor-pointer"
/>
<div>
<div className="font-medium text-surface-800 dark:text-surface-200">{role.name}</div>
<div className="text-xs text-surface-400 dark:text-surface-500">{role.slug}</div>
</div>
</label>
);
}) : (
<p className="text-surface-400 dark:text-surface-500 text-sm">No roles available. Create roles in Settings &gt; Roles.</p>
)}
</div>
<div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={() => onSave(Array.from(selected))}>Save Changes</Button>
</div>
</div>
</Modal>
);
}
/* ── Create User Modal ────────────────────────────────────── */
function CreateUserModal({ availableRoles, onCreated, onClose }: { availableRoles: TenantRole[]; onCreated: () => void; onClose: () => void }) {
const { toast } = useToast();
const [form, setForm] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
tenantRoleIds: [] as string[],
});
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
await api.post('/users', form);
onCreated();
} catch {
toast('Failed to create user', 'error');
} finally {
setSubmitting(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';
return (
<FormModal open onClose={onClose} title="Create User" description="Add a new user account and assign tenant roles">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="u-first" className="block text-sm font-medium text-surface-700 dark:text-surface-300">First Name</label>
<input id="u-first" type="text" required value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
className={ic} placeholder="First name" />
</div>
<div>
<label htmlFor="u-last" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Last Name</label>
<input id="u-last" type="text" required value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
className={ic} placeholder="Last name" />
</div>
</div>
<div>
<label htmlFor="u-email" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Email</label>
<input id="u-email" type="email" required value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className={ic} placeholder="user@example.com" />
</div>
<div>
<label htmlFor="u-pass" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Password</label>
<input id="u-pass" type="password" required minLength={8} value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className={ic} placeholder="Minimum 8 characters" />
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">Assign Roles</label>
<div className="space-y-2 max-h-40 overflow-y-auto">
{availableRoles.length > 0 ? availableRoles.map((role) => (
<label key={role.id} className="flex items-center gap-2 p-2 rounded-lg border border-surface-200 dark:border-surface-600 cursor-pointer hover:bg-surface-50 dark:hover:bg-surface-700 transition-all duration-200">
<input type="checkbox" checked={form.tenantRoleIds.includes(role.id)}
onChange={(e) => setForm({
...form,
tenantRoleIds: e.target.checked
? [...form.tenantRoleIds, role.id]
: form.tenantRoleIds.filter((id) => id !== role.id),
})}
className="w-4 h-4" />
<span className="font-medium text-surface-800 dark:text-surface-200">{role.name}</span>
</label>
)) : (
<p className="text-surface-400 dark:text-surface-500 text-sm">No roles available. Create roles in Settings &gt; Roles.</p>
)}
</div>
</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.tenantRoleIds.length === 0}>
Create User
</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { DataTable } from '@/components/ui/data-table';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { ActionIcon } from '@/components/ui/action-icon';
import { ActionMenu, ActionMenuItem } from '@/components/ui/action-menu';
import { Modal } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
interface Subscription {
id: string;
type: string;
status: string;
client: { id: string; firstName: string; lastName: string; accountNumber: string };
plan: { id: string; name: string; price: string; speedDown: number; speedUp: number };
createdAt: string;
}
export default function SubscriptionsPage() {
const { toast } = useToast();
const [subs, setSubs] = useState<Subscription[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [detailTarget, setDetailTarget] = useState<Subscription | null>(null);
const loadSubs = useCallback(async () => {
try {
const res = await api.get<{ data: Subscription[] }>('/subscriptions');
setSubs(res.data.data);
} catch {
toast('Failed to load subscriptions', 'error');
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => { loadSubs(); }, [loadSubs]);
async function handleAction(id: string, action: string) {
try {
await api.patch(`/subscriptions/${id}/${action}`);
toast(`Subscription ${action}d`, 'success');
loadSubs();
} catch {
toast(`Failed to ${action} subscription`, 'error');
}
}
const filtered = search
? subs.filter((s) => `${s.client.firstName} ${s.client.lastName} ${s.client.accountNumber} ${s.plan.name}`.toLowerCase().includes(search.toLowerCase()))
: subs;
function getActionItems(s: Subscription): ActionMenuItem[] {
const items: ActionMenuItem[] = [];
if (s.status === 'active') {
items.push({ icon: 'pause', label: 'Suspend', onClick: () => handleAction(s.id, 'suspend') });
}
if (s.status === 'suspended') {
items.push({ icon: 'play', label: 'Reactivate', onClick: () => handleAction(s.id, 'reactivate') });
}
if (['pending', 'active', 'suspended'].includes(s.status)) {
items.push({ icon: 'x-circle', label: 'Cancel', onClick: () => handleAction(s.id, 'cancel'), variant: 'danger' });
}
return items;
}
return (
<div>
<PageHeader title="Subscriptions" description="Manage client subscription plans and status" />
<div className="mt-5">
<DataTable data={filtered} loading={loading} keyExtractor={(s) => s.id}
emptyTitle="No subscriptions" emptyDescription="Subscriptions are created when clients sign up for plans."
searchPlaceholder="Search by client, account #, or plan..."
searchValue={search} onSearchChange={setSearch}
onRowClick={(s) => setDetailTarget(s)}
columns={[
{ key: 'client', label: 'Client', sortable: true, render: (s) => (
<div>
<span className="text-surface-800 dark:text-surface-200">{s.client.firstName} {s.client.lastName}</span>
<span className="ml-2 text-surface-400 font-mono text-xs">{s.client.accountNumber}</span>
</div>
)},
{ key: 'plan', label: 'Plan', sortable: true, render: (s) => <span className="text-surface-800 dark:text-surface-200">{s.plan.name}</span> },
{ key: 'type', label: 'Type', render: (s) => (
<Badge label={s.type} variant={s.type === 'postpaid' ? 'info' : 'default'} />
)},
{ key: 'status', label: 'Status', sortable: true, render: (s) => <Badge label={s.status} variant={statusBadgeVariant(s.status)} /> },
{ key: 'createdAt', label: 'Created', sortable: true, render: (s) => <span className="text-surface-500 dark:text-surface-400">{new Date(s.createdAt).toLocaleDateString()}</span> },
{ key: 'actions', label: '', align: 'right', render: (s) => {
const items = getActionItems(s);
return items.length > 0 ? (
<div onClick={(e) => e.stopPropagation()}>
<ActionMenu items={items} />
</div>
) : null;
}},
]}
/>
</div>
{/* Subscription Detail Modal */}
<Modal open={!!detailTarget} onClose={() => setDetailTarget(null)}
title="Subscription Details" description={detailTarget ? `${detailTarget.client.firstName} ${detailTarget.client.lastName}` : ''}>
{detailTarget && (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div><span className="text-surface-500 dark:text-surface-400">Account:</span> <span className="font-mono text-surface-700 dark:text-surface-300">{detailTarget.client.accountNumber}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Plan:</span> <span className="text-surface-800 dark:text-surface-200">{detailTarget.plan.name}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Speed:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.plan.speedDown}/{detailTarget.plan.speedUp} Mbps</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Price:</span> <span className="font-medium text-surface-900 dark:text-surface-100">PHP {Number(detailTarget.plan.price).toLocaleString()}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Type:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.type}</span></div>
<div><span className="text-surface-500 dark:text-surface-400">Status:</span> <Badge label={detailTarget.status} variant={statusBadgeVariant(detailTarget.status)} /></div>
<div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Created:</span> <span className="text-surface-700 dark:text-surface-300">{new Date(detailTarget.createdAt).toLocaleDateString()}</span></div>
</div>
<div className="flex justify-end pt-2 border-t border-surface-200">
<Button size="sm" variant="ghost" onClick={() => setDetailTarget(null)}>Close</Button>
</div>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -0,0 +1,355 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
const ADMIN_API_URL = process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api';
interface Attachment {
id: string;
fileName: string;
originalName: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
interface Comment {
id: string;
authorName: string;
authorType: string;
content: string;
createdAt: string;
attachments: Attachment[];
}
interface Ticket {
id: string;
subject: string;
description: string;
category: string;
priority: string;
status: string;
createdByName: string;
assignee: { firstName: string; lastName: string } | null;
comments: Comment[];
attachments: Attachment[];
createdAt: string;
}
const statusColors: Record<string, string> = {
open: 'bg-yellow-50 text-yellow-700',
in_progress: 'bg-blue-50 text-blue-700',
waiting_tenant: 'bg-orange-50 text-orange-700',
resolved: 'bg-green-50 text-green-700',
closed: 'bg-surface-100 text-surface-500',
};
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';
}
export default function TicketDetailPage() {
const params = useParams();
const router = useRouter();
const ticketId = params.id as string;
const [ticket, setTicket] = useState<Ticket | null>(null);
const [loading, setLoading] = useState(true);
const [comment, setComment] = useState('');
const [submitting, setSubmitting] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
useEffect(() => {
loadTicket();
}, [ticketId]);
function authHeaders() {
const token = localStorage.getItem('accessToken');
return { Authorization: `Bearer ${token}` };
}
async function loadTicket() {
try {
const res = await fetch(`${ADMIN_API_URL}/public/support/tickets/${ticketId}`, {
headers: authHeaders(),
});
const data = await res.json();
setTicket(data.data || data);
} catch {
// not found
} finally {
setLoading(false);
}
}
async function handleAddComment() {
if (!comment.trim() && selectedFiles.length === 0) return;
setSubmitting(true);
try {
// Add comment first
if (comment.trim()) {
await fetch(`${ADMIN_API_URL}/public/support/tickets/${ticketId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ content: comment }),
});
}
// Upload files if any
if (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,
});
}
setComment('');
setSelectedFiles([]);
loadTicket();
} catch (err) {
console.error('Failed to submit:', err);
} finally {
setSubmitting(false);
}
}
async function handleFileUpload(files: FileList) {
setUploading(true);
try {
const formData = new FormData();
Array.from(files).forEach((f) => formData.append('files', f));
await fetch(`${ADMIN_API_URL}/public/support/tickets/${ticketId}/attachments`, {
method: 'POST',
headers: authHeaders(),
body: formData,
});
loadTicket();
} catch (err) {
console.error('Failed to upload:', err);
} finally {
setUploading(false);
}
}
function handleDownload(fileName: string, originalName: string) {
const token = localStorage.getItem('accessToken');
window.open(`${ADMIN_API_URL}/public/support/uploads/${fileName}?token=${token}`, '_blank');
}
if (loading) return <div className="p-8 text-center text-surface-400">Loading...</div>;
if (!ticket) return <div className="p-8 text-center text-surface-400">Ticket not found</div>;
return (
<div className="space-y-6">
<button
onClick={() => router.push('/dashboard/support')}
className="text-sm text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300"
>
&larr; Back to support tickets
</button>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Ticket header */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6">
<div className="flex items-start justify-between mb-2">
<h1 className="text-xl font-semibold text-surface-900 dark:text-surface-100">{ticket.subject}</h1>
<span className={`inline-block px-2.5 py-1 rounded-full text-xs font-medium ${statusColors[ticket.status] || ''}`}>
{ticket.status.replace(/_/g, ' ')}
</span>
</div>
<p className="text-surface-600 dark:text-surface-300 whitespace-pre-wrap">{ticket.description}</p>
<div className="mt-4 text-xs text-surface-400">
Created {new Date(ticket.createdAt).toLocaleString()} by {ticket.createdByName}
</div>
</div>
{/* Ticket-level attachments */}
{ticket.attachments.length > 0 && (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4">
<h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3">Attachments</h3>
<div className="flex flex-wrap gap-2">
{ticket.attachments.map((a) => (
<button
key={a.id}
onClick={() => handleDownload(a.fileName, a.originalName)}
className="flex items-center gap-2 px-3 py-2 bg-surface-50 dark:bg-surface-700 border border-surface-200 dark:border-surface-600 rounded-lg text-sm hover:bg-surface-100 dark:hover:bg-surface-600 transition-colors"
>
{a.mimeType.startsWith('image/') ? (
<svg className="w-4 h-4 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
) : (
<svg className="w-4 h-4 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-4.586 4.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
)}
<span className="text-surface-700 dark:text-surface-300">{a.originalName}</span>
<span className="text-surface-400">({formatBytes(a.sizeBytes)})</span>
</button>
))}
</div>
</div>
)}
{/* Comments thread */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-surface-700 dark:text-surface-300">
Conversation ({ticket.comments.length})
</h3>
{ticket.comments.map((c) => (
<div
key={c.id}
className={`bg-white dark:bg-surface-800 rounded-lg border p-4 ${
c.authorType === 'super_admin' ? 'border-primary-200 bg-primary-50/30 dark:border-primary-700 dark:bg-primary-900/20' : 'border-surface-200 dark:border-surface-700'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-surface-700 dark:text-surface-300">{c.authorName}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
c.authorType === 'super_admin' ? 'bg-primary-100 text-primary-700' : 'bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400'
}`}>
{c.authorType === 'super_admin' ? 'FiberOps Team' : 'You'}
</span>
</div>
<span className="text-xs text-surface-400">{new Date(c.createdAt).toLocaleString()}</span>
</div>
<p className="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap">{c.content}</p>
{/* Comment attachments */}
{c.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-surface-100 dark:border-surface-700">
{c.attachments.map((a) => (
<button
key={a.id}
onClick={() => handleDownload(a.fileName, a.originalName)}
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 hover:bg-surface-100 dark:hover:bg-surface-600"
>
<svg className="w-3 h-3 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-4.586 4.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
{a.originalName} ({formatBytes(a.sizeBytes)})
</button>
))}
</div>
)}
</div>
))}
{/* Add comment form */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 space-y-3">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
placeholder="Type your message..."
className="w-full border border-surface-300 dark:border-surface-600 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-surface-700 text-surface-900 dark:text-surface-100"
/>
{/* File selection preview */}
{selectedFiles.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedFiles.map((f, i) => (
<div key={i} className="flex items-center gap-1 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 dark:text-surface-300">{f.name}</span>
<span className="text-surface-400">({formatBytes(f.size)})</span>
<button
onClick={() => setSelectedFiles(selectedFiles.filter((_, j) => j !== i))}
className="text-surface-400 hover:text-red-500 ml-1"
>
&times;
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<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!)]);
e.target.value = '';
}
}}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm text-surface-600 dark:text-surface-300 border border-surface-300 dark:border-surface-600 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-700"
>
Attach files
</button>
<span className="text-xs text-surface-400">Max 5 files, 10MB each</span>
</div>
<button
onClick={handleAddComment}
disabled={submitting || (!comment.trim() && selectedFiles.length === 0)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 disabled:opacity-50"
>
{submitting ? 'Sending...' : 'Send'}
</button>
</div>
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-4">
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 space-y-4">
<h3 className="text-sm font-medium text-surface-700 dark:text-surface-300">Ticket Info</h3>
<div>
<label className="text-xs text-surface-500 dark:text-surface-400">Category</label>
<p className="text-sm font-medium mt-1 capitalize text-surface-800 dark:text-surface-200">{ticket.category.replace(/_/g, ' ')}</p>
</div>
<div>
<label className="text-xs text-surface-500 dark:text-surface-400">Priority</label>
<p className={`text-sm font-medium mt-1 capitalize ${
ticket.priority === 'urgent' ? 'text-red-600' :
ticket.priority === 'high' ? 'text-orange-600' :
ticket.priority === 'normal' ? 'text-surface-700 dark:text-surface-300' : 'text-surface-500 dark:text-surface-400'
}`}>{ticket.priority}</p>
</div>
{ticket.assignee && (
<div>
<label className="text-xs text-surface-500 dark:text-surface-400">Assigned To</label>
<p className="text-sm font-medium mt-1 text-surface-800 dark:text-surface-200">{ticket.assignee.firstName} {ticket.assignee.lastName}</p>
</div>
)}
</div>
{/* Upload files directly to ticket */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 space-y-3">
<h3 className="text-sm font-medium text-surface-700 dark:text-surface-300">Upload Files</h3>
<p className="text-xs text-surface-500 dark:text-surface-400">Attach screenshots, documents, or other files to this ticket.</p>
<input
type="file"
multiple
accept="image/*,.pdf,.txt,.doc,.docx"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
handleFileUpload(e.target.files);
e.target.value = '';
}
}}
className="block w-full text-sm text-surface-500 file:mr-2 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
/>
{uploading && <p className="text-xs text-surface-400">Uploading...</p>}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,296 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
const ADMIN_API_URL = process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api';
interface Ticket {
id: string;
subject: string;
category: string;
priority: string;
status: string;
createdAt: string;
assignee: { firstName: string; lastName: string } | null;
_count: { comments: number };
}
const statusColors: Record<string, string> = {
open: 'bg-yellow-50 text-yellow-700',
in_progress: 'bg-blue-50 text-blue-700',
waiting_tenant: 'bg-orange-50 text-orange-700',
resolved: 'bg-green-50 text-green-700',
closed: 'bg-surface-100 text-surface-500',
};
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';
}
export default function SupportPage() {
const router = useRouter();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [showNew, setShowNew] = useState(false);
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(() => {
loadTickets();
}, []);
function authHeaders() {
const token = localStorage.getItem('accessToken');
return { Authorization: `Bearer ${token}` };
}
async function loadTickets() {
setLoading(true);
try {
const res = await fetch(`${ADMIN_API_URL}/public/support/tickets`, {
headers: authHeaders(),
});
if (!res.ok) {
setTickets([]);
return;
}
const data = await res.json();
setTickets(Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : []);
} catch (err) {
console.error('Failed to load tickets:', err);
setTickets([]);
} finally {
setLoading(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!subject.trim() || !description.trim()) return;
setSubmitting(true);
try {
// Create ticket
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;
// Upload files if any
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,
});
}
setShowNew(false);
setSubject('');
setDescription('');
setCategory('general');
setPriority('normal');
setSelectedFiles([]);
loadTickets();
} catch (err) {
console.error('Failed to create ticket:', err);
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-surface-900 dark:text-surface-100">Support</h2>
<p className="text-sm text-surface-500 dark:text-surface-400">Get help from the FiberOps team</p>
</div>
<button
onClick={() => setShowNew(!showNew)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium"
>
New Ticket
</button>
</div>
{showNew && (
<form onSubmit={handleSubmit} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 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"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg text-sm bg-white dark:bg-surface-700 text-surface-900 dark:text-surface-100"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg text-sm resize-none bg-white dark:bg-surface-700 text-surface-900 dark:text-surface-100"
required
/>
</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 rounded-lg text-sm bg-white dark:bg-surface-700 text-surface-900 dark:text-surface-100"
>
<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 rounded-lg text-sm bg-white dark:bg-surface-700 text-surface-900 dark:text-surface-100"
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
{/* File attachment area */}
<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-4 text-center">
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.txt,.doc,.docx"
className="hidden"
onChange={(e) => {
if (e.target.files) {
const newFiles = [...selectedFiles, ...Array.from(e.target.files!)].slice(0, 5);
setSelectedFiles(newFiles);
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">
{f.type.startsWith('image/') ? (
<svg className="w-3.5 h-3.5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
) : (
<svg className="w-3.5 h-3.5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
)}
<span className="text-surface-700 dark:text-surface-300">{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"
onClick={() => { setShowNew(false); setSelectedFiles([]); }}
className="px-4 py-2 text-sm text-surface-600 dark:text-surface-300 border border-surface-300 dark:border-surface-600 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 disabled:opacity-50"
>
{submitting ? 'Submitting...' : 'Submit Ticket'}
</button>
</div>
</form>
)}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-900/50">
<th className="text-left px-4 py-3 font-medium text-surface-600 dark:text-surface-400">Subject</th>
<th className="text-center px-4 py-3 font-medium text-surface-600 dark:text-surface-400">Category</th>
<th className="text-center px-4 py-3 font-medium text-surface-600 dark:text-surface-400">Priority</th>
<th className="text-center px-4 py-3 font-medium text-surface-600 dark:text-surface-400">Status</th>
<th className="text-center px-4 py-3 font-medium text-surface-600 dark:text-surface-400">Comments</th>
<th className="text-left px-4 py-3 font-medium text-surface-600 dark:text-surface-400">Created</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="text-center py-8 text-surface-400">Loading...</td></tr>
) : tickets.length === 0 ? (
<tr><td colSpan={6} className="text-center py-8 text-surface-400">No support tickets yet</td></tr>
) : (
tickets.map((t) => (
<tr
key={t.id}
onClick={() => router.push(`/dashboard/support/${t.id}`)}
className="border-b border-surface-100 dark:border-surface-700/50 hover:bg-surface-50 dark:hover:bg-surface-700/50 cursor-pointer"
>
<td className="px-4 py-3 font-medium text-surface-700 dark:text-surface-200">{t.subject}</td>
<td className="px-4 py-3 text-center capitalize text-surface-500 dark:text-surface-400">{t.category.replace(/_/g, ' ')}</td>
<td className="px-4 py-3 text-center capitalize">{t.priority}</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[t.status] || ''}`}>
{t.status.replace(/_/g, ' ')}
</span>
</td>
<td className="px-4 py-3 text-center text-surface-500 dark:text-surface-400">{t._count?.comments || 0}</td>
<td className="px-4 py-3 text-surface-500 dark:text-surface-400">{new Date(t.createdAt).toLocaleDateString()}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/api';
import { PageHeader } from '@/components/ui/page-header';
import { DataTable } from '@/components/ui/data-table';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ActionIcon } from '@/components/ui/action-icon';
import { useToast } from '@/components/ui/toast';
import { TicketDetailModal } from '@/components/modals/ticket-detail-modal';
import { CreateTicketModal } from '@/components/modals/create-ticket-modal';
interface Ticket {
id: string;
type: string;
status: string;
title: string;
priority: string;
client: { id: string; firstName: string; lastName: string; accountNumber: string } | null;
assignee: { id: string; firstName: string; lastName: string } | null;
createdAt: string;
}
const priorityVariant: Record<string, 'error' | 'warning' | 'info' | 'default'> = {
urgent: 'error', high: 'warning', normal: 'info', low: 'default',
};
export default function TicketsPage() {
const { toast } = useToast();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<Record<string, string>>({});
const [detailTicketId, setDetailTicketId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const loadTickets = useCallback(async () => {
try {
const res = await api.get<{ data: Ticket[] }>('/tickets');
setTickets(res.data.data);
} catch { toast('Failed to load tickets', 'error'); }
finally { setLoading(false); }
}, [toast]);
useEffect(() => { loadTickets(); }, [loadTickets]);
const filtered = tickets.filter((t) => {
if (search && !`${t.title} ${t.client?.firstName || ''} ${t.client?.lastName || ''}`.toLowerCase().includes(search.toLowerCase())) return false;
if (filters.status && t.status !== filters.status) return false;
if (filters.type && t.type !== filters.type) return false;
if (filters.priority && t.priority !== filters.priority) return false;
return true;
});
return (
<div>
<PageHeader title="Tickets" description="Manage installation, activation, and support tickets"
action={<Button onClick={() => setShowCreate(true)}>New Ticket</Button>}
/>
<div className="mt-5">
<DataTable data={filtered} loading={loading} keyExtractor={(t) => t.id}
emptyTitle="No tickets found" emptyDescription="Tickets are created automatically or manually."
searchPlaceholder="Search by title or client name..."
onRowClick={(t) => setDetailTicketId(t.id)}
searchValue={search} onSearchChange={setSearch}
quickFilters={[
{ key: 'status', label: 'Status', options: [{ label: 'Open', value: 'open' }, { label: 'In Progress', value: 'in_progress' }, { label: 'Resolved', value: 'resolved' }] },
{ key: 'type', label: 'Type', options: [{ label: 'Support', value: 'support' }, { label: 'Installation', value: 'installation' }, { label: 'Activation', value: 'activation' }, { label: 'Maintenance', value: 'maintenance' }] },
{ key: 'priority', label: 'Priority', options: [{ label: 'Urgent', value: 'urgent' }, { label: 'High', value: 'high' }, { label: 'Normal', value: 'normal' }, { label: 'Low', value: 'low' }] },
]}
activeFilters={filters}
onFilterChange={(k, v) => setFilters((f) => ({ ...f, [k]: v }))}
columns={[
{ key: 'title', label: 'Title', sortable: true, render: (t) => (
<span className="font-medium text-surface-800 dark:text-surface-200">
{t.title}
</span>
)},
{ key: 'type', label: 'Type', sortable: true, render: (t) => <Badge label={t.type} /> },
{ key: 'client', label: 'Client', render: (t) => t.client
? <span className="text-surface-600 dark:text-surface-300">{t.client.firstName} {t.client.lastName}</span>
: <span className="text-surface-400 dark:text-surface-500">--</span>
},
{ key: 'priority', label: 'Priority', sortable: true, render: (t) => <Badge label={t.priority} variant={priorityVariant[t.priority]} /> },
{ key: 'status', label: 'Status', sortable: true, render: (t) => <Badge label={t.status} variant={statusBadgeVariant(t.status)} /> },
{ key: 'assignee', label: 'Assignee', render: (t) => t.assignee
? <span className="text-surface-500 dark:text-surface-400 text-xs">{t.assignee.firstName} {t.assignee.lastName}</span>
: <span className="text-surface-300 dark:text-surface-500 text-xs">Unassigned</span>
},
{ key: 'actions', label: '', align: 'right', render: (t) => (
<ActionIcon icon="external-link" variant="ghost" label="Open" onClick={(e) => { e.stopPropagation(); setDetailTicketId(t.id); }} />
)},
]}
/>
</div>
<TicketDetailModal
open={!!detailTicketId}
onClose={() => setDetailTicketId(null)}
onUpdated={loadTickets}
ticketId={detailTicketId}
/>
<CreateTicketModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={loadTickets} />
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function UsersRedirect() {
redirect('/dashboard/settings/users');
}

View File

@@ -0,0 +1,39 @@
'use client';
import { useState } from 'react';
import { AuthProvider } from '@/components/layout/auth-provider';
import { Sidebar } from '@/components/layout/sidebar';
import { Header } from '@/components/layout/header';
import { ToastProvider } from '@/components/ui/toast';
import { MustChangePasswordGuard } from '@/components/layout/must-change-password-guard';
import { SupportModal } from '@/components/modals/support-modal';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [showSupport, setShowSupport] = useState(false);
return (
<AuthProvider>
<ToastProvider>
<div className="flex h-screen bg-surface-50 dark:bg-surface-900 overflow-hidden">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:px-4 focus:py-2 focus:bg-primary-600 focus:text-white focus:rounded-md focus:m-2">
Skip to main content
</a>
<Sidebar />
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Header onSupportOpen={() => setShowSupport(true)} />
<main id="main-content" role="main" className="flex-1 overflow-y-auto px-6 py-5 bg-surface-50 dark:bg-surface-900">
<MustChangePasswordGuard>
{children}
</MustChangePasswordGuard>
</main>
</div>
<SupportModal open={showSupport} onClose={() => setShowSupport(false)} />
</div>
</ToastProvider>
</AuthProvider>
);
}

65
src/app/globals.css Normal file
View File

@@ -0,0 +1,65 @@
@import 'tailwindcss';
@variant dark (&:where(.dark, .dark *));
@theme {
/* Primary — Deep Blue */
--color-primary-50: #eef2ff;
--color-primary-100: #e0e7ff;
--color-primary-200: #c7d2fe;
--color-primary-300: #a5b4fc;
--color-primary-400: #818cf8;
--color-primary-500: #6366f1;
--color-primary-600: #4f46e5;
--color-primary-700: #4338ca;
--color-primary-800: #3730a3;
--color-primary-900: #312e81;
--color-primary-950: #1e1b4b;
/* Neutral — Slate tones */
--color-surface-50: #f8fafc;
--color-surface-100: #f1f5f9;
--color-surface-200: #e2e8f0;
--color-surface-300: #cbd5e1;
--color-surface-400: #94a3b8;
--color-surface-500: #64748b;
--color-surface-600: #475569;
--color-surface-700: #334155;
--color-surface-800: #1e293b;
--color-surface-900: #0f172a;
/* Accent — Success/Warning/Error */
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
/* Focus visible for keyboard nav */
*:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* Leaflet map container must have explicit height */
.leaflet-container {
height: 100%;
width: 100%;
z-index: 0;
}

View File

@@ -0,0 +1,41 @@
'use client';
import { useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function ImpersonateHandler() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const token = searchParams.get('token');
const userStr = searchParams.get('user');
const tenantStr = searchParams.get('tenant');
if (token && userStr && tenantStr) {
localStorage.setItem('accessToken', token);
localStorage.setItem('user', userStr);
localStorage.setItem('tenant', tenantStr);
localStorage.setItem('is_impersonating', 'true');
router.push('/dashboard');
} else {
router.push('/login');
}
}, [router, searchParams]);
return null;
}
export default function ImpersonatePage() {
return (
<div className="min-h-screen flex items-center justify-center bg-surface-50 dark:bg-surface-900">
<div className="text-center">
<div className="w-10 h-10 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-surface-600 dark:text-surface-400">Loading impersonation session...</p>
</div>
<Suspense fallback={null}>
<ImpersonateHandler />
</Suspense>
</div>
);
}

37
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,37 @@
import type { Metadata } from 'next';
import { ThemeProvider } from 'next-themes';
import './globals.css';
export const metadata: Metadata = {
title: 'FiberOps',
description: 'ISP Management Platform',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
crossOrigin=""
/>
</head>
<body className="font-sans antialiased bg-surface-50 text-surface-800 dark:bg-surface-900 dark:text-surface-100">
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}

105
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,105 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { login } from '@/lib/auth';
import { useAuthStore } from '@/stores/auth.store';
export default function LoginPage() {
const router = useRouter();
const setUser = useAuthStore((s) => s.setUser);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const user = await login(email, password);
setUser(user);
router.push('/dashboard');
} catch (err: unknown) {
setError((err as any)?.response?.data?.error || 'Invalid credentials');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex">
{/* Left branding panel */}
<div className="hidden lg:flex lg:w-1/2 bg-primary-950 items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary-900/50 to-primary-950" />
<div className="relative z-10 px-16 max-w-lg">
<div className="flex items-center gap-3 mb-8">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" className="text-primary-400">
<rect width="40" height="40" rx="10" fill="currentColor" fillOpacity="0.15" />
<path d="M12 20h16M20 12v16M14 14l12 12M26 14L14 26" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<span className="text-2xl font-bold text-white tracking-tight">FiberOps</span>
</div>
<h2 className="text-3xl font-bold text-white leading-tight">
Manage your ISP business with confidence
</h2>
<p className="mt-4 text-primary-300 text-lg leading-relaxed">
Subscribers, billing, collections, and operations all in one platform built for Philippine ISPs.
</p>
</div>
</div>
{/* Right login form */}
<div className="flex-1 flex items-center justify-center px-6 py-12">
<div className="w-full max-w-sm">
<div className="lg:hidden flex items-center gap-2 mb-10">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none" className="text-primary-600">
<rect width="40" height="40" rx="10" fill="currentColor" fillOpacity="0.1" />
<path d="M12 20h16M20 12v16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<span className="text-xl font-bold text-surface-900 tracking-tight">FiberOps</span>
</div>
<h1 className="text-2xl font-bold text-surface-900">Welcome back</h1>
<p className="mt-1 text-surface-500 text-sm">Sign in to your account</p>
{error && (
<div className="mt-6 flex items-center gap-2 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0">
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 5v3M8 10v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
{error}
</div>
)}
<form className="mt-8 space-y-5" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-surface-700 mb-1.5">Email address</label>
<input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full rounded-lg border border-surface-200 bg-white px-3.5 py-2.5 text-sm text-surface-900 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200"
placeholder="admin@demo-isp.com" />
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-surface-700 mb-1.5">Password</label>
<input id="password" type="password" required value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full rounded-lg border border-surface-200 bg-white px-3.5 py-2.5 text-sm text-surface-900 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200"
placeholder="Enter your password" />
</div>
<button type="submit" disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold shadow-sm hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer">
{loading && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<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>
)}
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
);
}

10
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,10 @@
export default function NotFound() {
return (
<html lang="en">
<body>
<h1>404 - Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
</body>
</html>
);
}

5
src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/login');
}

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

32
src/lib/api.ts Normal file
View File

@@ -0,0 +1,32 @@
import axios from 'axios';
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
if (typeof window !== 'undefined') {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
}
}
return Promise.reject(error);
},
);

77
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,77 @@
import { api } from './api';
export interface AuthUser {
id: string;
email: string;
firstName: string;
lastName: string;
mustChangePassword?: boolean;
roles: string[];
permissions: string[];
accessMap: Record<string, Record<string, boolean>> | 'all';
tenantRoles: { id: string; name: string; slug: string }[];
tenant: {
id: string;
name: string;
slug: string;
settings: Record<string, unknown>;
} | null;
}
export interface LoginResponse {
success: boolean;
data: {
accessToken: string;
refreshToken: string;
mustChangePassword?: boolean;
};
}
export async function login(email: string, password: string): Promise<AuthUser> {
const res = await api.post<LoginResponse>('/auth/login', { email, password });
const { accessToken, refreshToken, mustChangePassword } = res.data.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
const user = await getProfile();
// Merge the flag from login response if not already in profile
if (mustChangePassword && !user.mustChangePassword) {
user.mustChangePassword = true;
}
return user;
}
export async function getProfile(): Promise<AuthUser> {
const res = await api.get<{ success: boolean; data: AuthUser }>('/auth/profile');
return res.data.data;
}
export async function refreshTokens() {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) throw new Error('No refresh token');
const res = await api.post<LoginResponse>('/auth/refresh', { refreshToken });
const { accessToken, refreshToken: newRefresh } = res.data.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefresh);
}
export function logout() {
const token = localStorage.getItem('accessToken');
if (token) {
api.post('/auth/logout').catch(() => {});
}
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
export function isAuthenticated(): boolean {
return !!localStorage.getItem('accessToken');
}
export async function changePassword(currentPassword: string, newPassword: string) {
const res = await api.post('/auth/change-password', { currentPassword, newPassword });
return res.data;
}

14
src/lib/form-styles.ts Normal file
View File

@@ -0,0 +1,14 @@
export const INPUT_CLASS =
'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';
export const LABEL_CLASS =
'block text-sm font-medium text-surface-700 dark:text-surface-300';
export const SELECT_CLASS =
'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';
export const TEXTAREA_CLASS =
'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';
export const HELPER_TEXT_CLASS =
'mt-1 text-xs text-surface-500 dark:text-surface-400';

98
src/stores/auth.store.ts Normal file
View File

@@ -0,0 +1,98 @@
import { create } from 'zustand';
import type { AuthUser } from '@/lib/auth';
/**
* Role hierarchy levels — higher number = more powerful.
* Used for legacy @Roles() guard compat on sidebar visibility.
*/
const ROLE_LEVEL: Record<string, number> = {
super_admin: 100,
tenant_admin: 80,
manager: 60,
technician: 40,
viewer: 20,
};
function satisfiesRole(userRoles: string[], requiredRole: string): boolean {
const requiredLevel = ROLE_LEVEL[requiredRole];
if (requiredLevel === undefined) return false;
return userRoles.some((r) => (ROLE_LEVEL[r] ?? 0) >= requiredLevel);
}
export type AccessMap = Record<string, Record<string, boolean>> | 'all';
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
accessMap: AccessMap;
setUser: (user: AuthUser | null) => void;
setLoading: (loading: boolean) => void;
logout: () => void;
hasRole: (role: string) => boolean;
hasAnyRole: (roles: string[]) => boolean;
hasPermission: (permission: string) => boolean;
hasAnyPermission: (permissions: string[]) => boolean;
isSuperAdmin: () => boolean;
/** Check module+action access from tenant role permissions */
canAccess: (module: string, action: string) => boolean;
/** Check if user can view a module (used for sidebar/nav filtering) */
canView: (module: string) => boolean;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: true,
accessMap: {},
setUser: (user) => set({
user,
isAuthenticated: !!user,
isLoading: false,
accessMap: (user as any)?.accessMap ?? {},
}),
setLoading: (isLoading) => set({ isLoading }),
logout: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
set({ user: null, isAuthenticated: false, isLoading: false, accessMap: {} });
},
hasRole: (role) => {
const { user } = get();
if (!user) return false;
return satisfiesRole(user.roles, role);
},
hasAnyRole: (roles) => {
const { user } = get();
if (!user) return false;
return roles.some((r) => satisfiesRole(user.roles, r));
},
hasPermission: (permission) => {
const { user } = get();
if (!user) return false;
if (user.roles.includes('super_admin')) return true;
return user.permissions?.includes(permission) ?? false;
},
hasAnyPermission: (permissions) => {
const { user } = get();
if (!user) return false;
if (user.roles.includes('super_admin')) return true;
return permissions.some((p) => user.permissions?.includes(p)) ?? false;
},
isSuperAdmin: () => {
const { user } = get();
return user?.roles.includes('super_admin') ?? false;
},
canAccess: (module, action) => {
const { accessMap, user } = get();
if (!user) return false;
if (accessMap === 'all') return true;
if (user.roles.includes('super_admin')) return true;
return accessMap[module]?.[action] ?? false;
},
canView: (module) => {
return get().canAccess(module, 'canView');
},
}));

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}