merge: develop into main
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
|
test-screenshots
|
||||||
|
tests
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
test-screenshots/
|
||||||
|
tests/
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -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"]
|
||||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal 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.
|
||||||
8
next.config.ts
Normal file
8
next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
transpilePackages: ['@fiberops/shared'],
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
36
package.json
Normal file
36
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/shared/package.json
Normal file
18
packages/shared/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/shared/src/constants/index.ts
Normal file
16
packages/shared/src/constants/index.ts
Normal 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';
|
||||||
224
packages/shared/src/constants/permissions.ts
Normal file
224
packages/shared/src/constants/permissions.ts
Normal 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 [];
|
||||||
|
}
|
||||||
36
packages/shared/src/constants/roles.ts
Normal file
36
packages/shared/src/constants/roles.ts
Normal 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);
|
||||||
|
}
|
||||||
67
packages/shared/src/constants/statuses.ts
Normal file
67
packages/shared/src/constants/statuses.ts
Normal 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];
|
||||||
3
packages/shared/src/index.ts
Normal file
3
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './constants';
|
||||||
|
export * from './schemas';
|
||||||
|
export * from './types';
|
||||||
25
packages/shared/src/schemas/auth.ts
Normal file
25
packages/shared/src/schemas/auth.ts
Normal 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>;
|
||||||
5
packages/shared/src/schemas/index.ts
Normal file
5
packages/shared/src/schemas/index.ts
Normal 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';
|
||||||
27
packages/shared/src/schemas/user.ts
Normal file
27
packages/shared/src/schemas/user.ts
Normal 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>;
|
||||||
36
packages/shared/src/types/api.ts
Normal file
36
packages/shared/src/types/api.ts
Normal 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;
|
||||||
|
}
|
||||||
7
packages/shared/src/types/index.ts
Normal file
7
packages/shared/src/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type {
|
||||||
|
ApiResponse,
|
||||||
|
PaginationMeta,
|
||||||
|
PaginationQuery,
|
||||||
|
JwtPayload,
|
||||||
|
TokenResponse,
|
||||||
|
} from './api';
|
||||||
20
packages/shared/tsconfig.json
Normal file
20
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
90
playwright-report/index.html
Normal file
90
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
638
src/app/(dashboard)/dashboard/accounting/page.tsx
Normal file
638
src/app/(dashboard)/dashboard/accounting/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/app/(dashboard)/dashboard/accounts/page.tsx
Normal file
140
src/app/(dashboard)/dashboard/accounts/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/app/(dashboard)/dashboard/areas/page.tsx
Normal file
166
src/app/(dashboard)/dashboard/areas/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/app/(dashboard)/dashboard/assets/page.tsx
Normal file
115
src/app/(dashboard)/dashboard/assets/page.tsx
Normal 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>);
|
||||||
|
}
|
||||||
110
src/app/(dashboard)/dashboard/change-password/page.tsx
Normal file
110
src/app/(dashboard)/dashboard/change-password/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
src/app/(dashboard)/dashboard/clients/[id]/page.tsx
Normal file
320
src/app/(dashboard)/dashboard/clients/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/app/(dashboard)/dashboard/clients/page.tsx
Normal file
95
src/app/(dashboard)/dashboard/clients/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/app/(dashboard)/dashboard/employees/page.tsx
Normal file
196
src/app/(dashboard)/dashboard/employees/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/app/(dashboard)/dashboard/expenses/page.tsx
Normal file
246
src/app/(dashboard)/dashboard/expenses/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/(dashboard)/dashboard/invoices/page.tsx
Normal file
150
src/app/(dashboard)/dashboard/invoices/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
566
src/app/(dashboard)/dashboard/page.tsx
Normal file
566
src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
src/app/(dashboard)/dashboard/payments/page.tsx
Normal file
309
src/app/(dashboard)/dashboard/payments/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
src/app/(dashboard)/dashboard/payroll/page.tsx
Normal file
266
src/app/(dashboard)/dashboard/payroll/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/app/(dashboard)/dashboard/plans/page.tsx
Normal file
145
src/app/(dashboard)/dashboard/plans/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
740
src/app/(dashboard)/dashboard/reports/page.tsx
Normal file
740
src/app/(dashboard)/dashboard/reports/page.tsx
Normal 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'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%']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/app/(dashboard)/dashboard/settings/areas/page.tsx
Normal file
162
src/app/(dashboard)/dashboard/settings/areas/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/app/(dashboard)/dashboard/settings/billing/page.tsx
Normal file
83
src/app/(dashboard)/dashboard/settings/billing/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/app/(dashboard)/dashboard/settings/company-accounts/page.tsx
Normal file
125
src/app/(dashboard)/dashboard/settings/company-accounts/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/app/(dashboard)/dashboard/settings/layout.tsx
Normal file
114
src/app/(dashboard)/dashboard/settings/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/(dashboard)/dashboard/settings/page.tsx
Normal file
105
src/app/(dashboard)/dashboard/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/app/(dashboard)/dashboard/settings/plans/page.tsx
Normal file
159
src/app/(dashboard)/dashboard/settings/plans/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
406
src/app/(dashboard)/dashboard/settings/roles/page.tsx
Normal file
406
src/app/(dashboard)/dashboard/settings/roles/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
src/app/(dashboard)/dashboard/settings/support/page.tsx
Normal file
235
src/app/(dashboard)/dashboard/settings/support/page.tsx
Normal 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">×</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
src/app/(dashboard)/dashboard/settings/users/page.tsx
Normal file
327
src/app/(dashboard)/dashboard/settings/users/page.tsx
Normal 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 > 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 > 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/app/(dashboard)/dashboard/subscriptions/page.tsx
Normal file
128
src/app/(dashboard)/dashboard/subscriptions/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
355
src/app/(dashboard)/dashboard/support/[id]/page.tsx
Normal file
355
src/app/(dashboard)/dashboard/support/[id]/page.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
← 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
src/app/(dashboard)/dashboard/support/page.tsx
Normal file
296
src/app/(dashboard)/dashboard/support/page.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/app/(dashboard)/dashboard/tickets/page.tsx
Normal file
109
src/app/(dashboard)/dashboard/tickets/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/(dashboard)/dashboard/users/page.tsx
Normal file
5
src/app/(dashboard)/dashboard/users/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function UsersRedirect() {
|
||||||
|
redirect('/dashboard/settings/users');
|
||||||
|
}
|
||||||
39
src/app/(dashboard)/layout.tsx
Normal file
39
src/app/(dashboard)/layout.tsx
Normal 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
65
src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
37
src/app/layout.tsx
Normal file
37
src/app/layout.tsx
Normal 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
105
src/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/page.tsx
Normal file
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
43
src/components/layout/auth-provider.tsx
Normal file
43
src/components/layout/auth-provider.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
165
src/components/layout/header.tsx
Normal file
165
src/components/layout/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/layout/must-change-password-guard.tsx
Normal file
30
src/components/layout/must-change-password-guard.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
159
src/components/layout/sidebar.tsx
Normal file
159
src/components/layout/sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/components/maps/leaflet-map.tsx
Normal file
159
src/components/maps/leaflet-map.tsx
Normal 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: '© <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/maps/location-picker-modal.tsx
Normal file
81
src/components/maps/location-picker-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/components/modals/create-client-modal.tsx
Normal file
165
src/components/modals/create-client-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/modals/create-expense-modal.tsx
Normal file
48
src/components/modals/create-expense-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/modals/create-subscription-modal.tsx
Normal file
102
src/components/modals/create-subscription-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/components/modals/create-ticket-modal.tsx
Normal file
155
src/components/modals/create-ticket-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
src/components/modals/payment-modal.tsx
Normal file
303
src/components/modals/payment-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/modals/support-modal.tsx
Normal file
109
src/components/modals/support-modal.tsx
Normal 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">×</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/components/modals/ticket-detail-modal.tsx
Normal file
246
src/components/modals/ticket-detail-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/components/modals/transfer-modal.tsx
Normal file
60
src/components/modals/transfer-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/components/ui/action-icon.tsx
Normal file
178
src/components/ui/action-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/ui/action-menu.tsx
Normal file
90
src/components/ui/action-menu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/ui/badge.tsx
Normal file
49
src/components/ui/badge.tsx
Normal 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';
|
||||||
|
}
|
||||||
42
src/components/ui/button.tsx
Normal file
42
src/components/ui/button.tsx
Normal 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';
|
||||||
238
src/components/ui/data-table.tsx
Normal file
238
src/components/ui/data-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/ui/empty-state.tsx
Normal file
23
src/components/ui/empty-state.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/ui/form-modal.tsx
Normal file
55
src/components/ui/form-modal.tsx
Normal 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
122
src/components/ui/modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/ui/page-header.tsx
Normal file
19
src/components/ui/page-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/components/ui/select.tsx
Normal file
130
src/components/ui/select.tsx
Normal 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 };
|
||||||
38
src/components/ui/skeleton.tsx
Normal file
38
src/components/ui/skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/components/ui/toast.tsx
Normal file
93
src/components/ui/toast.tsx
Normal 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
32
src/lib/api.ts
Normal 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
77
src/lib/auth.ts
Normal 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
14
src/lib/form-styles.ts
Normal 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
98
src/stores/auth.store.ts
Normal 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');
|
||||||
|
},
|
||||||
|
}));
|
||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user