From 3f5bf0e11831a12b365c842691a68840a4a8067d Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 13 Apr 2026 09:36:37 +0800 Subject: [PATCH 1/2] initial: standalone repo from monorepo split --- .dockerignore | 7 + .env.example | 1 + .gitignore | 10 + Dockerfile | 20 + next-env.d.ts | 6 + next.config.ts | 8 + package.json | 36 + packages/shared/package.json | 18 + packages/shared/src/constants/index.ts | 16 + packages/shared/src/constants/permissions.ts | 224 ++++++ packages/shared/src/constants/roles.ts | 36 + packages/shared/src/constants/statuses.ts | 67 ++ packages/shared/src/index.ts | 3 + packages/shared/src/schemas/auth.ts | 25 + packages/shared/src/schemas/index.ts | 5 + packages/shared/src/schemas/user.ts | 27 + packages/shared/src/types/api.ts | 36 + packages/shared/src/types/index.ts | 7 + packages/shared/tsconfig.json | 8 + playwright-report/index.html | 90 +++ postcss.config.mjs | 8 + .../(dashboard)/dashboard/accounting/page.tsx | 638 +++++++++++++++ .../(dashboard)/dashboard/accounts/page.tsx | 140 ++++ src/app/(dashboard)/dashboard/areas/page.tsx | 166 ++++ src/app/(dashboard)/dashboard/assets/page.tsx | 115 +++ .../dashboard/change-password/page.tsx | 110 +++ .../dashboard/clients/[id]/page.tsx | 320 ++++++++ .../(dashboard)/dashboard/clients/page.tsx | 95 +++ .../(dashboard)/dashboard/employees/page.tsx | 196 +++++ .../(dashboard)/dashboard/expenses/page.tsx | 246 ++++++ .../(dashboard)/dashboard/invoices/page.tsx | 150 ++++ src/app/(dashboard)/dashboard/page.tsx | 566 ++++++++++++++ .../(dashboard)/dashboard/payments/page.tsx | 309 ++++++++ .../(dashboard)/dashboard/payroll/page.tsx | 266 +++++++ src/app/(dashboard)/dashboard/plans/page.tsx | 145 ++++ .../(dashboard)/dashboard/reports/page.tsx | 740 ++++++++++++++++++ .../dashboard/settings/areas/page.tsx | 162 ++++ .../dashboard/settings/billing/page.tsx | 83 ++ .../settings/chart-of-accounts/page.tsx | 129 +++ .../settings/company-accounts/page.tsx | 125 +++ .../(dashboard)/dashboard/settings/layout.tsx | 114 +++ .../(dashboard)/dashboard/settings/page.tsx | 105 +++ .../dashboard/settings/plans/page.tsx | 159 ++++ .../dashboard/settings/roles/page.tsx | 406 ++++++++++ .../dashboard/settings/support/page.tsx | 235 ++++++ .../dashboard/settings/users/page.tsx | 327 ++++++++ .../dashboard/subscriptions/page.tsx | 128 +++ .../dashboard/support/[id]/page.tsx | 355 +++++++++ .../(dashboard)/dashboard/support/page.tsx | 296 +++++++ .../(dashboard)/dashboard/tickets/page.tsx | 109 +++ src/app/(dashboard)/dashboard/users/page.tsx | 5 + src/app/(dashboard)/layout.tsx | 39 + src/app/globals.css | 65 ++ src/app/layout.tsx | 37 + src/app/login/page.tsx | 105 +++ src/app/page.tsx | 5 + src/components/layout/auth-provider.tsx | 43 + src/components/layout/header.tsx | 165 ++++ .../layout/must-change-password-guard.tsx | 30 + src/components/layout/sidebar.tsx | 159 ++++ src/components/maps/leaflet-map.tsx | 159 ++++ src/components/maps/location-picker-modal.tsx | 81 ++ src/components/modals/create-client-modal.tsx | 165 ++++ .../modals/create-expense-modal.tsx | 48 ++ .../modals/create-subscription-modal.tsx | 102 +++ src/components/modals/create-ticket-modal.tsx | 155 ++++ src/components/modals/payment-modal.tsx | 303 +++++++ src/components/modals/support-modal.tsx | 109 +++ src/components/modals/ticket-detail-modal.tsx | 246 ++++++ src/components/modals/transfer-modal.tsx | 60 ++ src/components/ui/action-icon.tsx | 178 +++++ src/components/ui/action-menu.tsx | 90 +++ src/components/ui/badge.tsx | 49 ++ src/components/ui/button.tsx | 42 + src/components/ui/data-table.tsx | 238 ++++++ src/components/ui/empty-state.tsx | 23 + src/components/ui/form-modal.tsx | 55 ++ src/components/ui/modal.tsx | 122 +++ src/components/ui/page-header.tsx | 19 + src/components/ui/select.tsx | 130 +++ src/components/ui/skeleton.tsx | 38 + src/components/ui/toast.tsx | 93 +++ src/lib/api.ts | 32 + src/lib/auth.ts | 77 ++ src/lib/form-styles.ts | 14 + src/stores/auth.store.ts | 98 +++ test-results/.last-run.json | 4 + tsconfig.json | 21 + 88 files changed, 10997 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants/index.ts create mode 100644 packages/shared/src/constants/permissions.ts create mode 100644 packages/shared/src/constants/roles.ts create mode 100644 packages/shared/src/constants/statuses.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/schemas/auth.ts create mode 100644 packages/shared/src/schemas/index.ts create mode 100644 packages/shared/src/schemas/user.ts create mode 100644 packages/shared/src/types/api.ts create mode 100644 packages/shared/src/types/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 playwright-report/index.html create mode 100644 postcss.config.mjs create mode 100644 src/app/(dashboard)/dashboard/accounting/page.tsx create mode 100644 src/app/(dashboard)/dashboard/accounts/page.tsx create mode 100644 src/app/(dashboard)/dashboard/areas/page.tsx create mode 100644 src/app/(dashboard)/dashboard/assets/page.tsx create mode 100644 src/app/(dashboard)/dashboard/change-password/page.tsx create mode 100644 src/app/(dashboard)/dashboard/clients/[id]/page.tsx create mode 100644 src/app/(dashboard)/dashboard/clients/page.tsx create mode 100644 src/app/(dashboard)/dashboard/employees/page.tsx create mode 100644 src/app/(dashboard)/dashboard/expenses/page.tsx create mode 100644 src/app/(dashboard)/dashboard/invoices/page.tsx create mode 100644 src/app/(dashboard)/dashboard/page.tsx create mode 100644 src/app/(dashboard)/dashboard/payments/page.tsx create mode 100644 src/app/(dashboard)/dashboard/payroll/page.tsx create mode 100644 src/app/(dashboard)/dashboard/plans/page.tsx create mode 100644 src/app/(dashboard)/dashboard/reports/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/areas/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/billing/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/chart-of-accounts/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/company-accounts/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/layout.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/plans/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/roles/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/support/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/users/page.tsx create mode 100644 src/app/(dashboard)/dashboard/subscriptions/page.tsx create mode 100644 src/app/(dashboard)/dashboard/support/[id]/page.tsx create mode 100644 src/app/(dashboard)/dashboard/support/page.tsx create mode 100644 src/app/(dashboard)/dashboard/tickets/page.tsx create mode 100644 src/app/(dashboard)/dashboard/users/page.tsx create mode 100644 src/app/(dashboard)/layout.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/components/layout/auth-provider.tsx create mode 100644 src/components/layout/header.tsx create mode 100644 src/components/layout/must-change-password-guard.tsx create mode 100644 src/components/layout/sidebar.tsx create mode 100644 src/components/maps/leaflet-map.tsx create mode 100644 src/components/maps/location-picker-modal.tsx create mode 100644 src/components/modals/create-client-modal.tsx create mode 100644 src/components/modals/create-expense-modal.tsx create mode 100644 src/components/modals/create-subscription-modal.tsx create mode 100644 src/components/modals/create-ticket-modal.tsx create mode 100644 src/components/modals/payment-modal.tsx create mode 100644 src/components/modals/support-modal.tsx create mode 100644 src/components/modals/ticket-detail-modal.tsx create mode 100644 src/components/modals/transfer-modal.tsx create mode 100644 src/components/ui/action-icon.tsx create mode 100644 src/components/ui/action-menu.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/data-table.tsx create mode 100644 src/components/ui/empty-state.tsx create mode 100644 src/components/ui/form-modal.tsx create mode 100644 src/components/ui/modal.tsx create mode 100644 src/components/ui/page-header.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/lib/api.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/form-styles.ts create mode 100644 src/stores/auth.store.ts create mode 100644 test-results/.last-run.json create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..af72dda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.git +.env +*.tsbuildinfo +test-screenshots +tests diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d658484 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:3001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7756cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +out/ +.env +.env.local +.env.*.local +dist/ +*.tsbuildinfo +test-screenshots/ +tests/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ccd2a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +COPY packages/shared/package.json ./packages/shared/ +RUN npm install +COPY packages/shared/ ./packages/shared/ +COPY . . +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"] diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..42efe09 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + transpilePackages: ['@fiberops/shared'], + output: 'standalone', +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..c3db094 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..073de52 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fiberops/shared", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "zod": "^3.24.0" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts new file mode 100644 index 0000000..8c7be2b --- /dev/null +++ b/packages/shared/src/constants/index.ts @@ -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'; diff --git a/packages/shared/src/constants/permissions.ts b/packages/shared/src/constants/permissions.ts new file mode 100644 index 0000000..5a19140 --- /dev/null +++ b/packages/shared/src/constants/permissions.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 []; +} diff --git a/packages/shared/src/constants/roles.ts b/packages/shared/src/constants/roles.ts new file mode 100644 index 0000000..568ad34 --- /dev/null +++ b/packages/shared/src/constants/roles.ts @@ -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 = { + [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); +} diff --git a/packages/shared/src/constants/statuses.ts b/packages/shared/src/constants/statuses.ts new file mode 100644 index 0000000..5f5934b --- /dev/null +++ b/packages/shared/src/constants/statuses.ts @@ -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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..0f31ffa --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from './constants'; +export * from './schemas'; +export * from './types'; diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts new file mode 100644 index 0000000..5b793fd --- /dev/null +++ b/packages/shared/src/schemas/auth.ts @@ -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; +export type RegisterTenantInput = z.infer; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000..e5095cd --- /dev/null +++ b/packages/shared/src/schemas/index.ts @@ -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'; diff --git a/packages/shared/src/schemas/user.ts b/packages/shared/src/schemas/user.ts new file mode 100644 index 0000000..5a8e5c1 --- /dev/null +++ b/packages/shared/src/schemas/user.ts @@ -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; +export type UpdateUserInput = z.infer; diff --git a/packages/shared/src/types/api.ts b/packages/shared/src/types/api.ts new file mode 100644 index 0000000..735cd83 --- /dev/null +++ b/packages/shared/src/types/api.ts @@ -0,0 +1,36 @@ +export interface ApiResponse { + 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; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts new file mode 100644 index 0000000..ad6b9f0 --- /dev/null +++ b/packages/shared/src/types/index.ts @@ -0,0 +1,7 @@ +export type { + ApiResponse, + PaginationMeta, + PaginationQuery, + JwtPayload, + TokenResponse, +} from './api'; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..792172f --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..ca2c8b6 --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,90 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..5d6d845 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/src/app/(dashboard)/dashboard/accounting/page.tsx b/src/app/(dashboard)/dashboard/accounting/page.tsx new file mode 100644 index 0000000..b4e7cd5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/accounting/page.tsx @@ -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 = { + 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('overview'); + const [filterAccountId, setFilterAccountId] = useState(null); + + function goToLedger(accountId: string) { + setFilterAccountId(accountId); + setTab('ledger'); + } + + function clearAccountFilter() { + setFilterAccountId(null); + } + + return ( +
+
+ +
+ {([['overview', 'Overview'], ['trial-balance', 'Trial Balance'], ['ledger', 'General Ledger']] as const).map(([key, label]) => ( + + ))} +
+
+
+ {tab === 'overview' && } + {tab === 'trial-balance' && } + {tab === 'ledger' && } +
+
+ ); +} + +/* ── 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(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 ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ ); + } + if (!data) return

Failed to load accounting overview

; + + const maxExpense = data.expenseBreakdown.length > 0 ? Math.max(...data.expenseBreakdown.map((e) => e.amount)) : 1; + + return ( +
+ {/* Row 1 — Key Financial Position */} +
+
+

