diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..446e7f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +.env +*.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..62171f5 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:3004 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..190afa1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.next/ +out/ +.env +.env.local +.env.*.local +dist/ +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c98bde5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +COPY packages/shared/package.json ./packages/shared/ +RUN npm install +COPY packages/shared/ ./packages/shared/ +COPY . . +RUN npx next build + +FROM node:20-alpine AS runner +WORKDIR /app +RUN apk add --no-cache dumb-init +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +ENV NODE_ENV=production HOSTNAME=0.0.0.0 +EXPOSE 3003 +USER nextjs +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "server.js"] diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..130bf5b --- /dev/null +++ b/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + reactStrictMode: true, + output: 'standalone', +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..e12cc33 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..073de52 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@fiberops/shared", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "zod": "^3.24.0" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts new file mode 100644 index 0000000..8c7be2b --- /dev/null +++ b/packages/shared/src/constants/index.ts @@ -0,0 +1,16 @@ +export { Role, ALL_ROLES, satisfiesRole } from './roles'; +export { + Permission, getPermissionsForRoles, + MODULES, MODULE_LABELS, ACTIONS, ACTION_LABELS, MODULE_ACTIONS, + DEFAULT_ROLE_PERMISSIONS, +} from './permissions'; +export type { Module, Action, PermissionRow, PermissionString } from './permissions'; +export { + SubscriptionStatus, + SubscriptionType, + InvoiceStatus, + TicketType, + TicketStatus, + PaymentMethod, + AccountStatus, +} from './statuses'; diff --git a/packages/shared/src/constants/permissions.ts b/packages/shared/src/constants/permissions.ts new file mode 100644 index 0000000..5a19140 --- /dev/null +++ b/packages/shared/src/constants/permissions.ts @@ -0,0 +1,224 @@ +import { Role } from './roles'; + +/** + * All modules available in the permission matrix. + * Each module can have: view, create, update, archive, approve, export actions. + */ +export const MODULES = [ + 'dashboard', + 'clients', + 'subscriptions', + 'invoices', + 'payments', + 'tickets', + 'employees', + 'payroll', + 'expenses', + 'assets', + 'accounts', + 'fund_transfers', + 'accounting', + 'reports', + 'areas', + 'plans', + 'settings', + 'users', +] as const; + +export type Module = (typeof MODULES)[number]; + +export const MODULE_LABELS: Record = { + dashboard: 'Dashboard', + clients: 'Clients', + subscriptions: 'Subscriptions', + invoices: 'Invoices', + payments: 'Payments', + tickets: 'Tickets', + employees: 'Employees', + payroll: 'Payroll', + expenses: 'Expenses', + assets: 'Assets', + accounts: 'Company Accounts', + fund_transfers: 'Fund Transfers', + accounting: 'Accounting', + reports: 'Reports', + areas: 'Areas', + plans: 'Plans', + settings: 'Settings', + users: 'Users', +}; + +export const ACTIONS = ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'] as const; +export type Action = (typeof ACTIONS)[number]; + +export const ACTION_LABELS: Record = { + canView: 'View', + canCreate: 'Create', + canUpdate: 'Update', + canArchive: 'Archive', + canApprove: 'Approve', + canExport: 'Export', +}; + +/** + * Defines which actions are applicable per module. + * Only these checkboxes should be shown/enforced in the matrix. + */ +export const MODULE_ACTIONS: Record = { + dashboard: ['canView'], + clients: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'], + subscriptions: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'], + invoices: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'], + payments: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'], + tickets: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'], + employees: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'], + payroll: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'], + expenses: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canApprove', 'canExport'], + assets: ['canView', 'canCreate', 'canUpdate', 'canArchive', 'canExport'], + accounts: ['canView', 'canCreate', 'canUpdate', 'canApprove', 'canExport'], + fund_transfers: ['canView', 'canCreate', 'canApprove', 'canExport'], + accounting: ['canView', 'canExport'], + reports: ['canView', 'canExport'], + areas: ['canView', 'canCreate', 'canUpdate', 'canArchive'], + plans: ['canView', 'canCreate', 'canUpdate', 'canArchive'], + settings: ['canView', 'canUpdate'], + users: ['canView', 'canCreate', 'canUpdate', 'canArchive'], +}; + +/** + * Permission matrix type — one row per module with boolean actions. + */ +export interface PermissionRow { + module: Module; + canView: boolean; + canCreate: boolean; + canUpdate: boolean; + canArchive: boolean; + canApprove: boolean; + canExport: boolean; +} + +/** + * Default permission matrices for system roles seeded per tenant. + */ +function allTrue(modules: readonly Module[], actions: readonly Action[]): PermissionRow[] { + return MODULES.map((mod) => ({ + module: mod, + canView: actions.includes('canView') && modules.includes(mod), + canCreate: actions.includes('canCreate') && modules.includes(mod), + canUpdate: actions.includes('canUpdate') && modules.includes(mod), + canArchive: actions.includes('canArchive') && modules.includes(mod), + canApprove: actions.includes('canApprove') && modules.includes(mod), + canExport: actions.includes('canExport') && modules.includes(mod), + })); +} + +const ALL_MODULES = [...MODULES] as Module[]; + +export const DEFAULT_ROLE_PERMISSIONS: Record = { + tenant_admin: MODULES.map((mod) => ({ + module: mod, + canView: true, + canCreate: true, + canUpdate: true, + canArchive: true, + canApprove: true, + canExport: true, + })), + + manager: MODULES.map((mod) => { + const noAccess: Module[] = ['users']; + const viewOnly: Module[] = ['dashboard', 'accounting', 'settings']; + if (noAccess.includes(mod)) return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false }; + if (viewOnly.includes(mod)) return { module: mod, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: mod === 'accounting' }; + return { + module: mod, + canView: true, + canCreate: true, + canUpdate: true, + canArchive: true, + canApprove: ['invoices', 'payments', 'expenses', 'payroll', 'fund_transfers'].includes(mod), + canExport: true, + }; + }), + + technician: MODULES.map((mod) => { + // Technicians can create/update tickets and record payments in the field. + // They can VIEW clients, subscriptions, invoices for context but creating/updating + // those requires manager-level API access (@Roles('manager') on POST/PATCH). + // Assets require manager via class-level @Roles('manager'). + // Subscriptions require manager for all endpoints. + const viewOnly: Module[] = ['clients', 'subscriptions', 'invoices', 'dashboard']; + const fullAccess: Module[] = ['tickets', 'payments']; + if (viewOnly.includes(mod)) return { module: mod, canView: true, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false }; + if (fullAccess.includes(mod)) return { module: mod, canView: true, canCreate: true, canUpdate: true, canArchive: false, canApprove: false, canExport: false }; + return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false }; + }), + + collector: MODULES.map((mod) => { + const canWrite: Module[] = ['payments']; + const canView: Module[] = ['dashboard', 'clients', 'invoices', 'payments']; + if (!canView.includes(mod)) return { module: mod, canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false }; + return { + module: mod, + canView: true, + canCreate: canWrite.includes(mod), + canUpdate: false, + canArchive: false, + canApprove: false, + canExport: false, + }; + }), +}; + +// ─── Legacy support (for super_admin which uses old UserRole system) ─── + +export const Permission = { + READ_DASHBOARD: 'read:dashboard', + READ_REPORTS: 'read:reports', + READ_CLIENTS: 'read:clients', + WRITE_CLIENTS: 'write:clients', + DELETE_CLIENTS: 'delete:clients', + READ_SUBSCRIPTIONS: 'read:subscriptions', + WRITE_SUBSCRIPTIONS: 'write:subscriptions', + READ_INVOICES: 'read:invoices', + WRITE_INVOICES: 'write:invoices', + VOID_INVOICES: 'void:invoices', + READ_PAYMENTS: 'read:payments', + WRITE_PAYMENTS: 'write:payments', + APPROVE_REMITTANCES: 'approve:remittances', + READ_TICKETS: 'read:tickets', + WRITE_TICKETS: 'write:tickets', + ASSIGN_TICKETS: 'assign:tickets', + READ_EMPLOYEES: 'read:employees', + WRITE_EMPLOYEES: 'write:employees', + READ_PAYROLL: 'read:payroll', + WRITE_PAYROLL: 'write:payroll', + READ_EXPENSES: 'read:expenses', + WRITE_EXPENSES: 'write:expenses', + APPROVE_EXPENSES: 'approve:expenses', + READ_ASSETS: 'read:assets', + WRITE_ASSETS: 'write:assets', + READ_ACCOUNTS: 'read:accounts', + WRITE_ACCOUNTS: 'write:accounts', + TRANSFER_ACCOUNTS: 'transfer:accounts', + READ_ACCOUNTING: 'read:accounting', + WRITE_ACCOUNTING: 'write:accounting', + READ_SETTINGS: 'read:settings', + WRITE_SETTINGS: 'write:settings', + MANAGE_USERS: 'manage:users', + MANAGE_TENANTS: 'manage:tenants', +} as const; + +export type PermissionString = (typeof Permission)[keyof typeof Permission]; + +/** + * Get permissions for super_admin (all permissions). + */ +export function getPermissionsForRoles(roles: string[]): string[] { + if (roles.includes(Role.SUPER_ADMIN)) { + return Object.values(Permission); + } + // For tenant users, permissions come from TenantRole → RolePermission in DB + return []; +} diff --git a/packages/shared/src/constants/roles.ts b/packages/shared/src/constants/roles.ts new file mode 100644 index 0000000..568ad34 --- /dev/null +++ b/packages/shared/src/constants/roles.ts @@ -0,0 +1,36 @@ +export const Role = { + SUPER_ADMIN: 'super_admin', + TENANT_ADMIN: 'tenant_admin', + MANAGER: 'manager', + TECHNICIAN: 'technician', + VIEWER: 'viewer', +} as const; + +export type Role = (typeof Role)[keyof typeof Role]; + +export const ALL_ROLES: readonly Role[] = Object.values(Role); + +/** + * Numeric hierarchy level per role. + * Higher number = more powerful role. + * Used for hierarchy-aware authorization checks. + */ +const ROLE_LEVEL: Record = { + [Role.SUPER_ADMIN]: 100, + [Role.TENANT_ADMIN]: 80, + [Role.MANAGER]: 60, + [Role.TECHNICIAN]: 40, + [Role.VIEWER]: 20, +}; + +/** + * Check if any of the user's roles satisfies the required role level. + * A higher-level role always satisfies a lower-level requirement. + * + * Example: user with ['manager'] satisfies 'technician' because manager(60) >= technician(40). + */ +export function satisfiesRole(userRoles: string[], requiredRole: string): boolean { + const requiredLevel = ROLE_LEVEL[requiredRole]; + if (requiredLevel === undefined) return false; + return userRoles.some((r) => (ROLE_LEVEL[r] ?? 0) >= requiredLevel); +} diff --git a/packages/shared/src/constants/statuses.ts b/packages/shared/src/constants/statuses.ts new file mode 100644 index 0000000..5f5934b --- /dev/null +++ b/packages/shared/src/constants/statuses.ts @@ -0,0 +1,67 @@ +export const SubscriptionStatus = { + PENDING: 'pending', + ACTIVE: 'active', + SUSPENDED: 'suspended', + CANCELLED: 'cancelled', + EXPIRED: 'expired', +} as const; + +export type SubscriptionStatus = + (typeof SubscriptionStatus)[keyof typeof SubscriptionStatus]; + +export const SubscriptionType = { + PREPAID: 'prepaid', + POSTPAID: 'postpaid', +} as const; + +export type SubscriptionType = + (typeof SubscriptionType)[keyof typeof SubscriptionType]; + +export const InvoiceStatus = { + DRAFT: 'draft', + SENT: 'sent', + PARTIAL: 'partial', + PAID: 'paid', + OVERDUE: 'overdue', + VOID: 'void', +} as const; + +export type InvoiceStatus = + (typeof InvoiceStatus)[keyof typeof InvoiceStatus]; + +export const TicketType = { + INSTALLATION: 'installation', + ACTIVATION: 'activation', + SUPPORT: 'support', + MAINTENANCE: 'maintenance', +} as const; + +export type TicketType = (typeof TicketType)[keyof typeof TicketType]; + +export const TicketStatus = { + OPEN: 'open', + IN_PROGRESS: 'in_progress', + RESOLVED: 'resolved', + CANCELLED: 'cancelled', +} as const; + +export type TicketStatus = (typeof TicketStatus)[keyof typeof TicketStatus]; + +export const PaymentMethod = { + GCASH: 'gcash', + MAYA: 'maya', + CASH: 'cash', + BANK_TRANSFER: 'bank_transfer', +} as const; + +export type PaymentMethod = + (typeof PaymentMethod)[keyof typeof PaymentMethod]; + +export const AccountStatus = { + ACTIVE: 'active', + INACTIVE: 'inactive', + SUSPENDED: 'suspended', +} as const; + +export type AccountStatus = + (typeof AccountStatus)[keyof typeof AccountStatus]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..0f31ffa --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from './constants'; +export * from './schemas'; +export * from './types'; diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts new file mode 100644 index 0000000..5b793fd --- /dev/null +++ b/packages/shared/src/schemas/auth.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(8, 'Password must be at least 8 characters'), +}); + +export const registerTenantSchema = z.object({ + tenantName: z.string().min(2, 'Tenant name must be at least 2 characters'), + slug: z + .string() + .min(2) + .max(50) + .regex( + /^[a-z0-9-]+$/, + 'Slug must contain only lowercase letters, numbers, and hyphens', + ), + adminEmail: z.string().email('Invalid email address'), + adminPassword: z.string().min(8, 'Password must be at least 8 characters'), + adminFirstName: z.string().min(1, 'First name is required'), + adminLastName: z.string().min(1, 'Last name is required'), +}); + +export type LoginInput = z.infer; +export type RegisterTenantInput = z.infer; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000..e5095cd --- /dev/null +++ b/packages/shared/src/schemas/index.ts @@ -0,0 +1,5 @@ +export { loginSchema, registerTenantSchema } from './auth'; +export type { LoginInput, RegisterTenantInput } from './auth'; + +export { createUserSchema, updateUserSchema } from './user'; +export type { CreateUserInput, UpdateUserInput } from './user'; diff --git a/packages/shared/src/schemas/user.ts b/packages/shared/src/schemas/user.ts new file mode 100644 index 0000000..5a8e5c1 --- /dev/null +++ b/packages/shared/src/schemas/user.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { Role } from '../constants/roles'; + +const roleEnum = z.enum([ + Role.SUPER_ADMIN, + Role.TENANT_ADMIN, + Role.MANAGER, + Role.TECHNICIAN, + Role.VIEWER, +]); + +export const createUserSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(8, 'Password must be at least 8 characters'), + firstName: z.string().min(1, 'First name is required'), + lastName: z.string().min(1, 'Last name is required'), + roles: z.array(roleEnum).min(1, 'At least one role is required'), +}); + +export const updateUserSchema = z.object({ + firstName: z.string().min(1).optional(), + lastName: z.string().min(1).optional(), + roles: z.array(roleEnum).min(1).optional(), +}); + +export type CreateUserInput = z.infer; +export type UpdateUserInput = z.infer; diff --git a/packages/shared/src/types/api.ts b/packages/shared/src/types/api.ts new file mode 100644 index 0000000..735cd83 --- /dev/null +++ b/packages/shared/src/types/api.ts @@ -0,0 +1,36 @@ +export interface ApiResponse { + success: boolean; + data: T | null; + error: string | null; + meta?: PaginationMeta; +} + +export interface PaginationMeta { + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface PaginationQuery { + page?: number; + limit?: number; + search?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +export interface JwtPayload { + sub: string; + tenantId?: string | null; + roles: string[]; + permissions: string[]; + iat?: number; + exp?: number; +} + +export interface TokenResponse { + accessToken: string; + refreshToken: string; + mustChangePassword?: boolean; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts new file mode 100644 index 0000000..ad6b9f0 --- /dev/null +++ b/packages/shared/src/types/index.ts @@ -0,0 +1,7 @@ +export type { + ApiResponse, + PaginationMeta, + PaginationQuery, + JwtPayload, + TokenResponse, +} from './api'; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..dae4070 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..5d6d845 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/src/app/(admin)/audit-logs/page.tsx b/src/app/(admin)/audit-logs/page.tsx new file mode 100644 index 0000000..28d792d --- /dev/null +++ b/src/app/(admin)/audit-logs/page.tsx @@ -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; + createdAt: string; +} + +export default function AuditLogsPage() { + const [logs, setLogs] = useState([]); + 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 ( +
+
+

