Compare commits
7 Commits
e84d7b81f0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bc3da361 | ||
|
|
99cc1240d5 | ||
|
|
f516d4de8a | ||
|
|
17ebbcc5aa | ||
|
|
6f995a10c8 | ||
|
|
07980fcc59 | ||
|
|
be0b7d50d2 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3004
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
ARG NODE_ENV=production
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
RUN npm install
|
||||||
|
COPY packages/shared/ ./packages/shared/
|
||||||
|
COPY . .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN npx next build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
ENV NODE_ENV=production HOSTNAME=0.0.0.0
|
||||||
|
EXPOSE 3003
|
||||||
|
USER nextjs
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
6
next.config.mjs
Normal file
6
next.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "fiberops-web-admin",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": ["packages/*"],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 3003",
|
||||||
|
"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",
|
||||||
|
"next": "^15.3.0",
|
||||||
|
"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/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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';
|
||||||
19
packages/shared/tsconfig.json
Normal file
19
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
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;
|
||||||
177
src/app/(admin)/audit-logs/page.tsx
Normal file
177
src/app/(admin)/audit-logs/page.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
tenantId: string | null;
|
||||||
|
userId: string;
|
||||||
|
action: string;
|
||||||
|
entity: string;
|
||||||
|
entityId: string;
|
||||||
|
details: Record<string, any>;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogsPage() {
|
||||||
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [action, setAction] = useState('');
|
||||||
|
const [entity, setEntity] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { loadLogs(); }, [page, action, entity, startDate, endDate]);
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
|
if (action) params.set('action', action);
|
||||||
|
if (entity) params.set('entity', entity);
|
||||||
|
if (startDate) params.set('startDate', startDate);
|
||||||
|
if (endDate) params.set('endDate', endDate);
|
||||||
|
const res = await api.get(`/audit-logs?${params}`);
|
||||||
|
setLogs(res.data.data.items);
|
||||||
|
setTotal(res.data.data.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setAction('');
|
||||||
|
setEntity('');
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = action || entity || startDate || endDate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-surface-900">Audit Logs</h2>
|
||||||
|
<p className="text-sm text-surface-500">{total} total entries</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => { setStartDate(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
title="Start date"
|
||||||
|
/>
|
||||||
|
<span className="text-surface-400 text-sm">to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => { setEndDate(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
title="End date"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={action}
|
||||||
|
onChange={(e) => { setAction(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All Actions</option>
|
||||||
|
<option value="payment.created">Payment Created</option>
|
||||||
|
<option value="payment.updated">Payment Updated</option>
|
||||||
|
<option value="invoice.created">Invoice Created</option>
|
||||||
|
<option value="client.created">Client Created</option>
|
||||||
|
<option value="subscription.created">Subscription Created</option>
|
||||||
|
<option value="ticket.created">Ticket Created</option>
|
||||||
|
<option value="ticket.updated">Ticket Updated</option>
|
||||||
|
<option value="user.login">User Login</option>
|
||||||
|
<option value="impersonate.start">Impersonation Start</option>
|
||||||
|
<option value="remittance.confirmed">Remittance Confirmed</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={entity}
|
||||||
|
onChange={(e) => { setEntity(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All Entities</option>
|
||||||
|
<option value="payment">Payment</option>
|
||||||
|
<option value="invoice">Invoice</option>
|
||||||
|
<option value="client">Client</option>
|
||||||
|
<option value="subscription">Subscription</option>
|
||||||
|
<option value="ticket">Ticket</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="remittance">Remittance</option>
|
||||||
|
</select>
|
||||||
|
{hasFilters && (
|
||||||
|
<button onClick={clearFilters} className="text-xs text-primary-600 hover:underline">Clear filters</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-200 bg-surface-50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Timestamp</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Action</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Entity</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Entity ID</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">User ID</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Tenant</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={6} className="text-center py-8 text-surface-400">Loading...</td></tr>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="text-center py-8 text-surface-400">No audit logs found</td></tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<tr key={log.id} className="border-b border-surface-100 hover:bg-surface-50">
|
||||||
|
<td className="px-4 py-3 text-surface-500 text-xs">
|
||||||
|
{new Date(log.createdAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
|
||||||
|
{log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs">{log.entity}</td>
|
||||||
|
<td className="px-4 py-3 text-surface-400 text-xs font-mono">{log.entityId?.slice(0, 8) || '—'}...</td>
|
||||||
|
<td className="px-4 py-3 text-surface-400 text-xs font-mono">{log.userId.slice(0, 8)}...</td>
|
||||||
|
<td className="px-4 py-3 text-surface-400 text-xs">{log.tenantId?.slice(0, 8) || '—'}...</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > limit && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-surface-500">
|
||||||
|
<span>Showing {(page - 1) * limit + 1}-{Math.min(page * limit, total)} of {total}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)} disabled={page === 1}
|
||||||
|
className="px-3 py-1.5 border border-surface-300 rounded-lg disabled:opacity-50"
|
||||||
|
>Previous</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)} disabled={page * limit >= total}
|
||||||
|
className="px-3 py-1.5 border border-surface-300 rounded-lg disabled:opacity-50"
|
||||||
|
>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/app/(admin)/layout.tsx
Normal file
42
src/app/(admin)/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/lib/auth';
|
||||||
|
import { AdminSidebar } from '@/components/layout/admin-sidebar';
|
||||||
|
import { AdminHeader } from '@/components/layout/admin-header';
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isLoading, hydrate } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hydrate();
|
||||||
|
}, [hydrate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, router]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-surface-50">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-primary-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-surface-50 overflow-hidden">
|
||||||
|
<AdminSidebar />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<AdminHeader />
|
||||||
|
<main className="flex-1 overflow-y-auto px-6 py-5">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/app/(admin)/page.tsx
Normal file
187
src/app/(admin)/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
totalTenants: number;
|
||||||
|
activeTenants: number;
|
||||||
|
inactiveTenants: number;
|
||||||
|
totalUsers: number;
|
||||||
|
totalClients: number;
|
||||||
|
totalSubscriptions: number;
|
||||||
|
activeSubscriptions: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
openSupportTickets: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentTenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentTicket {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
tenantName: string;
|
||||||
|
category: string;
|
||||||
|
priority: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [recentTenants, setRecentTenants] = useState<RecentTenant[]>([]);
|
||||||
|
const [recentTickets, setRecentTickets] = useState<RecentTicket[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [statsRes, activityRes] = await Promise.all([
|
||||||
|
api.get('/dashboard/stats'),
|
||||||
|
api.get('/dashboard/recent-activity'),
|
||||||
|
]);
|
||||||
|
setStats(statsRes.data.data);
|
||||||
|
setRecentTenants(activityRes.data.data.recentTenants || []);
|
||||||
|
setRecentTickets(activityRes.data.data.recentTickets || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load dashboard:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <DashboardSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ label: 'Total Tenants', value: stats?.totalTenants ?? 0, sub: `${stats?.activeTenants ?? 0} active` },
|
||||||
|
{ label: 'Total Users', value: stats?.totalUsers ?? 0 },
|
||||||
|
{ label: 'Active Subscriptions', value: stats?.activeSubscriptions ?? 0, sub: `${stats?.totalSubscriptions ?? 0} total` },
|
||||||
|
{ label: 'Platform Revenue', value: `₱${Number(stats?.totalRevenue ?? 0).toLocaleString()}` },
|
||||||
|
{ label: 'Open Tickets', value: stats?.openSupportTickets ?? 0 },
|
||||||
|
{ label: 'Total Clients', value: stats?.totalClients ?? 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.label}
|
||||||
|
className="bg-white rounded-xl border border-surface-200 p-5"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-surface-500">{card.label}</p>
|
||||||
|
<p className="text-2xl font-bold text-surface-900 mt-1">{card.value}</p>
|
||||||
|
{card.sub && <p className="text-xs text-surface-400 mt-1">{card.sub}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Recent Tenants */}
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-base font-semibold text-surface-900">Recent Tenants</h2>
|
||||||
|
<Link href="/tenants" className="text-sm text-primary-600 hover:text-primary-700">
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recentTenants.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.id}
|
||||||
|
href={`/tenants/${t.id}`}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg hover:bg-surface-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-surface-900">{t.name}</p>
|
||||||
|
<p className="text-xs text-surface-400">{t.slug}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-1 rounded-full ${
|
||||||
|
t.isActive
|
||||||
|
? 'bg-green-50 text-green-700'
|
||||||
|
: 'bg-red-50 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{recentTenants.length === 0 && (
|
||||||
|
<p className="text-sm text-surface-400 text-center py-4">No tenants yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Support Tickets */}
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-base font-semibold text-surface-900">Open Support Tickets</h2>
|
||||||
|
<Link href="/support" className="text-sm text-primary-600 hover:text-primary-700">
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recentTickets.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.id}
|
||||||
|
href={`/support/${t.id}`}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg hover:bg-surface-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-surface-900 truncate">{t.subject}</p>
|
||||||
|
<p className="text-xs text-surface-400">{t.tenantName}</p>
|
||||||
|
</div>
|
||||||
|
<PriorityBadge priority={t.priority} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{recentTickets.length === 0 && (
|
||||||
|
<p className="text-sm text-surface-400 text-center py-4">No open tickets</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriorityBadge({ priority }: { priority: string }) {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
urgent: 'bg-red-100 text-red-700',
|
||||||
|
high: 'bg-orange-100 text-orange-700',
|
||||||
|
normal: 'bg-blue-100 text-blue-700',
|
||||||
|
low: 'bg-surface-100 text-surface-600',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full capitalize ${styles[priority] || styles.normal}`}>
|
||||||
|
{priority}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-surface-200 p-5 h-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-5 h-64" />
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-5 h-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
418
src/app/(admin)/support/[id]/page.tsx
Normal file
418
src/app/(admin)/support/[id]/page.tsx
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
uploadedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
authorName: string;
|
||||||
|
authorType: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
attachments: Attachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Ticket {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
tenantSlug: string;
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
priority: string;
|
||||||
|
status: string;
|
||||||
|
assignedToId: string | null;
|
||||||
|
assignee: { id: string; firstName: string; lastName: string } | null;
|
||||||
|
comments: Comment[];
|
||||||
|
attachments: Attachment[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions = ['open', 'in_progress', 'waiting_tenant', 'resolved', 'closed'];
|
||||||
|
|
||||||
|
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 [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTicket();
|
||||||
|
}, [ticketId]);
|
||||||
|
|
||||||
|
async function loadTicket() {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/support/tickets/${ticketId}`);
|
||||||
|
setTicket(res.data.data);
|
||||||
|
} catch {
|
||||||
|
// ticket not found
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddComment() {
|
||||||
|
if (!comment.trim() && selectedFiles.length === 0) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (comment.trim()) {
|
||||||
|
await api.post(`/support/tickets/${ticketId}/comments`, { content: comment });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
const formData = new FormData();
|
||||||
|
selectedFiles.forEach((f) => formData.append('files', f));
|
||||||
|
await api.post(`/support/tickets/${ticketId}/attachments`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setComment('');
|
||||||
|
setSelectedFiles([]);
|
||||||
|
loadTicket();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to submit:', err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(newStatus: string) {
|
||||||
|
try {
|
||||||
|
await api.patch(`/support/tickets/${ticketId}`, { status: newStatus });
|
||||||
|
loadTicket();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssign() {
|
||||||
|
try {
|
||||||
|
await api.patch(`/support/tickets/${ticketId}/assign`, { adminId: 'self' });
|
||||||
|
loadTicket();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to assign:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadFiles() {
|
||||||
|
if (selectedFiles.length === 0) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
selectedFiles.forEach((f) => formData.append('files', f));
|
||||||
|
await api.post(`/support/tickets/${ticketId}/attachments`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
setSelectedFiles([]);
|
||||||
|
loadTicket();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to upload:', err);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAttachment(attachmentId: string) {
|
||||||
|
try {
|
||||||
|
await api.delete(`/support/tickets/${ticketId}/attachments/${attachmentId}`);
|
||||||
|
loadTicket();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete attachment:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
const ADMIN_API_URL = process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="text-sm text-surface-500 hover:text-surface-700"
|
||||||
|
>
|
||||||
|
← Back to 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">
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-6">
|
||||||
|
<h1 className="text-xl font-semibold text-surface-900 mb-2">{ticket.subject}</h1>
|
||||||
|
<p className="text-surface-600 whitespace-pre-wrap">{ticket.description}</p>
|
||||||
|
<div className="mt-4 text-xs text-surface-400">
|
||||||
|
Created {new Date(ticket.createdAt).toLocaleString()} by {ticket.tenantName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket-level attachments */}
|
||||||
|
{ticket.attachments.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-surface-700 mb-3">Attachments</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ticket.attachments.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-center gap-2 px-3 py-2 bg-surface-50 border border-surface-200 rounded-lg text-sm">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`${ADMIN_API_URL}/support/tickets/uploads/${a.fileName}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{a.originalName}
|
||||||
|
</a>
|
||||||
|
<span className="text-surface-400">({formatBytes(a.sizeBytes)})</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteAttachment(a.id)}
|
||||||
|
className="text-surface-400 hover:text-red-500 ml-1"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments thread */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-surface-700">
|
||||||
|
Conversation ({ticket.comments.length})
|
||||||
|
</h3>
|
||||||
|
{ticket.comments.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`bg-white rounded-lg border p-4 ${
|
||||||
|
c.authorType === 'super_admin' ? 'border-primary-200 bg-primary-50/30' : 'border-surface-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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">{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 text-surface-600'
|
||||||
|
}`}>
|
||||||
|
{c.authorType === 'super_admin' ? 'Admin' : 'Tenant'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-surface-400">{new Date(c.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-surface-700 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">
|
||||||
|
{c.attachments.map((a) => (
|
||||||
|
<a
|
||||||
|
key={a.id}
|
||||||
|
href={`${ADMIN_API_URL}/support/tickets/uploads/${a.fileName}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 bg-surface-50 border border-surface-200 rounded text-xs hover:bg-surface-100"
|
||||||
|
>
|
||||||
|
<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)})
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add comment */}
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-4 space-y-3">
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment((e.target as HTMLTextAreaElement).value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Type your response..."
|
||||||
|
className="w-full border border-surface-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Selected files 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 border border-surface-200 rounded text-xs">
|
||||||
|
<span className="text-surface-700">{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!)].slice(0, 5));
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="px-3 py-1.5 text-sm text-surface-600 border border-surface-300 rounded-lg hover:bg-surface-50"
|
||||||
|
>
|
||||||
|
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 Reply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-4 space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-surface-700">Ticket Info</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-500">Status</label>
|
||||||
|
<select
|
||||||
|
value={ticket.status}
|
||||||
|
onChange={(e) => handleStatusChange((e.target as HTMLSelectElement).value)}
|
||||||
|
className="w-full mt-1 px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
{statusOptions.map((s) => (
|
||||||
|
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-500">Priority</label>
|
||||||
|
<p className="text-sm font-medium mt-1 capitalize">{ticket.priority}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-500">Category</label>
|
||||||
|
<p className="text-sm font-medium mt-1 capitalize">{ticket.category.replace(/_/g, ' ')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-500">Tenant</label>
|
||||||
|
<p className="text-sm font-medium mt-1">{ticket.tenantName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-surface-500">Assigned To</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
{ticket.assignee ? (
|
||||||
|
<p className="text-sm">{ticket.assignee.firstName} {ticket.assignee.lastName}</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleAssign}
|
||||||
|
className="text-sm text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
Assign to me
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload files */}
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-surface-700">Upload Files</h3>
|
||||||
|
<p className="text-xs text-surface-500">Attach files directly 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) {
|
||||||
|
setSelectedFiles(Array.from(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"
|
||||||
|
/>
|
||||||
|
{selectedFiles.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleUploadFiles}
|
||||||
|
disabled={uploading}
|
||||||
|
className="w-full px-3 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading...' : `Upload ${selectedFiles.length} file(s)`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-4 space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-surface-700">Quick Actions</h3>
|
||||||
|
{ticket.status !== 'resolved' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusChange('resolved')}
|
||||||
|
className="w-full px-3 py-2 text-sm bg-green-50 text-green-700 rounded-lg hover:bg-green-100"
|
||||||
|
>
|
||||||
|
Mark as Resolved
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{ticket.status !== 'closed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusChange('closed')}
|
||||||
|
className="w-full px-3 py-2 text-sm bg-surface-100 text-surface-600 rounded-lg hover:bg-surface-200"
|
||||||
|
>
|
||||||
|
Close Ticket
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/app/(admin)/support/page.tsx
Normal file
187
src/app/(admin)/support/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import CreateTicketModal from '@/components/create-ticket-modal';
|
||||||
|
|
||||||
|
interface Ticket {
|
||||||
|
id: string;
|
||||||
|
tenantName: string;
|
||||||
|
subject: string;
|
||||||
|
category: string;
|
||||||
|
priority: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
assignee: { id: string; 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: 'bg-surface-100 text-surface-600',
|
||||||
|
normal: 'bg-blue-50 text-blue-600',
|
||||||
|
high: 'bg-orange-50 text-orange-600',
|
||||||
|
urgent: 'bg-red-50 text-red-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TenantOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupportPage() {
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [priority, setPriority] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/tenants?limit=100').then((res) => setTenants(res.data.data.items || [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTickets();
|
||||||
|
}, [status, priority, search, page]);
|
||||||
|
|
||||||
|
async function loadTickets() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
if (priority) params.set('priority', priority);
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
|
||||||
|
const res = await api.get(`/support/tickets?${params}`);
|
||||||
|
setTickets(res.data.data.items);
|
||||||
|
setTotal(res.data.data.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load tickets:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-surface-900">Support Tickets</h2>
|
||||||
|
<p className="text-sm text-surface-500">{total} total tickets</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Create Ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tickets or tenants..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm flex-1 min-w-[200px] max-w-xs"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="waiting_tenant">Waiting Tenant</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => { setPriority(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All Priority</option>
|
||||||
|
<option value="urgent">Urgent</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-200 bg-surface-50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Subject</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Tenant</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-surface-600">Priority</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-surface-600">Status</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Assignee</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-surface-600">Comments</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={7} className="text-center py-8 text-surface-400">Loading...</td></tr>
|
||||||
|
) : tickets.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="text-center py-8 text-surface-400">No tickets found</td></tr>
|
||||||
|
) : (
|
||||||
|
tickets.map((t) => (
|
||||||
|
<tr key={t.id} className="border-b border-surface-100 hover:bg-surface-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/support/${t.id}`} className="text-primary-600 hover:underline font-medium">
|
||||||
|
{t.subject}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-surface-500">{t.tenantName}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[t.priority] || ''}`}>
|
||||||
|
{t.priority}
|
||||||
|
</span>
|
||||||
|
</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-surface-500">
|
||||||
|
{t.assignee ? `${t.assignee.firstName} ${t.assignee.lastName}` : 'Unassigned'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">{t._count.comments}</td>
|
||||||
|
<td className="px-4 py-3 text-surface-500">
|
||||||
|
{new Date(t.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<CreateTicketModal
|
||||||
|
tenants={tenants}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSuccess={() => loadTickets()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
src/app/(admin)/tenants/[id]/page.tsx
Normal file
217
src/app/(admin)/tenants/[id]/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface TenantUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
roles: { role: string }[];
|
||||||
|
tenantRoles: { tenantRole: { name: string; slug: string } }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isActive: boolean;
|
||||||
|
settings: Record<string, any>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
users: TenantUser[];
|
||||||
|
_count: {
|
||||||
|
clients: number;
|
||||||
|
subscriptions: number;
|
||||||
|
invoices: number;
|
||||||
|
payments: number;
|
||||||
|
tickets: number;
|
||||||
|
};
|
||||||
|
totalRevenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TenantDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const tenantId = params.id as string;
|
||||||
|
|
||||||
|
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [impersonating, setImpersonating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { loadTenant(); }, [tenantId]);
|
||||||
|
|
||||||
|
async function loadTenant() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/tenants/${tenantId}`);
|
||||||
|
setTenant(res.data.data);
|
||||||
|
} catch {
|
||||||
|
// tenant not found
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleActive() {
|
||||||
|
if (!tenant) return;
|
||||||
|
const action = tenant.isActive ? 'deactivate' : 'activate';
|
||||||
|
try {
|
||||||
|
await api.patch(`/tenants/${tenantId}/${action}`);
|
||||||
|
loadTenant();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to ${action} tenant:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImpersonate() {
|
||||||
|
setImpersonating(true);
|
||||||
|
try {
|
||||||
|
const adminId = (JSON.parse(localStorage.getItem('admin_user') || '{}')).id;
|
||||||
|
const adminName = 'Super Admin';
|
||||||
|
const res = await api.post(`/impersonate/${tenantId}`, { adminId, adminName });
|
||||||
|
const { accessToken, impersonatedUser, tenant: t } = res.data.data;
|
||||||
|
// Open tenant app with impersonation token
|
||||||
|
const url = new URL('http://localhost:3000/impersonate');
|
||||||
|
url.searchParams.set('token', accessToken);
|
||||||
|
url.searchParams.set('user', JSON.stringify(impersonatedUser));
|
||||||
|
url.searchParams.set('tenant', JSON.stringify(t));
|
||||||
|
window.open(url.toString(), '_blank');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to impersonate:', err);
|
||||||
|
} finally {
|
||||||
|
setImpersonating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center text-surface-400">Loading...</div>;
|
||||||
|
if (!tenant) return <div className="p-8 text-center text-surface-400">Tenant not found</div>;
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ label: 'Users', value: tenant.users.length },
|
||||||
|
{ label: 'Clients', value: tenant._count.clients },
|
||||||
|
{ label: 'Subscriptions', value: tenant._count.subscriptions },
|
||||||
|
{ label: 'Invoices', value: tenant._count.invoices },
|
||||||
|
{ label: 'Payments', value: tenant._count.payments },
|
||||||
|
{ label: 'Tickets', value: tenant._count.tickets },
|
||||||
|
{ label: 'Revenue', value: `₱${Number(tenant.totalRevenue).toLocaleString()}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="text-sm text-surface-500 hover:text-surface-700"
|
||||||
|
>
|
||||||
|
← Back to tenants
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-xl font-semibold text-surface-900">{tenant.name}</h1>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
tenant.isActive ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{tenant.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-surface-500 mt-1">{tenant.slug}</p>
|
||||||
|
<p className="text-xs text-surface-400 mt-2">
|
||||||
|
Created {new Date(tenant.createdAt).toLocaleDateString()} · Updated {new Date(tenant.updatedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleToggleActive}
|
||||||
|
className={`px-3 py-2 text-sm rounded-lg ${
|
||||||
|
tenant.isActive
|
||||||
|
? 'bg-red-50 text-red-700 hover:bg-red-100'
|
||||||
|
: 'bg-green-50 text-green-700 hover:bg-green-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tenant.isActive ? 'Deactivate' : 'Activate'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImpersonate}
|
||||||
|
disabled={impersonating || !tenant.isActive}
|
||||||
|
className="px-3 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{impersonating ? 'Opening...' : 'Impersonate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<div key={card.label} className="bg-white rounded-xl border border-surface-200 p-4">
|
||||||
|
<p className="text-xs text-surface-500">{card.label}</p>
|
||||||
|
<p className="text-lg font-bold text-surface-900 mt-1">{card.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users */}
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-5">
|
||||||
|
<h3 className="text-base font-semibold text-surface-900 mb-4">Users ({tenant.users.length})</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-200 bg-surface-50">
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-surface-600">Name</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-surface-600">Email</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-surface-600">Roles</th>
|
||||||
|
<th className="text-center px-4 py-2 font-medium text-surface-600">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tenant.users.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b border-surface-100 hover:bg-surface-50">
|
||||||
|
<td className="px-4 py-2 font-medium">{u.firstName} {u.lastName}</td>
|
||||||
|
<td className="px-4 py-2 text-surface-500">{u.email}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{u.roles.map((r, i) => (
|
||||||
|
<span key={i} className="text-xs px-1.5 py-0.5 bg-surface-100 rounded">{r.role}</span>
|
||||||
|
))}
|
||||||
|
{u.tenantRoles.map((tr, i) => (
|
||||||
|
<span key={i} className="text-xs px-1.5 py-0.5 bg-primary-50 text-primary-700 rounded">
|
||||||
|
{tr.tenantRole.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
u.isActive ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{u.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-5">
|
||||||
|
<h3 className="text-base font-semibold text-surface-900 mb-4">Settings</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
{tenant.settings && Object.entries(tenant.settings).map(([key, value]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<span className="text-surface-500 capitalize">{key.replace(/([A-Z])/g, ' $1')}:</span>
|
||||||
|
<span className="ml-2 text-surface-900 font-medium">{String(value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/app/(admin)/tenants/new/page.tsx
Normal file
140
src/app/(admin)/tenants/new/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
function slugify(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewTenantPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [slugManual, setSlugManual] = useState(false);
|
||||||
|
const [adminEmail, setAdminEmail] = useState('');
|
||||||
|
const [adminPassword, setAdminPassword] = useState('');
|
||||||
|
const [adminFirstName, setAdminFirstName] = useState('');
|
||||||
|
const [adminLastName, setAdminLastName] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
function handleNameChange(value: string) {
|
||||||
|
setName(value);
|
||||||
|
if (!slugManual) setSlug(slugify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlugChange(value: string) {
|
||||||
|
setSlug(value);
|
||||||
|
setSlugManual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !slug.trim() || !adminEmail.trim() || !adminPassword.trim() || !adminFirstName.trim() || !adminLastName.trim()) return;
|
||||||
|
setError('');
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.post('/tenants', {
|
||||||
|
name: name.trim(),
|
||||||
|
slug: slug.trim(),
|
||||||
|
adminEmail: adminEmail.trim(),
|
||||||
|
adminPassword,
|
||||||
|
adminFirstName: adminFirstName.trim(),
|
||||||
|
adminLastName: adminLastName.trim(),
|
||||||
|
});
|
||||||
|
router.push('/tenants');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to create tenant');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<button onClick={() => router.push('/tenants')} className="text-sm text-surface-500 hover:text-surface-700 mb-2 inline-flex items-center gap-1">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||||
|
<path d="M10 3L5 8l5 5" />
|
||||||
|
</svg>
|
||||||
|
Back to Tenants
|
||||||
|
</button>
|
||||||
|
<h2 className="text-xl font-semibold text-surface-900">Create New Tenant</h2>
|
||||||
|
<p className="text-sm text-surface-500 mt-1">Add a new ISP organization and its admin account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200 mb-4">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-surface-200 p-6 space-y-5">
|
||||||
|
{/* Tenant Info */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-surface-700 mb-3 uppercase tracking-wide">Organization</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Company Name *</label>
|
||||||
|
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm" placeholder="e.g. Fiber Internet Services" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Slug *</label>
|
||||||
|
<input type="text" value={slug} onChange={(e) => handleSlugChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm font-mono" placeholder="e.g. fiber-internet-services" required />
|
||||||
|
<p className="text-xs text-surface-400 mt-1">Auto-generated from name. Edit manually if needed. Used as a unique identifier.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-surface-200" />
|
||||||
|
|
||||||
|
{/* Admin Account */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-surface-700 mb-3 uppercase tracking-wide">Admin Account</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">First Name *</label>
|
||||||
|
<input type="text" value={adminFirstName} onChange={(e) => setAdminFirstName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Last Name *</label>
|
||||||
|
<input type="text" value={adminLastName} onChange={(e) => setAdminLastName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Email *</label>
|
||||||
|
<input type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm" placeholder="admin@company.com" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Password *</label>
|
||||||
|
<input type="password" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm" placeholder="Min. 8 characters" required minLength={8} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={() => router.push('/tenants')}
|
||||||
|
className="px-4 py-2 text-sm text-surface-600 border border-surface-300 rounded-lg hover:bg-surface-50">
|
||||||
|
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 ? 'Creating...' : 'Create Tenant'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/app/(admin)/tenants/page.tsx
Normal file
150
src/app/(admin)/tenants/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
_count: { users: number; clients: number; subscriptions: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TenantsPage() {
|
||||||
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [status, setStatus] = useState<string>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTenants();
|
||||||
|
}, [search, status, page]);
|
||||||
|
|
||||||
|
async function loadTenants() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
if (status !== 'all') params.set('status', status);
|
||||||
|
|
||||||
|
const res = await api.get(`/tenants?${params}`);
|
||||||
|
setTenants(res.data.data.items);
|
||||||
|
setTotal(res.data.data.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load tenants:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-surface-900">Tenants</h2>
|
||||||
|
<p className="text-sm text-surface-500">{total} total tenants</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/tenants/new"
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
New Tenant
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tenants..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm flex-1 max-w-xs"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-200 bg-surface-50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Slug</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-surface-600">Status</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-surface-600">Users</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-surface-600">Clients</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-surface-600">Subscriptions</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={7} className="text-center py-8 text-surface-400">Loading...</td></tr>
|
||||||
|
) : tenants.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="text-center py-8 text-surface-400">No tenants found</td></tr>
|
||||||
|
) : (
|
||||||
|
tenants.map((t) => (
|
||||||
|
<tr key={t.id} className="border-b border-surface-100 hover:bg-surface-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/tenants/${t.id}`} className="text-primary-600 hover:underline font-medium">
|
||||||
|
{t.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-surface-500">{t.slug}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
t.isActive ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{t.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">{t._count.users}</td>
|
||||||
|
<td className="px-4 py-3 text-center">{t._count.clients}</td>
|
||||||
|
<td className="px-4 py-3 text-center">{t._count.subscriptions}</td>
|
||||||
|
<td className="px-4 py-3 text-surface-500">
|
||||||
|
{new Date(t.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-surface-500">Page {page} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
src/app/(admin)/users/page.tsx
Normal file
199
src/app/(admin)/users/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import EditUserModal from '@/components/edit-user-modal';
|
||||||
|
import ResetPasswordModal from '@/components/reset-password-modal';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
tenant: { id: string; name: string; slug: string } | null;
|
||||||
|
roles: { role: string }[];
|
||||||
|
tenantRoles: { tenantRole: { name: string; slug: string } }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [tenantId, setTenantId] = useState('');
|
||||||
|
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [resettingUser, setResettingUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { loadTenants(); }, []);
|
||||||
|
useEffect(() => { loadUsers(); }, [search, tenantId, page]);
|
||||||
|
|
||||||
|
async function loadTenants() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/tenants?limit=100');
|
||||||
|
setTenants(res.data.data.items || []);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
if (tenantId) params.set('tenantId', tenantId);
|
||||||
|
const res = await api.get(`/users?${params}`);
|
||||||
|
setUsers(res.data.data.items);
|
||||||
|
setTotal(res.data.data.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-surface-900">Users</h2>
|
||||||
|
<p className="text-sm text-surface-500">{total} total users across all tenants</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm flex-1 min-w-[200px] max-w-xs"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={tenantId}
|
||||||
|
onChange={(e) => { setTenantId(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All Tenants</option>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-200 bg-surface-50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Email</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Tenant</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-surface-600">Roles</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-surface-600">Status</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-surface-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={6} className="text-center py-8 text-surface-400">Loading...</td></tr>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="text-center py-8 text-surface-400">No users found</td></tr>
|
||||||
|
) : (
|
||||||
|
users.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b border-surface-100 hover:bg-surface-50">
|
||||||
|
<td className="px-4 py-3 font-medium">{u.firstName} {u.lastName}</td>
|
||||||
|
<td className="px-4 py-3 text-surface-500">{u.email}</td>
|
||||||
|
<td className="px-4 py-3 text-surface-500">{u.tenant?.name || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{u.roles.map((r, i) => (
|
||||||
|
<span key={i} className="text-xs px-1.5 py-0.5 bg-surface-100 rounded">{r.role}</span>
|
||||||
|
))}
|
||||||
|
{u.tenantRoles.map((tr, i) => (
|
||||||
|
<span key={i} className="text-xs px-1.5 py-0.5 bg-primary-50 text-primary-700 rounded">
|
||||||
|
{tr.tenantRole.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
u.isActive ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{u.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingUser(u)}
|
||||||
|
title="Edit user"
|
||||||
|
className="p-1.5 text-surface-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" 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>
|
||||||
|
<button
|
||||||
|
onClick={() => setResettingUser(u)}
|
||||||
|
title="Reset password"
|
||||||
|
className="p-1.5 text-surface-400 hover:text-orange-600 hover:bg-orange-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||||
|
<rect x="3" y="6" width="10" height="7" rx="1.5" />
|
||||||
|
<path d="M5 6V4.5a3 3 0 016 0V6" />
|
||||||
|
<circle cx="8" cy="9.5" r="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > limit && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-surface-500">
|
||||||
|
<span>Showing {(page - 1) * limit + 1}-{Math.min(page * limit, total)} of {total}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)} disabled={page === 1}
|
||||||
|
className="px-3 py-1.5 border border-surface-300 rounded-lg disabled:opacity-50"
|
||||||
|
>Previous</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)} disabled={page * limit >= total}
|
||||||
|
className="px-3 py-1.5 border border-surface-300 rounded-lg disabled:opacity-50"
|
||||||
|
>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{editingUser && (
|
||||||
|
<EditUserModal
|
||||||
|
user={editingUser}
|
||||||
|
onClose={() => setEditingUser(null)}
|
||||||
|
onSuccess={loadUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{resettingUser && (
|
||||||
|
<ResetPasswordModal
|
||||||
|
user={resettingUser}
|
||||||
|
onClose={() => setResettingUser(null)}
|
||||||
|
onSuccess={loadUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/app/globals.css
Normal file
54
src/app/globals.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary-500);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/layout.tsx
Normal file
19
src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'FiberOps Admin',
|
||||||
|
description: 'Platform administration for FiberOps',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/app/login/page.tsx
Normal file
78
src/app/login/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const login = useAuthStore((s) => s.login);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
router.push('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Invalid credentials');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-surface-50">
|
||||||
|
<div className="w-full max-w-sm p-8 bg-white rounded-xl shadow-lg border border-surface-200">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-surface-900">FiberOps</h1>
|
||||||
|
<p className="text-sm text-surface-500 mt-1">Platform Administration</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="superadmin@fiberops.dev"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 px-4 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 font-medium"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/not-found.tsx
Normal file
10
src/app/not-found.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<h1>404 - Page Not Found</h1>
|
||||||
|
<p>The page you are looking for does not exist.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/create-ticket-modal.tsx
Normal file
127
src/components/create-ticket-modal.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface TenantOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tenants: TenantOption[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateTicketModal({ tenants, onClose, onSuccess }: Props) {
|
||||||
|
const [tenantId, setTenantId] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState('general');
|
||||||
|
const [priority, setPriority] = useState('normal');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!tenantId || !subject.trim() || !description.trim()) return;
|
||||||
|
setError('');
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.post('/support/tickets', { tenantId, subject, description, category, priority });
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to create ticket');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-white rounded-xl p-6 w-full max-w-lg shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Create Support Ticket</h3>
|
||||||
|
|
||||||
|
{error && <div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200 mb-4">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Tenant</label>
|
||||||
|
<select
|
||||||
|
value={tenantId}
|
||||||
|
onChange={(e) => setTenantId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a tenant...</option>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 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 rounded-lg text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 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 rounded-lg text-sm resize-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<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 mb-1">Priority</label>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="urgent">Urgent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-surface-600 border border-surface-300 rounded-lg">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !tenantId}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Creating...' : 'Create Ticket'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/edit-user-modal.tsx
Normal file
83
src/components/edit-user-modal.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditUserModal({ user, onClose, onSuccess }: Props) {
|
||||||
|
const [firstName, setFirstName] = useState(user.firstName);
|
||||||
|
const [lastName, setLastName] = useState(user.lastName);
|
||||||
|
const [isActive, setIsActive] = useState(user.isActive);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const { default: api } = await import('@/lib/api');
|
||||||
|
await api.patch(`/users/${user.id}`, { firstName, lastName, isActive });
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update user:', err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Edit User</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">First Name</label>
|
||||||
|
<input
|
||||||
|
type="text" value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Last Name</label>
|
||||||
|
<input
|
||||||
|
type="text" value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Email</label>
|
||||||
|
<input type="email" value={user.email} disabled className="w-full px-3 py-2 border border-surface-200 rounded-lg text-sm bg-surface-50" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox" id="isActive" checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
className="rounded border-surface-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="isActive" className="text-sm text-surface-700">Active</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-surface-600 border border-surface-300 rounded-lg">Cancel</button>
|
||||||
|
<button type="submit" disabled={submitting} className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50">
|
||||||
|
{submitting ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/layout/admin-header.tsx
Normal file
33
src/components/layout/admin-header.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/lib/auth';
|
||||||
|
|
||||||
|
export function AdminHeader() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { logout, admin } = useAuthStore();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
router.replace('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-16 bg-white border-b border-surface-200 flex items-center justify-between px-6 shrink-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-surface-900">Platform Administration</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-surface-600">
|
||||||
|
{admin?.firstName} {admin?.lastName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-sm text-surface-500 hover:text-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/layout/admin-sidebar.tsx
Normal file
72
src/components/layout/admin-sidebar.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/lib/auth';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/', icon: '📊' },
|
||||||
|
{ name: 'Tenants', href: '/tenants', icon: '🏢' },
|
||||||
|
{ name: 'Users', href: '/users', icon: '👥' },
|
||||||
|
{
|
||||||
|
name: 'Support Tickets',
|
||||||
|
href: '/support',
|
||||||
|
icon: '🎫',
|
||||||
|
children: [
|
||||||
|
{ name: 'All Tickets', href: '/support' },
|
||||||
|
{ name: 'Open', href: '/support?status=open' },
|
||||||
|
{ name: 'My Assigned', href: '/support?assigned=me' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'Audit Logs', href: '/audit-logs', icon: '📋' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdminSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const admin = useAuthStore((s) => s.admin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 bg-white border-r border-surface-200 flex flex-col shrink-0">
|
||||||
|
<div className="h-16 flex items-center px-6 border-b border-surface-200">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold text-primary-600">FiberOps</span>
|
||||||
|
<span className="text-xs font-medium text-surface-400 bg-surface-100 px-2 py-0.5 rounded">Admin</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.href === '/'
|
||||||
|
? pathname === '/'
|
||||||
|
: pathname.startsWith(item.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.name}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-50 text-primary-700'
|
||||||
|
: 'text-surface-600 hover:bg-surface-100 hover:text-surface-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-surface-200">
|
||||||
|
<div className="text-xs text-surface-500">
|
||||||
|
<p className="font-medium text-surface-700">
|
||||||
|
{admin?.firstName} {admin?.lastName}
|
||||||
|
</p>
|
||||||
|
<p>{admin?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/reset-password-modal.tsx
Normal file
74
src/components/reset-password-modal.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: { id: string; firstName: string; lastName: string };
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordModal({ user, onClose, onSuccess }: Props) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (password.length < 8) { setError('Password must be at least 8 characters'); return; }
|
||||||
|
if (password !== confirm) { setError('Passwords do not match'); return; }
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const { default: api } = await import('@/lib/api');
|
||||||
|
await api.patch(`/users/${user.id}/reset-password`, { password });
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reset password:', err);
|
||||||
|
setError('Failed to reset password');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-semibold mb-1">Reset Password</h3>
|
||||||
|
<p className="text-sm text-surface-500 mb-4">for {user.firstName} {user.lastName}</p>
|
||||||
|
|
||||||
|
{error && <div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200 mb-4">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password" value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
required minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-700 mb-1">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password" value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||||
|
required minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-surface-600 border border-surface-300 rounded-lg">Cancel</button>
|
||||||
|
<button type="submit" disabled={submitting} className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||||
|
{submitting ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/lib/api.ts
Normal file
55
src/lib/api.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const token = localStorage.getItem('admin_access_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem('admin_refresh_token');
|
||||||
|
if (!refreshToken) return Promise.reject(error);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(
|
||||||
|
`${process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api'}/auth/refresh`,
|
||||||
|
{ refreshToken },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { accessToken, refreshToken: newRefreshToken } = res.data.data;
|
||||||
|
localStorage.setItem('admin_access_token', accessToken);
|
||||||
|
localStorage.setItem('admin_refresh_token', newRefreshToken);
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('admin_access_token');
|
||||||
|
localStorage.removeItem('admin_refresh_token');
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
65
src/lib/auth.ts
Normal file
65
src/lib/auth.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import api from './api';
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
admin: AdminUser | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshProfile: () => Promise<void>;
|
||||||
|
hydrate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
|
admin: null,
|
||||||
|
accessToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
|
||||||
|
login: async (email: string, password: string) => {
|
||||||
|
const res = await api.post('/auth/login', { email, password });
|
||||||
|
const { accessToken, refreshToken, admin } = res.data.data;
|
||||||
|
localStorage.setItem('admin_access_token', accessToken);
|
||||||
|
localStorage.setItem('admin_refresh_token', refreshToken);
|
||||||
|
set({ admin, accessToken, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout');
|
||||||
|
} catch {
|
||||||
|
// Ignore errors on logout
|
||||||
|
}
|
||||||
|
localStorage.removeItem('admin_access_token');
|
||||||
|
localStorage.removeItem('admin_refresh_token');
|
||||||
|
set({ admin: null, accessToken: null, isAuthenticated: false, isLoading: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshProfile: async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/auth/profile');
|
||||||
|
set({ admin: res.data.data, isAuthenticated: true, isLoading: false });
|
||||||
|
} catch {
|
||||||
|
get().logout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hydrate: () => {
|
||||||
|
const token = localStorage.getItem('admin_access_token');
|
||||||
|
if (token) {
|
||||||
|
set({ accessToken: token, isLoading: true });
|
||||||
|
get().refreshProfile();
|
||||||
|
} else {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
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