diff --git a/app/(app)/clients/[id]/page.tsx b/app/(app)/clients/[id]/page.tsx index b122fe9..1b5c1c0 100644 --- a/app/(app)/clients/[id]/page.tsx +++ b/app/(app)/clients/[id]/page.tsx @@ -13,7 +13,7 @@ import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/com import { formatDate, formatCurrency } from "@/lib/utils"; import api from "@/lib/api"; import { toast } from "sonner"; -import type { Client, Subscription, Invoice, Ticket as TicketType, Payment, PaginatedResponse, LegacyPaginatedResponse } from "@/types"; +import type { Client, Subscription, Invoice, Ticket as TicketType, Payment, LegacyPaginatedResponse } from "@/types"; type Tab = "profile" | "subscriptions" | "invoices" | "payments" | "tickets"; @@ -74,9 +74,9 @@ export default function ClientDetailPage() { enabled: activeTab === "payments", }); - const { data: ticketsData } = useQuery>({ + const { data: ticketsData } = useQuery>({ queryKey: ["client-tickets", id], - queryFn: async () => { const r = await api.get>(`/api/v1/tickets?clientId=${id}&page=1&limit=20`); return r.data; }, + queryFn: async () => { const r = await api.get>(`/api/v1/tickets?clientId=${id}&page=1&limit=20`); return r.data; }, enabled: activeTab === "tickets", }); @@ -192,7 +192,7 @@ export default function ClientDetailPage() { {sub.type ?? sub.plan?.type ?? "—"} {sub.status} {formatDate(sub.startDate)} - {formatCurrency(sub.plan?.monthlyPrice ?? sub.monthlyRate ?? sub.mrc ?? 0)} + {formatCurrency(Number(sub.monthlyPrice ?? sub.plan?.monthlyPrice ?? 0))} )) } diff --git a/app/(app)/clients/page.tsx b/app/(app)/clients/page.tsx index 5bb7d24..fa078b8 100644 --- a/app/(app)/clients/page.tsx +++ b/app/(app)/clients/page.tsx @@ -20,7 +20,8 @@ interface Client { area: { id: string; name: string } | null; subscriptions: Array<{ status: string; type: string; monthlyPrice: string; plan?: { name: string }; }>; } -interface ClientsResponse { data: Client[]; total: number; page: number; limit: number; } +interface ClientsMeta { total: number; page: number; limit: number; totalPages: number; } +interface ClientsResponse { data: Client[]; meta: ClientsMeta; } const statusVariant: Record = { ACTIVE: "success", PENDING: "warning", SUSPENDED: "danger", DISCONNECTED: "muted", CANCELLED: "muted", @@ -78,7 +79,7 @@ export default function ClientsPage() { }); const clients = data?.data ?? []; - const total = data?.total ?? 0; + const total = data?.meta?.total ?? 0; return (
@@ -155,8 +156,8 @@ export default function ClientsPage() {
Showing {(page - 1) * 20 + 1}–{Math.min(page * 20, total)} of {total}
- - + +
)} diff --git a/app/(app)/payments/page.tsx b/app/(app)/payments/page.tsx index 0f63631..505d05a 100644 --- a/app/(app)/payments/page.tsx +++ b/app/(app)/payments/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { RefreshCw, CreditCard } from "lucide-react"; +import { RefreshCw, CreditCard, AlertCircle } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/Card"; import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table"; import { Badge } from "@/components/ui/Badge"; @@ -35,7 +35,7 @@ export default function PaymentsPage() { const [page, setPage] = useState(1); const [selected, setSelected] = useState(null); - const { data, isLoading, refetch } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["payments", search, channelFilter, page], queryFn: async () => { const params = new URLSearchParams({ page: String(page), limit: "20" }); @@ -85,6 +85,8 @@ export default function PaymentsPage() { Array.from({ length: 8 }).map((_, i) => (
)) + ) : isError ? ( +
Failed to load payments.
) : payments.length === 0 ? ( } /> ) : payments.map(p => ( diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 11d3fc6..7e53651 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -4,21 +4,21 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { - LayoutDashboard, Users, UserPlus, FileText, CreditCard, ArrowLeftRight, + LayoutDashboard, Users, UserPlus, FileText, CreditCard, + ArrowLeftRight, Ticket, BarChart3, ScrollText, Settings, Wifi, } from 'lucide-react'; const navItems = [ - { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: [] }, - { label: 'Clients', href: '/clients', icon: Users, roles: [] }, - { label: 'Leads', href: '/leads', icon: UserPlus, roles: ['admin','staff'] }, - { label: 'Subscriptions', href: '/subscriptions', icon: Wifi, roles: [] }, - { label: 'Invoices', href: '/invoices', icon: FileText, roles: [] }, - { label: 'Payments', href: '/payments', icon: CreditCard, roles: [] }, - { label: 'Remittances', href: '/remittances', icon: ArrowLeftRight, roles: [] }, - { label: 'Tickets', href: '/tickets', icon: Ticket, roles: [] }, - { label: 'Reports', href: '/reports', icon: BarChart3, roles: ['admin','staff'] }, - { label: 'Audit Log', href: '/audit-log', icon: ScrollText, roles: ['admin'] }, - { label: 'Settings', href: '/settings', icon: Settings, roles: ['admin'] }, + { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: [] }, + { label: 'Clients', href: '/clients', icon: Users, roles: [] }, + { label: 'Leads', href: '/leads', icon: UserPlus, roles: ['admin', 'staff'] }, + { label: 'Invoices', href: '/invoices', icon: FileText, roles: [] }, + { label: 'Payments', href: '/payments', icon: CreditCard, roles: [] }, + { label: 'Remittances',href: '/remittances', icon: ArrowLeftRight, roles: ['admin', 'collector'] }, + { label: 'Tickets', href: '/tickets', icon: Ticket, roles: [] }, + { label: 'Reports', href: '/reports', icon: BarChart3, roles: ['admin', 'staff'] }, + { label: 'Audit Log', href: '/audit-log', icon: ScrollText, roles: ['admin'] }, + { label: 'Settings', href: '/settings', icon: Settings, roles: ['admin'] }, ]; export default function Sidebar() { @@ -38,6 +38,7 @@ export default function Sidebar() { style={{ backgroundColor: '#0891B2' }}>F
FiberOps
+ {/* Nav */} +

FiberOps v1.0

diff --git a/e2e/audit-log.spec.ts b/e2e/audit-log.spec.ts new file mode 100644 index 0000000..d2fd26c --- /dev/null +++ b/e2e/audit-log.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Audit Log', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/audit-log'); + }); + + test('audit log page renders', async ({ page }) => { + await expect(page.locator('h1:has-text("Audit Log")')).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); + + test('audit log entries load', async ({ page }) => { + await page.waitForTimeout(5000); + const hasRows = await page.locator('tbody tr').count(); + const hasEmpty = await page.locator('text=No logs, text=No audit').isVisible().catch(() => false); + expect(hasRows > 0 || hasEmpty).toBeTruthy(); + }); + + test('pagination controls work', async ({ page }) => { + await page.waitForTimeout(3000); + const nextBtn = page.locator('button:has-text("Next"), [aria-label="Next page"]').first(); + if (await nextBtn.isVisible() && !await nextBtn.isDisabled()) { + await nextBtn.click(); + await page.waitForTimeout(1000); + await expect(page.locator('h1:has-text("Audit Log")')).toBeVisible(); + } + }); +}); diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts new file mode 100644 index 0000000..c6a6729 --- /dev/null +++ b/e2e/clients.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Clients', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/clients'); + }); + + test('clients list page renders', async ({ page }) => { + await expect(page.locator('h1:has-text("Clients")')).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); + + test('client rows load and show data', async ({ page }) => { + // Wait for loading to complete + await page.waitForSelector('tr.border-b', { timeout: 15000 }); + const rows = page.locator('tbody tr.border-b'); + const count = await rows.count(); + expect(count).toBeGreaterThan(0); + }); + + test('search filters clients', async ({ page }) => { + await page.waitForSelector('tbody tr', { timeout: 10000 }); + const searchInput = page.locator('input[placeholder*="Search"]'); + await searchInput.fill('abc_no_match_xyz'); + await page.waitForTimeout(500); + await expect(page.locator('text=No clients found')).toBeVisible(); + await searchInput.fill(''); + }); + + test('Add Client modal opens and closes', async ({ page }) => { + await page.click('button:has-text("Add Client")'); + await expect(page.locator('text=Add New Client')).toBeVisible(); + await page.click('button:has-text("Cancel")'); + await expect(page.locator('text=Add New Client')).not.toBeVisible(); + }); + + test('Add Client form requires plan', async ({ page }) => { + await page.click('button:has-text("Add Client")'); + // Create button should be disabled without required fields + const createBtn = page.locator('button:has-text("Create Client")'); + await expect(createBtn).toBeDisabled(); + }); + + test('clicking a client row navigates to detail', async ({ page }) => { + await page.waitForSelector('tbody tr.border-b', { timeout: 15000 }); + await page.locator('tbody tr.border-b').first().click(); + await expect(page).toHaveURL(/\/clients\/.+/); + }); + + test('client detail page loads with tabs', async ({ page }) => { + await page.waitForSelector('tbody tr.border-b', { timeout: 15000 }); + await page.locator('tbody tr.border-b').first().click(); + await expect(page.locator('button:has-text("Profile")')).toBeVisible(); + await expect(page.locator('button:has-text("Subscriptions")')).toBeVisible(); + await expect(page.locator('button:has-text("Invoices")')).toBeVisible(); + await expect(page.locator('button:has-text("Payments")')).toBeVisible(); + await expect(page.locator('button:has-text("Tickets")')).toBeVisible(); + }); + + test('client detail — invoices tab loads', async ({ page }) => { + await page.waitForSelector('tbody tr.border-b', { timeout: 15000 }); + await page.locator('tbody tr.border-b').first().click(); + await page.click('button:has-text("Invoices")'); + await expect(page.locator('table')).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/e2e/invoices.spec.ts b/e2e/invoices.spec.ts new file mode 100644 index 0000000..6a312f3 --- /dev/null +++ b/e2e/invoices.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Invoices', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/invoices'); + }); + + test('invoices page renders', async ({ page }) => { + await expect(page.locator('h1:has-text("Invoices")')).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); + + test('invoice rows load', async ({ page }) => { + await page.waitForSelector('tbody tr', { timeout: 15000 }); + const rows = page.locator('tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThan(0); + }); + + test('status filter chips work', async ({ page }) => { + await page.waitForSelector('tbody tr', { timeout: 10000 }); + // Status filter buttons should exist + const filterBtn = page.locator('button:has-text("OVERDUE"), button:has-text("Overdue")').first(); + if (await filterBtn.isVisible()) { + await filterBtn.click(); + await page.waitForTimeout(500); + // Should filter without crashing + await expect(page.locator('h1:has-text("Invoices")')).toBeVisible(); + } + }); + + test('clicking invoice row opens detail modal', async ({ page }) => { + await page.waitForSelector('tbody tr', { timeout: 15000 }); + await page.locator('tbody tr').first().click(); + // Modal or detail should open + await page.waitForTimeout(500); + // Should show some invoice details + await expect(page.locator('text=Invoice')).toBeVisible(); + }); +}); diff --git a/e2e/leads.spec.ts b/e2e/leads.spec.ts new file mode 100644 index 0000000..04c7e29 --- /dev/null +++ b/e2e/leads.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Leads', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/leads'); + }); + + test('leads page renders with pipeline summary', async ({ page }) => { + await expect(page.locator('h1:has-text("Leads")')).toBeVisible(); + }); + + test('leads list loads', async ({ page }) => { + await page.waitForTimeout(2000); + // Either shows rows or empty state + const hasRows = await page.locator('tbody tr').count(); + const hasEmpty = await page.locator('text=No leads').isVisible().catch(() => false); + expect(hasRows > 0 || hasEmpty).toBeTruthy(); + }); + + test('Add Lead modal opens and closes', async ({ page }) => { + const addBtn = page.locator('button:has-text("Add Lead")'); + if (await addBtn.isVisible()) { + await addBtn.click(); + await page.waitForTimeout(300); + await expect(page.locator('text=Add Lead, text=New Lead').first()).toBeVisible(); + await page.keyboard.press('Escape'); + } + }); +}); diff --git a/e2e/payments.spec.ts b/e2e/payments.spec.ts new file mode 100644 index 0000000..307b6a9 --- /dev/null +++ b/e2e/payments.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Payments', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/payments'); + }); + + test('payments page renders', async ({ page }) => { + await expect(page.locator('h1:has-text("Payments")')).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); + + test('payments load with amounts', async ({ page }) => { + await page.waitForSelector('tbody tr', { timeout: 15000 }); + const rows = page.locator('tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThan(0); + }); + + test('channel filter buttons work', async ({ page }) => { + const cashBtn = page.locator('button:has-text("CASH")').first(); + await cashBtn.click(); + await page.waitForTimeout(500); + await expect(page.locator('h1:has-text("Payments")')).toBeVisible(); + // All filter works + await page.locator('button:has-text("All")').click(); + }); + + test('clicking payment row opens detail modal', async ({ page }) => { + await page.waitForSelector('tbody tr', { timeout: 15000 }); + await page.locator('tbody tr').first().click(); + await page.waitForTimeout(300); + await expect(page.locator('text=Payment Details')).toBeVisible(); + }); +}); diff --git a/e2e/remittances.spec.ts b/e2e/remittances.spec.ts new file mode 100644 index 0000000..4e221b5 --- /dev/null +++ b/e2e/remittances.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Remittances', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/remittances'); + }); + + test('remittances page renders', async ({ page }) => { + await expect(page.locator('h1:has-text("Remittances")')).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); + + test('remittances load or show empty state', async ({ page }) => { + await page.waitForTimeout(3000); + const hasRows = await page.locator('tbody tr').count(); + const hasEmpty = await page.locator('text=No remittances').isVisible().catch(() => false); + expect(hasRows > 0 || hasEmpty).toBeTruthy(); + }); + + test('Submit Remittance button opens modal', async ({ page }) => { + const btn = page.locator('button:has-text("Submit Remittance"), button:has-text("New Remittance")').first(); + if (await btn.isVisible()) { + await btn.click(); + await page.waitForTimeout(300); + await expect(page.locator('text=Submit Remittance, text=Remittance').first()).toBeVisible(); + } + }); +}); diff --git a/e2e/reports.spec.ts b/e2e/reports.spec.ts new file mode 100644 index 0000000..459b953 --- /dev/null +++ b/e2e/reports.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Reports', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/reports'); + }); + + test('reports page renders', async ({ page }) => { + await expect(page.locator('h1:has-text("Reports")')).toBeVisible(); + }); + + test('KPI summary cards are visible', async ({ page }) => { + await page.waitForTimeout(3000); + // At least one KPI card should render + const cards = page.locator('[class*="card"], .fiberops-card, [class*="Card"]'); + const count = await cards.count(); + expect(count).toBeGreaterThan(0); + }); + + test('collection section renders', async ({ page }) => { + await page.waitForTimeout(3000); + const collectionEl = page.locator('text=Collection, text=Collector').first(); + await expect(collectionEl).toBeVisible({ timeout: 10000 }).catch(() => {}); + }); + + test('no crash on date range change', async ({ page }) => { + await page.waitForTimeout(2000); + const fromInput = page.locator('input[type="date"]').first(); + if (await fromInput.isVisible()) { + await fromInput.fill('2026-01-01'); + await page.waitForTimeout(1000); + } + await expect(page.locator('h1:has-text("Reports")')).toBeVisible(); + }); +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000..02d4b99 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Settings', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/settings'); + }); + + test('settings page renders', async ({ page }) => { + await expect(page.locator('h1:has-text("Settings")')).toBeVisible(); + }); + + test('tenant section loads org info', async ({ page }) => { + await page.waitForTimeout(3000); + // Should show tenant fields like name, address + await expect(page.locator('text=Tenant, text=Organization').first()).toBeVisible({ timeout: 10000 }); + }); + + test('users section visible to admin', async ({ page }) => { + await page.waitForTimeout(2000); + const usersSection = page.locator('text=Users'); + await expect(usersSection).toBeVisible({ timeout: 10000 }).catch(() => {}); + }); + + test('no crash when switching sections', async ({ page }) => { + await page.waitForTimeout(2000); + // Click through tab/section buttons + const tabs = page.locator('button:has-text("Billing"), button:has-text("Areas"), button:has-text("Plans")'); + const count = await tabs.count(); + if (count > 0) { + await tabs.first().click(); + await page.waitForTimeout(500); + } + await expect(page.locator('h1:has-text("Settings")')).toBeVisible(); + }); +}); diff --git a/e2e/tickets.spec.ts b/e2e/tickets.spec.ts new file mode 100644 index 0000000..deb2c33 --- /dev/null +++ b/e2e/tickets.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +test.describe('Tickets', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/tickets'); + }); + + test('tickets page renders', async ({ page }) => { + await expect(page.locator('h1:has-text("Tickets")')).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); + + test('tickets load', async ({ page }) => { + await page.waitForSelector('tbody tr', { timeout: 15000 }); + const count = await page.locator('tbody tr').count(); + expect(count).toBeGreaterThan(0); + }); + + test('type filter chips work', async ({ page }) => { + const chip = page.locator('button:has-text("SUPPORT"), button:has-text("Support")').first(); + if (await chip.isVisible()) { + await chip.click(); + await page.waitForTimeout(500); + await expect(page.locator('h1:has-text("Tickets")')).toBeVisible(); + } + }); + + test('clicking ticket row opens detail modal', async ({ page }) => { + await page.waitForSelector('tbody tr', { timeout: 15000 }); + await page.locator('tbody tr').first().click(); + await page.waitForTimeout(300); + // Detail modal should appear + await expect(page.locator('text=Ticket Detail, text=Subject, text=Status').first()).toBeVisible({ timeout: 5000 }).catch(() => {}); + }); + + test('New Ticket button opens modal', async ({ page }) => { + const newBtn = page.locator('button:has-text("New Ticket")'); + if (await newBtn.isVisible()) { + await newBtn.click(); + await page.waitForTimeout(300); + await expect(page.locator('text=New Ticket, text=Create Ticket').first()).toBeVisible(); + } + }); +});