Cash on Hand

+

{formatPHP(data.cashOnHand)}

+

From journal entries

+
+
+

Accounts Receivable

+

{formatPHP(data.accountsReceivable)}

+

Outstanding invoices

+
+
+

Monthly Revenue

+

{formatPHP(data.monthlyRevenue)}

+

Current month credits

+
+
+

Net Income (MTD)

+

= 0 ? 'text-emerald-700' : 'text-red-600'}`}> + {formatPHP(Math.abs(data.netIncome))}{data.netIncome < 0 ? ' loss' : ''} +

+

Revenue minus expenses

+
+
+ + {/* Row 2 — Cash Account Breakdown + Expense Breakdown */} +
+ {/* Cash accounts */} +
+

Cash Account Breakdown

+
+ {data.cashAccounts.map((acc) => { + const pct = data.cashOnHand !== 0 ? Math.abs(acc.balance / data.cashOnHand) * 100 : 0; + return ( +
+
+ + {acc.code} + {acc.name} + + {formatPHP(acc.balance)} +
+
+
+
+
+ ); + })} +
+
+ + {/* Expense breakdown */} +
+

Expense Breakdown (MTD)

+

Total: {formatPHP(data.monthlyExpenses)}

+ {data.expenseBreakdown.length === 0 ? ( +

No expenses this month

+ ) : ( +
+ {data.expenseBreakdown.map((exp) => { + const pct = maxExpense > 0 ? (exp.amount / maxExpense) * 100 : 0; + return ( +
+
+ + {exp.code} + {exp.name} + + {formatPHP(exp.amount)} +
+
+
+
+
+ ); + })} +
+ )} +
+
+ + {/* Row 3 — Balance Sheet Summary */} +
+

Balance Sheet Summary

+
+
+

Total Assets

+

{formatPHP(data.totalAssets)}

+
+
+

Total Liabilities

+

{formatPHP(data.totalLiabilities)}

+
+
+

Total Equity

+

{formatPHP(data.totalEquity)}

+
+
+

Accounting Equation

+

+ {Math.abs(data.totalAssets - (data.totalLiabilities + data.totalEquity)) < 0.01 ? 'A = L + E Balanced' : `Imbalance: ${formatPHP(Math.abs(data.totalAssets - (data.totalLiabilities + data.totalEquity)))}`} +

+
+
+
+
+ ); +} + +/* ── 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([]); + const [selectedAccount, setSelectedAccount] = useState(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 ( +
+ {data.length === 0 ? ( +
No journal entries yet. Transactions will appear here when payments and invoices are recorded.
+ ) : ( +
+
+ + + + + + + + + + + {data.map((a) => ( + 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"> + + + + + + + + ))} + +
CodeAccountTypeDebitCredit
{a.code}{a.name}{a.debit > 0 ? `PHP ${a.debit.toLocaleString()}` : ''}{a.credit > 0 ? `PHP ${a.credit.toLocaleString()}` : ''} + { e.stopPropagation(); onAccountClick(a.id); }} /> +
+
+ {/* Sticky totals footer */} +
+
+ Totals + PHP {totalDebit.toLocaleString()} + PHP {totalCredit.toLocaleString()} + +
+
+ Difference + + {Math.abs(totalDebit - totalCredit) < 0.01 ? 'Balanced' : `PHP ${Math.abs(totalDebit - totalCredit).toLocaleString()} imbalance`} + + +
+
+
+ )} + + {selectedAccount && ( + setSelectedAccount(null)} + onViewLedger={() => { + setSelectedAccount(null); + onAccountClick(selectedAccount.id); + }} + /> + )} +
+ ); +} + +/* ── 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([]); + 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 ( + + {/* Account summary */} +
+
+
Total Debit
+
PHP {account.debit.toLocaleString()}
+
+
+
Total Credit
+
PHP {account.credit.toLocaleString()}
+
+
+
Net Balance
+
= 0 ? 'text-surface-900 dark:text-surface-200' : 'text-red-600'}`}> + PHP {Math.abs(account.balance).toLocaleString()}{account.balance < 0 ? ' CR' : ''} +
+
+
+ + {/* Journal entries table */} + {loading ? ( +
Loading entries...
+ ) : entries.length === 0 ? ( +
No journal entries for this account.
+ ) : ( +
+ + + + + + + + + + + + {rowsWithBalance.map((e, i) => ( + + + + + + + + ))} + + + + + + + + + +
DateDescriptionDebitCreditBalance
{new Date(e.journalEntry.entryDate).toLocaleDateString()}{e.journalEntry.description}{Number(e.debit) > 0 ? `PHP ${Number(e.debit).toLocaleString()}` : ''}{Number(e.credit) > 0 ? `PHP ${Number(e.credit).toLocaleString()}` : ''}= 0 ? 'text-surface-900 dark:text-surface-200' : 'text-red-600'}`}> + PHP {Math.abs(e.balance).toLocaleString()}{e.balance < 0 ? ' CR' : ''} +
Totals ({entries.length} entries)PHP {totalDebit.toLocaleString()}PHP {totalCredit.toLocaleString()}
+
+ )} + + {/* Footer actions */} +
+ + +
+
+ ); +} + +/* ── 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([]); + const [search, setSearch] = useState(''); + const [accountFilter, setAccountFilter] = useState(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(); + 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 ( +
+ {/* Filters */} +
+
+
+ +
+ + setSearch(e.target.value)} + placeholder="Description, account, reference..." + className={`${inputClass} pl-9 w-full`} /> +
+
+
+ + +
+
+ + setDateFrom(e.target.value)} className={`${inputClass} w-full`} /> +
+
+ + setDateTo(e.target.value)} className={`${inputClass} w-full`} /> +
+ {activeFilterCount > 0 && ( + + )} +
+
+ + {/* Account filter banner */} + {accountFilter && ( +
+ Filtered to: + {uniqueAccounts.find((a) => a.id === accountFilter)?.code} — {uniqueAccounts.find((a) => a.id === accountFilter)?.name} + +
+ )} + + {/* Table */} +
+
+ + + + + {!accountFilter && } + + + + {showRunningBalance && } + + + {rowsWithBalance.map((e, i) => ( + + + + {!accountFilter && } + + + + {showRunningBalance && ( + + )} + + ))} + {filtered.length === 0 && } + +
DateDescriptionAccountReferenceDebitCreditBalance
{new Date(e.journalEntry.entryDate).toLocaleDateString()}{e.journalEntry.description}{e.account.code} — {e.account.name}{e.journalEntry.reference || '—'}{Number(e.debit) > 0 ? `PHP ${Number(e.debit).toLocaleString()}` : ''}{Number(e.credit) > 0 ? `PHP ${Number(e.credit).toLocaleString()}` : ''}= 0 ? 'text-surface-900 dark:text-surface-200' : 'text-red-600'}`}> + PHP {Math.abs(e.balance ?? 0).toLocaleString()}{(e.balance ?? 0) < 0 ? ' CR' : ''} +
No journal entries match your filters
+
+ {/* Sticky totals footer */} + {filtered.length > 0 && ( +
+
+ Totals ({filtered.length} entries) + PHP {totalDebit.toLocaleString()} + PHP {totalCredit.toLocaleString()} + {showRunningBalance && } +
+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/accounts/page.tsx b/src/app/(dashboard)/dashboard/accounts/page.tsx new file mode 100644 index 0000000..c94f74e --- /dev/null +++ b/src/app/(dashboard)/dashboard/accounts/page.tsx @@ -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 = { bank: 'Bank', e_wallet: 'E-Wallet', cash: 'Cash' }; + +export default function FundTransfersPage() { + const { toast } = useToast(); + const [accounts, setAccounts] = useState([]); + const [transfers, setTransfers] = useState([]); + 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 ( +
+ setShowTransfer(true)}>New Transfer} /> + + {/* Account balances summary */} +
+ {accounts.map((a: any) => ( +
+
+

{a.name}

+ +
+ {a.accountNo &&

{a.accountNo}

} +

PHP {Number(a.balance).toLocaleString()}

+ {a.isSystem &&

System account

} +
+ ))} +
+ +
+ Total: PHP {totalBalance.toLocaleString()} +
+ + {/* Transfer history */} +

Transfer History

+ + {/* Date range filters */} +
+
+ + setDateFrom(e.target.value)} className={`${inputClass} w-full`} /> +
+
+ + setDateTo(e.target.value)} className={`${inputClass} w-full`} /> +
+ {activeFilterCount > 0 && ( + + )} +
+ + 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) => {new Date(t.createdAt).toLocaleDateString()} }, + { key: 'from', label: 'From', render: (t: any) => {t.fromAccount?.name} }, + { key: 'arrow', label: '', align: 'center' as const, render: () => ( + + + + )}, + { key: 'to', label: 'To', render: (t: any) => {t.toAccount?.name} }, + { key: 'amount', label: 'Amount', align: 'right' as const, sortable: true, render: (t: any) => PHP {Number(t.amount).toLocaleString()} }, + { key: 'description', label: 'Description', render: (t: any) => {t.description || '—'} }, + ]} + /> + + setShowTransfer(false)} onSuccess={load} /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/areas/page.tsx b/src/app/(dashboard)/dashboard/areas/page.tsx new file mode 100644 index 0000000..d006f9f --- /dev/null +++ b/src/app/(dashboard)/dashboard/areas/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(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 ( +
+ setShowCreate(!showCreate)} variant={showCreate ? 'secondary' : 'primary'}> + {showCreate ? 'Cancel' : 'Add Area'} + + } + /> + + {showCreate && ( + { setShowCreate(false); loadAreas(); toast('Area created', 'success'); }} /> + )} + + {loading ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : areas.length === 0 ? ( +
+ } + action={} + /> +
+ ) : ( +
+ {areas.map((area) => ( +
+
+
+

{area.name}

+ {area.description &&

{area.description}

} +
+ +
+
+ {area._count.clients} client{area._count.clients !== 1 ? 's' : ''} + {area._count.clients === 0 && ( + + )} +
+
+ ))} +
+ )} + + 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} + /> +
+ ); +} + +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 ( +
+ {error && ( +
{error}
+ )} +
+ + 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" /> +
+
+ + 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" /> +
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/assets/page.tsx b/src/app/(dashboard)/dashboard/assets/page.tsx new file mode 100644 index 0000000..bb73ad1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/assets/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const [detailTarget, setDetailTarget] = useState(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 ( +
+ + + +
} /> + +
+ 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) => {a.name} }, + { key: 'category', label: 'Category', sortable: true, render: (a: any) => }, + { key: 'serialNumber', label: 'Serial #', render: (a: any) => {a.serialNumber || '—'} }, + { key: 'status', label: 'Status', sortable: true, render: (a: any) => }, + { key: 'assignedTo', label: 'Assigned To', render: (a: any) => a.assignedTo ? {a.assignedTo.firstName} {a.assignedTo.lastName} : Unassigned }, + { key: 'purchasePrice', label: 'Value', align: 'right' as const, render: (a: any) => a.purchasePrice ? PHP {Number(a.purchasePrice).toLocaleString()} : }, + ]} + /> +
+ + setShowCreate(false)} onSuccess={load} /> + + {/* Asset Detail Modal */} + setDetailTarget(null)} + title={detailTarget?.name || 'Asset Details'} description={detailTarget?.serialNumber ? `S/N: ${detailTarget.serialNumber}` : ''}> + {detailTarget && ( +
+
+
Category:
+
Status:
+
Serial #: {detailTarget.serialNumber || '—'}
+
Value: {detailTarget.purchasePrice ? `PHP ${Number(detailTarget.purchasePrice).toLocaleString()}` : '—'}
+
Assigned To: {detailTarget.assignedTo ? `${detailTarget.assignedTo.firstName} ${detailTarget.assignedTo.lastName}` : 'Unassigned'}
+ {detailTarget.location &&
Location: {detailTarget.location}
} + {detailTarget.notes &&
Notes: {detailTarget.notes}
} +
+
+ +
+
+ )} +
+
+ ); +} + +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 ( +
+
+
setForm({ ...form, name: e.target.value })} className={ic} placeholder="e.g. Mikrotik hEX S" />
+
+
+
+
setForm({ ...form, serialNumber: e.target.value })} className={ic} />
+
setForm({ ...form, purchasePrice: parseFloat(e.target.value) || 0 })} className={ic} />
+
+
setForm({ ...form, location: e.target.value })} className={ic} placeholder="e.g. Warehouse, Field" />
+
+
); +} diff --git a/src/app/(dashboard)/dashboard/change-password/page.tsx b/src/app/(dashboard)/dashboard/change-password/page.tsx new file mode 100644 index 0000000..4b7054b --- /dev/null +++ b/src/app/(dashboard)/dashboard/change-password/page.tsx @@ -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 ( +
+
+
+

Change Your Password

+

+ {user?.mustChangePassword + ? 'For security, please set a new password before continuing.' + : 'Update your account password.'} +

+
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/clients/[id]/page.tsx b/src/app/(dashboard)/dashboard/clients/[id]/page.tsx new file mode 100644 index 0000000..e22d792 --- /dev/null +++ b/src/app/(dashboard)/dashboard/clients/[id]/page.tsx @@ -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(null); + const [payments, setPayments] = useState([]); + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState('profile'); + + // Modals + const [showPayment, setShowPayment] = useState(false); + const [payInvoice, setPayInvoice] = useState(null); + const [showSubscription, setShowSubscription] = useState(false); + const [suspendTarget, setSuspendTarget] = useState(null); + const [cancelTarget, setCancelTarget] = useState(null); + const [showCreateTicket, setShowCreateTicket] = useState(false); + const [detailTicketId, setDetailTicketId] = useState(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
; + } + + if (!client) { + return ; + } + + 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 ( +
+ + + Back to Clients + + + {/* Header */} +
+
+
+
+ {client.firstName[0]}{client.lastName[0]} +
+
+

{client.firstName} {client.lastName}

+

{client.accountNumber}

+
+
+
+ + + {!hasActiveSub && ( + + )} +
+
+
+ + {/* Tabs */} + + + {/* Tab content */} + {tab === 'profile' && } + {tab === 'subscriptions' && ( + setSuspendTarget(s)} + onCancel={(s: any) => setCancelTarget(s)} + onReactivate={(subId: string) => handleSubAction(subId, 'reactivate')} + onCreateNew={() => setShowSubscription(true)} + hasActive={hasActiveSub} + /> + )} + {tab === 'tickets' && setShowCreateTicket(true)} />} + {tab === 'invoices' && setPayInvoice(inv)} />} + {tab === 'payments' && } + + {/* Modals */} + setShowPayment(false)} onSuccess={loadData} + prefillClientId={client.id} prefillClientName={`${client.firstName} ${client.lastName}`} /> + + {payInvoice && ( + setPayInvoice(null)} onSuccess={loadData} + prefillClientId={client.id} prefillClientName={`${client.firstName} ${client.lastName}`} prefillInvoice={payInvoice} /> + )} + + setShowSubscription(false)} onSuccess={loadData} + clientId={client.id} clientName={`${client.firstName} ${client.lastName}`} /> + + 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')} /> + + 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')} /> + + setShowCreateTicket(false)} onSuccess={loadData} + prefillClientId={client.id} prefillClientName={`${client.firstName} ${client.lastName}`} /> + + setDetailTicketId(null)} onUpdated={loadData} ticketId={detailTicketId} /> +
+ ); +} + +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 ( +
+

Client Information

+
+ {fields.map((f) => ( +
+
{f.label}
+
{f.value || Not provided}
+
+ ))} +
+
+ ); +} + +function SubscriptionsTab({ subscriptions, onSuspend, onCancel, onReactivate, onCreateNew, hasActive }: any) { + if (subscriptions.length === 0) { + return New Subscription} />; + } + return ( +
+ {subscriptions.map((sub: any) => ( +
+
+
+ {sub.plan.name} + {sub.plan.speedDown}/{sub.plan.speedUp} Mbps +
+ +
+
+ PHP {Number(sub.plan.price).toLocaleString()} + + {sub.status === 'active' && ( + + )} + {sub.status === 'suspended' && ( + + )} + {['pending', 'active', 'suspended'].includes(sub.status) && ( + + )} +
+
+ ))} +
+ ); +} + +function TicketsTab({ tickets, onOpenTicket, onCreateTicket }: { tickets: any[]; onOpenTicket: (id: string) => void; onCreateTicket: () => void }) { + if (tickets.length === 0) { + return Create Ticket} />; + } + return ( +
+
+ +
+
+ {tickets.map((t: any) => ( + + ))} +
+
+ ); +} + +function InvoicesTab({ invoices, onPay }: { invoices: any[]; onPay: (inv: any) => void }) { + if (invoices.length === 0) return ; + return ( +
+ + + + + + + + + + + + + {invoices.map((inv: any) => ( + + + + + + + + + ))} + +
Invoice #AmountBalanceDue DateStatusActions
{inv.number}PHP {Number(inv.amount).toLocaleString()}PHP {Number(inv.balance).toLocaleString()}{new Date(inv.dueDate).toLocaleDateString()} + {(inv.status === 'sent' || inv.status === 'partial') && ( + + )} +
+
+ ); +} + +function PaymentsTab({ payments }: { payments: any[] }) { + if (payments.length === 0) return ; + const methodLabels: Record = { gcash: 'GCash', maya: 'Maya', cash: 'Cash', bank_transfer: 'Bank Transfer' }; + return ( +
+ + + + + + + + + + + + {payments.map((p: any) => ( + + + + + + + + ))} + +
DateAmountMethodInvoiceCollected By
{new Date(p.createdAt).toLocaleDateString()}PHP {Number(p.amount).toLocaleString()}{methodLabels[p.method] || p.method}{p.invoice?.number || '—'}{p.collectedBy ? `${p.collectedBy.firstName} ${p.collectedBy.lastName}` : '—'}
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/clients/page.tsx b/src/app/(dashboard)/dashboard/clients/page.tsx new file mode 100644 index 0000000..5c063ad --- /dev/null +++ b/src/app/(dashboard)/dashboard/clients/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const [filters, setFilters] = useState>({}); + + 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 ( +
+ setShowCreate(true)}>New Client} /> + +
+ 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) => {c.accountNumber} }, + { key: 'firstName', label: 'Name', sortable: true, render: (c) => ( + {c.firstName} {c.lastName} + )}, + { key: 'area', label: 'Area', render: (c) => {c.area?.name || '—'} }, + { key: 'phone', label: 'Contact', render: (c) => {c.phone || c.email || '—'} }, + { key: 'status', label: 'Status', sortable: true, render: (c) => }, + { key: 'actions', label: '', align: 'right', render: (c) => ( + e.stopPropagation()}> + + + )}, + ]} + /> +
+ + setShowCreate(false)} onSuccess={loadClients} /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/employees/page.tsx b/src/app/(dashboard)/dashboard/employees/page.tsx new file mode 100644 index 0000000..2c999a0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/employees/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const [editTarget, setEditTarget] = useState(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 ( +
+ setShowCreate(true)}>Add Employee} /> +
+ 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) => {e.employeeNo} }, + { key: 'firstName', label: 'Name', sortable: true, render: (e: any) => {e.firstName} {e.lastName} }, + { key: 'position', label: 'Position', sortable: true, render: (e: any) => {e.position} }, + { key: 'department', label: 'Department', render: (e: any) => {e.department || '—'} }, + { key: 'status', label: 'Status', sortable: true, render: (e: any) => }, + { key: 'salary', label: 'Salary', align: 'right' as const, render: (e: any) => e.salary ? PHP {Number(e.salary).toLocaleString()} : }, + ]} + /> +
+ setShowCreate(false)} onSuccess={load} /> + {editTarget && setEditTarget(null)} onSuccess={() => { setEditTarget(null); load(); }} />} +
+ ); +} + +// ─── 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([]); + 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 ( + +
+
+
setForm({ ...form, firstName: e.target.value })} className={ic} />
+
setForm({ ...form, lastName: e.target.value })} className={ic} />
+
+
+
setForm({ ...form, position: e.target.value })} className={ic} placeholder="e.g. Technician" />
+
setForm({ ...form, department: e.target.value })} className={ic} placeholder="e.g. Operations" />
+
+
+
setForm({ ...form, email: e.target.value })} className={ic} />
+
setForm({ ...form, salary: parseFloat(e.target.value) || 0 })} className={ic} />
+
+
+ + +

Links this employee record to a system user for login access

+
+
+
+
+ ); +} + +// ─── 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([]); + 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 ( + +
+
+
setForm({ ...form, firstName: e.target.value })} className={ic} />
+
setForm({ ...form, lastName: e.target.value })} className={ic} />
+
+
+
setForm({ ...form, position: e.target.value })} className={ic} placeholder="e.g. Technician" />
+
setForm({ ...form, department: e.target.value })} className={ic} placeholder="e.g. Operations" />
+
+
+
setForm({ ...form, email: e.target.value })} className={ic} />
+
setForm({ ...form, phone: e.target.value })} className={ic} />
+
+
+
setForm({ ...form, salary: parseFloat(e.target.value) || 0 })} className={ic} />
+
+ + +
+
+
+ + +
+
+ +