From bd006aca155612d985d169bdb93fd6ed3ebf6a6b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 26 Mar 2026 09:54:01 +0000 Subject: [PATCH] fix(m4): plans module audit fixes + E2E tests --- app/(app)/plans/page.tsx | 362 ++++++++++++++++++++++++++++++++++ components/layout/sidebar.tsx | 1 + e2e/plans.spec.ts | 118 +++++++++++ 3 files changed, 481 insertions(+) create mode 100644 app/(app)/plans/page.tsx create mode 100644 e2e/plans.spec.ts diff --git a/app/(app)/plans/page.tsx b/app/(app)/plans/page.tsx new file mode 100644 index 0000000..600a955 --- /dev/null +++ b/app/(app)/plans/page.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { RefreshCw, Package, Plus, Pencil, Trash2 } 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"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { Modal } from "@/components/ui/Modal"; +import { formatCurrency } from "@/lib/utils"; +import api from "@/lib/api"; +import { toast } from "sonner"; + +interface Plan { + id: string; + name: string; + type: "PREPAID" | "POSTPAID"; + speed: string; + price: string | number; + description?: string; +} +interface PlansResponse { data: Plan[]; total: number; page: number; limit: number; } + +const typeVariant: Record = { + PREPAID: "success", + POSTPAID: "muted", +}; + +const emptyForm = { name: "", type: "PREPAID" as "PREPAID" | "POSTPAID", speed: "", price: "", description: "" }; + +export default function PlansPage() { + const qc = useQueryClient(); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); + + // Modals + const [showCreate, setShowCreate] = useState(false); + const [editPlan, setEditPlan] = useState(null); + const [deletePlan, setDeletePlan] = useState(null); + + // Forms + const [createForm, setCreateForm] = useState({ ...emptyForm }); + const [editForm, setEditForm] = useState({ ...emptyForm }); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["plans", search, page], + queryFn: async () => { + const params = new URLSearchParams({ page: String(page), limit: "20" }); + if (search) params.set("search", search); + const res = await api.get(`/api/v1/plans?${params}`); + return res.data; + }, + }); + + const createMutation = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/plans", { + name: createForm.name, + type: createForm.type, + speed: createForm.speed, + price: Number(createForm.price), + description: createForm.description || undefined, + }); + }, + onSuccess: () => { + toast.success("Plan created!"); + setShowCreate(false); + setCreateForm({ ...emptyForm }); + qc.invalidateQueries({ queryKey: ["plans"] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create plan"), + }); + + const updateMutation = useMutation({ + mutationFn: async () => { + await api.patch(`/api/v1/plans/${editPlan!.id}`, { + name: editForm.name, + type: editForm.type, + speed: editForm.speed, + price: Number(editForm.price), + description: editForm.description || undefined, + }); + }, + onSuccess: () => { + toast.success("Plan updated!"); + setEditPlan(null); + qc.invalidateQueries({ queryKey: ["plans"] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to update plan"), + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + await api.delete(`/api/v1/plans/${deletePlan!.id}`); + }, + onSuccess: () => { + toast.success("Plan deleted!"); + setDeletePlan(null); + qc.invalidateQueries({ queryKey: ["plans"] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to delete plan"), + }); + + const plans = data?.data ?? []; + const total = data?.total ?? 0; + + const openEdit = (plan: Plan) => { + setEditForm({ + name: plan.name, + type: plan.type, + speed: plan.speed, + price: String(plan.price), + description: plan.description ?? "", + }); + setEditPlan(plan); + }; + + return ( +
+ {/* Header */} +
+
+

Plans

+

{total} total plans

+
+
+ + +
+
+ + + + { setSearch(e.target.value); setPage(1); }} + data-testid="input-search" + /> + + + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 6 }).map((_, i) => ( + + + + )) + ) : isError ? ( + + + + ) : plans.length === 0 ? ( + } /> + ) : ( + plans.map(plan => ( + + + + + + + + + )) + )} + +
NameTypeSpeedPriceDescription
+

+ Failed to load plans.{" "} + +

+
{plan.name} + {plan.type} + {plan.speed}{formatCurrency(Number(plan.price))}{plan.description ?? "—"} +
+ + +
+
+ + {total > 20 && ( +
+ Showing {(page - 1) * 20 + 1}–{Math.min(page * 20, total)} of {total} +
+ + +
+
+ )} +
+
+ + {/* Create Plan Modal */} + { setShowCreate(false); setCreateForm({ ...emptyForm }); }} title="Add Plan" className="max-w-md" data-testid="modal-create-plan"> +
+ setCreateForm(f => ({ ...f, name: e.target.value }))} + placeholder="e.g. Basic 25Mbps" + data-testid="input-plan-name" + /> +
+ + +
+ setCreateForm(f => ({ ...f, speed: e.target.value }))} + placeholder="e.g. 25Mbps" + data-testid="input-plan-speed" + /> + setCreateForm(f => ({ ...f, price: e.target.value }))} + placeholder="e.g. 999" + data-testid="input-plan-price" + /> + setCreateForm(f => ({ ...f, description: e.target.value }))} + placeholder="e.g. Perfect for households" + data-testid="input-plan-description" + /> +
+ + +
+
+
+ + {/* Edit Plan Modal */} + setEditPlan(null)} title={`Edit Plan: ${editPlan?.name ?? ""}`} className="max-w-md"> +
+ setEditForm(f => ({ ...f, name: e.target.value }))} + data-testid="input-edit-name" + /> +
+ + +
+ setEditForm(f => ({ ...f, speed: e.target.value }))} + data-testid="input-edit-speed" + /> + setEditForm(f => ({ ...f, price: e.target.value }))} + data-testid="input-edit-price" + /> + setEditForm(f => ({ ...f, description: e.target.value }))} + data-testid="input-edit-description" + /> +
+ + +
+
+
+ + {/* Delete Confirmation Modal */} + setDeletePlan(null)} title="Delete Plan" className="max-w-sm"> +
+