Audit Logs

+

{total} total entries

+
+ + {/* Filters */} +
+ { setStartDate(e.target.value); setPage(1); }} + className="px-3 py-2 border border-surface-300 rounded-lg text-sm" + title="Start date" + /> + to + { setEndDate(e.target.value); setPage(1); }} + className="px-3 py-2 border border-surface-300 rounded-lg text-sm" + title="End date" + /> + + + {hasFilters && ( + + )} +
+ +
+ + + + + + + + + + + + + {loading ? ( + + ) : logs.length === 0 ? ( + + ) : ( + logs.map((log) => ( + + + + + + + + + )) + )} + +
TimestampActionEntityEntity IDUser IDTenant
Loading...
No audit logs found
+ {new Date(log.createdAt).toLocaleString()} + + + {log.action} + + {log.entity}{log.entityId?.slice(0, 8) || '—'}...{log.userId.slice(0, 8)}...{log.tenantId?.slice(0, 8) || '—'}...
+
+ + {/* Pagination */} + {total > limit && ( +
+ Showing {(page - 1) * limit + 1}-{Math.min(page * limit, total)} of {total} +
+ + +
+
+ )} +
+ ); +} diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx new file mode 100644 index 0000000..822a113 --- /dev/null +++ b/src/app/(admin)/layout.tsx @@ -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 ( +
+
+
+ ); + } + + if (!isAuthenticated) return null; + + return ( +
+ +
+ +
{children}
+
+
+ ); +} diff --git a/src/app/(admin)/page.tsx b/src/app/(admin)/page.tsx new file mode 100644 index 0000000..66ce890 --- /dev/null +++ b/src/app/(admin)/page.tsx @@ -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(null); + const [recentTenants, setRecentTenants] = useState([]); + const [recentTickets, setRecentTickets] = useState([]); + 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 ; + } + + 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 ( +
+
+ {statCards.map((card) => ( +
+

{card.label}

+

{card.value}

+ {card.sub &&

{card.sub}

} +
+ ))} +
+ +
+ {/* Recent Tenants */} +
+
+

Recent Tenants

+ + View all + +
+
+ {recentTenants.map((t) => ( + +
+

{t.name}

+

{t.slug}

+
+ + {t.isActive ? 'Active' : 'Inactive'} + + + ))} + {recentTenants.length === 0 && ( +

No tenants yet

+ )} +
+
+ + {/* Recent Support Tickets */} +
+
+

Open Support Tickets

+ + View all + +
+
+ {recentTickets.map((t) => ( + +
+

{t.subject}

+

{t.tenantName}

+
+ + + ))} + {recentTickets.length === 0 && ( +

No open tickets

+ )} +
+
+
+
+ ); +} + +function PriorityBadge({ priority }: { priority: string }) { + const styles: Record = { + 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 ( + + {priority} + + ); +} + +function DashboardSkeleton() { + return ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/src/app/(admin)/support/[id]/page.tsx b/src/app/(admin)/support/[id]/page.tsx new file mode 100644 index 0000000..01c8425 --- /dev/null +++ b/src/app/(admin)/support/[id]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [comment, setComment] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [uploading, setUploading] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + const fileInputRef = useRef(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
Loading...
; + if (!ticket) return
Ticket not found
; + + const ADMIN_API_URL = process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api'; + + return ( +
+ + +
+ {/* Main content */} +
+
+

{ticket.subject}

+

{ticket.description}

+
+ Created {new Date(ticket.createdAt).toLocaleString()} by {ticket.tenantName} +
+
+ + {/* Ticket-level attachments */} + {ticket.attachments.length > 0 && ( +
+

Attachments

+
+ {ticket.attachments.map((a) => ( +
+ {a.mimeType.startsWith('image/') ? ( + + ) : ( + + )} + + {a.originalName} + + ({formatBytes(a.sizeBytes)}) + +
+ ))} +
+
+ )} + + {/* Comments thread */} +
+

+ Conversation ({ticket.comments.length}) +

+ {ticket.comments.map((c) => ( +
+
+
+ {c.authorName} + + {c.authorType === 'super_admin' ? 'Admin' : 'Tenant'} + +
+ {new Date(c.createdAt).toLocaleString()} +
+

{c.content}

+ + {/* Comment attachments */} + {c.attachments.length > 0 && ( +
+ {c.attachments.map((a) => ( + + + {a.originalName} ({formatBytes(a.sizeBytes)}) + + ))} +
+ )} +
+ ))} + + {/* Add comment */} +
+