diff --git a/app/(app)/plans/page.tsx b/app/(app)/plans/page.tsx index 600a955..4d3586b 100644 --- a/app/(app)/plans/page.tsx +++ b/app/(app)/plans/page.tsx @@ -17,23 +17,30 @@ interface Plan { id: string; name: string; type: "PREPAID" | "POSTPAID"; - speed: string; - price: string | number; + speedDownMbps: number; + speedUpMbps: number; + monthlyPrice: number; description?: string; + isActive: boolean; } -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: "" }; +const emptyForm = { + name: "", + type: "PREPAID" as "PREPAID" | "POSTPAID", + speedDown: "", + speedUp: "", + monthlyPrice: "", + description: "", +}; export default function PlansPage() { const qc = useQueryClient(); const [search, setSearch] = useState(""); - const [page, setPage] = useState(1); // Modals const [showCreate, setShowCreate] = useState(false); @@ -44,12 +51,13 @@ export default function PlansPage() { const [createForm, setCreateForm] = useState({ ...emptyForm }); const [editForm, setEditForm] = useState({ ...emptyForm }); - const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ["plans", search, page], + // GET /api/v1/plans returns a plain array + const { data: plans = [], isLoading, isError, refetch } = useQuery({ + queryKey: ["plans", search], queryFn: async () => { - const params = new URLSearchParams({ page: String(page), limit: "20" }); + const params = new URLSearchParams({ limit: "100" }); if (search) params.set("search", search); - const res = await api.get(`/api/v1/plans?${params}`); + const res = await api.get(`/api/v1/plans?${params}`); return res.data; }, }); @@ -59,8 +67,9 @@ export default function PlansPage() { await api.post("/api/v1/plans", { name: createForm.name, type: createForm.type, - speed: createForm.speed, - price: Number(createForm.price), + speedDownMbps: Number(createForm.speedDown), + speedUpMbps: Number(createForm.speedUp), + monthlyPrice: Number(createForm.monthlyPrice), description: createForm.description || undefined, }); }, @@ -70,7 +79,11 @@ export default function PlansPage() { setCreateForm({ ...emptyForm }); qc.invalidateQueries({ queryKey: ["plans"] }); }, - onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create plan"), + onError: (e: any) => toast.error( + Array.isArray(e.response?.data?.message) + ? e.response.data.message.join(", ") + : (e.response?.data?.message ?? "Failed to create plan") + ), }); const updateMutation = useMutation({ @@ -78,8 +91,9 @@ export default function PlansPage() { await api.patch(`/api/v1/plans/${editPlan!.id}`, { name: editForm.name, type: editForm.type, - speed: editForm.speed, - price: Number(editForm.price), + speedDownMbps: Number(editForm.speedDown), + speedUpMbps: Number(editForm.speedUp), + monthlyPrice: Number(editForm.monthlyPrice), description: editForm.description || undefined, }); }, @@ -103,15 +117,17 @@ export default function PlansPage() { onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to delete plan"), }); - const plans = data?.data ?? []; - const total = data?.total ?? 0; + const filtered = search + ? plans.filter(p => p.name.toLowerCase().includes(search.toLowerCase())) + : plans; const openEdit = (plan: Plan) => { setEditForm({ name: plan.name, type: plan.type, - speed: plan.speed, - price: String(plan.price), + speedDown: String(plan.speedDownMbps), + speedUp: String(plan.speedUpMbps), + monthlyPrice: String(plan.monthlyPrice), description: plan.description ?? "", }); setEditPlan(plan); @@ -123,7 +139,7 @@ export default function PlansPage() {

Plans

-

{total} total plans

+

{filtered.length} total plans

- -
-
- )} {/* Create Plan Modal */} - { setShowCreate(false); setCreateForm({ ...emptyForm }); }} title="Add Plan" className="max-w-md" data-testid="modal-create-plan"> -
+ { setShowCreate(false); setCreateForm({ ...emptyForm }); }} title="Add Plan" className="max-w-md"> +
POSTPAID
+
+ setCreateForm(f => ({ ...f, speedDown: e.target.value }))} + placeholder="e.g. 25" + data-testid="input-plan-speed-down" + /> + setCreateForm(f => ({ ...f, speedUp: e.target.value }))} + placeholder="e.g. 10" + data-testid="input-plan-speed-up" + /> +
setCreateForm(f => ({ ...f, speed: e.target.value }))} - placeholder="e.g. 25Mbps" - data-testid="input-plan-speed" - /> - setCreateForm(f => ({ ...f, price: e.target.value }))} + value={createForm.monthlyPrice} + onChange={e => setCreateForm(f => ({ ...f, monthlyPrice: e.target.value }))} placeholder="e.g. 999" data-testid="input-plan-price" /> @@ -273,7 +290,7 @@ export default function PlansPage() { size="sm" onClick={() => createMutation.mutate()} isLoading={createMutation.isPending} - disabled={!createForm.name || !createForm.speed || !createForm.price} + disabled={!createForm.name || !createForm.speedDown || !createForm.speedUp || !createForm.monthlyPrice} data-testid="btn-submit-create" > Create Plan @@ -284,7 +301,7 @@ export default function PlansPage() { {/* Edit Plan Modal */} setEditPlan(null)} title={`Edit Plan: ${editPlan?.name ?? ""}`} className="max-w-md"> -
+
POSTPAID
+
+ setEditForm(f => ({ ...f, speedDown: e.target.value }))} + data-testid="input-edit-speed-down" + /> + setEditForm(f => ({ ...f, speedUp: e.target.value }))} + data-testid="input-edit-speed-up" + /> +
setEditForm(f => ({ ...f, speed: e.target.value }))} - data-testid="input-edit-speed" - /> - setEditForm(f => ({ ...f, price: e.target.value }))} + value={editForm.monthlyPrice} + onChange={e => setEditForm(f => ({ ...f, monthlyPrice: e.target.value }))} data-testid="input-edit-price" /> updateMutation.mutate()} isLoading={updateMutation.isPending} - disabled={!editForm.name || !editForm.speed || !editForm.price} + disabled={!editForm.name || !editForm.speedDown || !editForm.speedUp || !editForm.monthlyPrice} data-testid="btn-submit-edit" > Save Changes @@ -339,7 +366,7 @@ export default function PlansPage() { {/* 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/e2e/plans.spec.ts b/e2e/plans.spec.ts index 1957528..873ff1c 100644 --- a/e2e/plans.spec.ts +++ b/e2e/plans.spec.ts @@ -1,118 +1,79 @@ 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'); + await page.waitForURL(/\/plans/); }); 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(); + await expect(page.locator('th:has-text("Name")')).toBeVisible(); + await expect(page.locator('th:has-text("Type")')).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(); + // Wait for at least one row (demo data has 5 plans) + await expect(page.locator('[data-testid="plan-row"]').first()).toBeVisible({ timeout: 10000 }); + const count = await page.locator('[data-testid="plan-row"]').count(); + expect(count).toBeGreaterThan(0); }); 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()}`; + // Fill form with correct numeric fields + const planName = `E2E 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-speed-down"]', '25'); + await page.fill('[data-testid="input-plan-speed-up"]', '10'); 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 close after successful API call + await expect(page.locator('[data-testid="input-plan-name"]')).not.toBeVisible({ timeout: 10000 }); - // Modal should be closed - const modalInput = page.locator('[data-testid="input-plan-name"]'); - await expect(modalInput).not.toBeVisible({ timeout: 5000 }); + // New plan should appear in table + await expect(page.locator(`text=${planName}`)).toBeVisible({ timeout: 10000 }); }); 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; - } + // Wait for rows + await expect(page.locator('[data-testid="plan-row"]').first()).toBeVisible({ timeout: 10000 }); // Click first edit button - await editBtns.first().click(); + await page.locator('[data-testid="btn-edit-plan"]').first().click(); + await expect(page.locator('[data-testid="input-edit-name"]')).toBeVisible(); - // 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 + // Edit the name + await page.fill('[data-testid="input-edit-name"]', 'Updated Plan Name'); 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 }); + await expect(page.locator('[data-testid="input-edit-name"]')).not.toBeVisible({ timeout: 10000 }); }); 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; - } + await expect(page.locator('[data-testid="plan-row"]').first()).toBeVisible({ timeout: 10000 }); // Click first delete button - await deleteBtns.first().click(); + await page.locator('[data-testid="btn-delete-plan"]').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) + // Cancel — don't actually delete 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 }); + await expect(page.locator('[data-testid="btn-confirm-delete"]')).not.toBeVisible(); }); });