+ Are you sure you want to delete {deletePlan?.name}? This action cannot be undone. +

+
+ + +
+
+
+
+ ); +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 7e53651..3cdad70 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -11,6 +11,7 @@ import { const navItems = [ { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: [] }, { label: 'Clients', href: '/clients', icon: Users, roles: [] }, + { label: 'Plans', href: '/plans', icon: Wifi, roles: ['admin', 'staff'] }, { label: 'Leads', href: '/leads', icon: UserPlus, roles: ['admin', 'staff'] }, { label: 'Invoices', href: '/invoices', icon: FileText, roles: [] }, { label: 'Payments', href: '/payments', icon: CreditCard, roles: [] }, diff --git a/e2e/plans.spec.ts b/e2e/plans.spec.ts new file mode 100644 index 0000000..1957528 --- /dev/null +++ b/e2e/plans.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +const API = 'https://fiberops-api.juankibin.space/api/v1'; + +test.describe('Plans', () => { + test.beforeEach(async ({ page }) => { + await login(page); + await page.goto('/plans'); + await page.waitForLoadState('networkidle'); + }); + + test('plans page renders with header and table', async ({ page }) => { + await expect(page.locator('h1:has-text("Plans")')).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + await expect(page.locator('[data-testid="btn-add-plan"]')).toBeVisible(); + }); + + test('plans list loads rows from API', async ({ page }) => { + // Wait for either rows or empty state + await page.waitForTimeout(3000); + const rows = page.locator('[data-testid="plan-row"]'); + const empty = page.locator('text=No plans found'); + const count = await rows.count(); + const hasEmpty = await empty.isVisible(); + // Either rows exist OR empty state is shown — both are valid + expect(count > 0 || hasEmpty).toBeTruthy(); + }); + + test('add plan modal opens and submits', async ({ page }) => { + // Open create modal + await page.click('[data-testid="btn-add-plan"]'); + + // Modal should appear with form fields + await expect(page.locator('[data-testid="input-plan-name"]')).toBeVisible(); + await expect(page.locator('[data-testid="select-plan-type"]')).toBeVisible(); + await expect(page.locator('[data-testid="input-plan-speed"]')).toBeVisible(); + await expect(page.locator('[data-testid="input-plan-price"]')).toBeVisible(); + + // Fill form + const planName = `E2E Test Plan ${Date.now()}`; + await page.fill('[data-testid="input-plan-name"]', planName); + await page.selectOption('[data-testid="select-plan-type"]', 'PREPAID'); + await page.fill('[data-testid="input-plan-speed"]', '25Mbps'); + await page.fill('[data-testid="input-plan-price"]', '999'); + await page.fill('[data-testid="input-plan-description"]', 'E2E test plan - safe to delete'); + + // Submit + await page.click('[data-testid="btn-submit-create"]'); + + // Modal should close and success toast or new row appears + await page.waitForTimeout(2000); + + // Modal should be closed + const modalInput = page.locator('[data-testid="input-plan-name"]'); + await expect(modalInput).not.toBeVisible({ timeout: 5000 }); + }); + + test('edit plan modal opens and submits', async ({ page }) => { + // Wait for rows to load + await page.waitForTimeout(3000); + const editBtns = page.locator('[data-testid="btn-edit-plan"]'); + const count = await editBtns.count(); + + if (count === 0) { + // No plans exist yet — create one first via API, then test edit + test.skip(); + return; + } + + // Click first edit button + await editBtns.first().click(); + + // Edit modal should open + await expect(page.locator('[data-testid="input-edit-name"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('[data-testid="input-edit-speed"]')).toBeVisible(); + await expect(page.locator('[data-testid="input-edit-price"]')).toBeVisible(); + + // Change the speed + await page.fill('[data-testid="input-edit-speed"]', '50Mbps'); + + // Submit + await page.click('[data-testid="btn-submit-edit"]'); + + // Modal should close + await page.waitForTimeout(2000); + const editModal = page.locator('[data-testid="input-edit-name"]'); + await expect(editModal).not.toBeVisible({ timeout: 5000 }); + }); + + test('delete plan shows confirmation dialog', async ({ page }) => { + // Wait for rows + await page.waitForTimeout(3000); + const deleteBtns = page.locator('[data-testid="btn-delete-plan"]'); + const count = await deleteBtns.count(); + + if (count === 0) { + test.skip(); + return; + } + + // Click first delete button + await deleteBtns.first().click(); + + // Confirmation modal should appear + await expect(page.locator('[data-testid="modal-delete-plan"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('[data-testid="btn-confirm-delete"]')).toBeVisible(); + await expect(page.locator('[data-testid="btn-cancel-delete"]')).toBeVisible(); + + // Cancel the delete (don't actually delete in tests) + await page.click('[data-testid="btn-cancel-delete"]'); + + // Modal should close + await page.waitForTimeout(500); + const deleteModal = page.locator('[data-testid="modal-delete-plan"]'); + await expect(deleteModal).not.toBeVisible({ timeout: 5000 }); + }); +});