Files
fiberops-web/app/(app)/plans/page.tsx

363 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, "success" | "muted"> = {
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<Plan | null>(null);
const [deletePlan, setDeletePlan] = useState<Plan | null>(null);
// Forms
const [createForm, setCreateForm] = useState({ ...emptyForm });
const [editForm, setEditForm] = useState({ ...emptyForm });
const { data, isLoading, isError, refetch } = useQuery<PlansResponse>({
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<PlansResponse>(`/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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Plans</h1>
<p className="text-sm text-gray-500 mt-1">{total} total plans</p>
</div>
<div className="flex gap-2">
<Button onClick={() => refetch()} variant="outline" size="sm" data-testid="btn-refresh">
<RefreshCw size={14} className="mr-1" />Refresh
</Button>
<Button onClick={() => setShowCreate(true)} size="sm" data-testid="btn-add-plan">
<Plus size={14} className="mr-1" />Add Plan
</Button>
</div>
</div>
<Card>
<CardHeader>
<input
className="w-full max-w-sm border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search plans..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
data-testid="input-search"
/>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHead>
<TableRow>
<Th>Name</Th>
<Th>Type</Th>
<Th>Speed</Th>
<Th>Price</Th>
<Th>Description</Th>
<Th></Th>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<TableRow key={i}>
<Td colSpan={6}><div className="h-4 bg-gray-100 rounded animate-pulse" /></Td>
</TableRow>
))
) : isError ? (
<TableRow>
<Td colSpan={6}>
<p className="text-center py-6 text-red-400 text-sm">
Failed to load plans.{" "}
<button onClick={() => refetch()} className="underline">Retry</button>
</p>
</Td>
</TableRow>
) : plans.length === 0 ? (
<EmptyState colSpan={6} message="No plans found" icon={<Package size={24} />} />
) : (
plans.map(plan => (
<TableRow key={plan.id} data-testid="plan-row">
<Td className="font-medium">{plan.name}</Td>
<Td>
<Badge variant={typeVariant[plan.type] ?? "muted"}>{plan.type}</Badge>
</Td>
<Td className="font-mono text-sm">{plan.speed}</Td>
<Td className="font-semibold">{formatCurrency(Number(plan.price))}</Td>
<Td className="text-gray-500 text-sm max-w-xs truncate">{plan.description ?? "—"}</Td>
<Td>
<div className="flex gap-2 justify-end">
<button
onClick={() => openEdit(plan)}
className="p-1.5 rounded hover:bg-blue-50 text-blue-600 transition-colors"
data-testid="btn-edit-plan"
title="Edit plan"
>
<Pencil size={14} />
</button>
<button
onClick={() => setDeletePlan(plan)}
className="p-1.5 rounded hover:bg-red-50 text-red-500 transition-colors"
data-testid="btn-delete-plan"
title="Delete plan"
>
<Trash2 size={14} />
</button>
</div>
</Td>
</TableRow>
))
)}
</TableBody>
</Table>
{total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t text-sm text-gray-500">
<span>Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}</span>
<div className="flex gap-2">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Prev</button>
<button disabled={page * 20 >= total} onClick={() => setPage(p => p + 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Next</button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Create Plan Modal */}
<Modal isOpen={showCreate} onClose={() => { setShowCreate(false); setCreateForm({ ...emptyForm }); }} title="Add Plan" className="max-w-md" data-testid="modal-create-plan">
<div className="space-y-4" data-testid="modal-create-plan">
<Input
label="Plan Name"
value={createForm.name}
onChange={e => setCreateForm(f => ({ ...f, name: e.target.value }))}
placeholder="e.g. Basic 25Mbps"
data-testid="input-plan-name"
/>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Type</label>
<select
className="border rounded-lg px-3 py-2 text-sm"
value={createForm.type}
onChange={e => setCreateForm(f => ({ ...f, type: e.target.value as "PREPAID" | "POSTPAID" }))}
data-testid="select-plan-type"
>
<option value="PREPAID">PREPAID</option>
<option value="POSTPAID">POSTPAID</option>
</select>
</div>
<Input
label="Speed"
value={createForm.speed}
onChange={e => setCreateForm(f => ({ ...f, speed: e.target.value }))}
placeholder="e.g. 25Mbps"
data-testid="input-plan-speed"
/>
<Input
label="Price (₱)"
type="number"
value={createForm.price}
onChange={e => setCreateForm(f => ({ ...f, price: e.target.value }))}
placeholder="e.g. 999"
data-testid="input-plan-price"
/>
<Input
label="Description (optional)"
value={createForm.description}
onChange={e => setCreateForm(f => ({ ...f, description: e.target.value }))}
placeholder="e.g. Perfect for households"
data-testid="input-plan-description"
/>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" size="sm" onClick={() => { setShowCreate(false); setCreateForm({ ...emptyForm }); }}>Cancel</Button>
<Button
size="sm"
onClick={() => createMutation.mutate()}
isLoading={createMutation.isPending}
disabled={!createForm.name || !createForm.speed || !createForm.price}
data-testid="btn-submit-create"
>
Create Plan
</Button>
</div>
</div>
</Modal>
{/* Edit Plan Modal */}
<Modal isOpen={!!editPlan} onClose={() => setEditPlan(null)} title={`Edit Plan: ${editPlan?.name ?? ""}`} className="max-w-md">
<div className="space-y-4" data-testid="modal-edit-plan">
<Input
label="Plan Name"
value={editForm.name}
onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))}
data-testid="input-edit-name"
/>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Type</label>
<select
className="border rounded-lg px-3 py-2 text-sm"
value={editForm.type}
onChange={e => setEditForm(f => ({ ...f, type: e.target.value as "PREPAID" | "POSTPAID" }))}
data-testid="select-edit-type"
>
<option value="PREPAID">PREPAID</option>
<option value="POSTPAID">POSTPAID</option>
</select>
</div>
<Input
label="Speed"
value={editForm.speed}
onChange={e => setEditForm(f => ({ ...f, speed: e.target.value }))}
data-testid="input-edit-speed"
/>
<Input
label="Price (₱)"
type="number"
value={editForm.price}
onChange={e => setEditForm(f => ({ ...f, price: e.target.value }))}
data-testid="input-edit-price"
/>
<Input
label="Description (optional)"
value={editForm.description}
onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))}
data-testid="input-edit-description"
/>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" size="sm" onClick={() => setEditPlan(null)}>Cancel</Button>
<Button
size="sm"
onClick={() => updateMutation.mutate()}
isLoading={updateMutation.isPending}
disabled={!editForm.name || !editForm.speed || !editForm.price}
data-testid="btn-submit-edit"
>
Save Changes
</Button>
</div>
</div>
</Modal>
{/* Delete Confirmation Modal */}
<Modal isOpen={!!deletePlan} onClose={() => setDeletePlan(null)} title="Delete Plan" className="max-w-sm">
<div className="space-y-4" data-testid="modal-delete-plan">
<p className="text-sm text-gray-600">
Are you sure you want to delete <strong>{deletePlan?.name}</strong>? This action cannot be undone.
</p>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" size="sm" onClick={() => setDeletePlan(null)} data-testid="btn-cancel-delete">Cancel</Button>
<Button
variant="danger"
size="sm"
onClick={() => deleteMutation.mutate()}
isLoading={deleteMutation.isPending}
data-testid="btn-confirm-delete"
>
Delete
</Button>
</div>
</div>
</Modal>
</div>
);
}