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

677 lines
23 KiB
TypeScript
Raw Permalink 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 } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Building2, CreditCard, Map, Wifi, Users, ChevronRight,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
import { Badge } from "@/components/ui/Badge";
import { Modal } from "@/components/ui/Modal";
import { formatCurrency } from "@/lib/utils";
import api from "@/lib/api";
// ─── Types ─────────────────────────────────────────────────────────────────────
interface Tenant {
id: string;
name: string;
slug: string;
address?: string;
phone?: string;
email?: string;
settings?: TenantBillingSettings | null;
}
interface TenantBillingSettings {
billingDay?: number;
lateFeeAmount?: number;
lateFeeGraceDays?: number;
}
interface Plan {
id: string;
name: string;
description?: string;
type: string;
speedDownMbps: number;
speedUpMbps: number;
monthlyPrice: number | string;
isActive: boolean;
}
// ─── Sub-page: Tenant ─────────────────────────────────────────────────────────
function TenantSettings() {
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [loaded, setLoaded] = useState(false);
const { isLoading } = useQuery<Tenant>({
queryKey: ["tenant-me"],
queryFn: async () => {
const res = await api.get<Tenant>("/api/v1/tenants/me");
return res.data;
},
select: (data) => {
if (!loaded) {
setName(data.name ?? "");
setAddress(data.address ?? "");
setEmail(data.email ?? "");
setPhone(data.phone ?? "");
setLoaded(true);
}
return data;
},
});
const saveMutation = useMutation({
mutationFn: async () => {
await api.patch("/api/v1/tenants/me", { name, address, email, phone });
},
onSuccess: () => toast.success("Tenant settings saved"),
onError: () => toast.error("Failed to save. Endpoint may not be available yet."),
});
if (isLoading) {
return <div className="h-40 animate-pulse bg-gray-100 rounded-xl" />;
}
return (
<Card>
<CardHeader><CardTitle>Business Information</CardTitle></CardHeader>
<CardContent>
<form
onSubmit={(e) => { e.preventDefault(); saveMutation.mutate(); }}
className="space-y-4 max-w-lg"
>
<Input
label="Business Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
label="Address"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
<Input
label="Contact Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
label="Phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
{saveMutation.isError && (
<p className="text-sm text-orange-600 bg-orange-50 rounded-lg px-3 py-2">
Save endpoint not available yet changes not persisted.
</p>
)}
<Button type="submit" isLoading={saveMutation.isPending}>
Save Changes
</Button>
</form>
</CardContent>
</Card>
);
}
// ─── Sub-page: Billing Settings ───────────────────────────────────────────────
function BillingSettings() {
const [billingDay, setBillingDay] = useState("1");
const [lateFeeAmount, setLateFeeAmount] = useState("0");
const [graceDays, setGraceDays] = useState("0");
const [loaded, setLoaded] = useState(false);
const { isLoading } = useQuery<TenantBillingSettings>({
queryKey: ["tenant-billing-settings"],
queryFn: async () => {
try {
const res = await api.get<TenantBillingSettings>("/api/v1/tenants/me/settings");
return res.data ?? {};
} catch {
return {};
}
},
select: (data) => {
if (!loaded && data) {
setBillingDay(String(data.billingDay ?? 1));
setLateFeeAmount(String(data.lateFeeAmount ?? 0));
setGraceDays(String(data.lateFeeGraceDays ?? 0));
setLoaded(true);
}
return data;
},
});
const saveMutation = useMutation({
mutationFn: async () => {
await api.patch("/api/v1/tenants/me/settings", {
billingDay: parseInt(billingDay),
lateFeeAmount: parseFloat(lateFeeAmount),
lateFeeGraceDays: parseInt(graceDays),
});
},
onSuccess: () => toast.success("Billing settings saved"),
onError: () => toast.error("Failed to save. Endpoint may not be available yet."),
});
if (isLoading) {
return <div className="h-40 animate-pulse bg-gray-100 rounded-xl" />;
}
return (
<Card>
<CardHeader><CardTitle>Billing Configuration</CardTitle></CardHeader>
<CardContent>
<form
onSubmit={(e) => { e.preventDefault(); saveMutation.mutate(); }}
className="space-y-4 max-w-lg"
>
<Input
label="Billing Day (128)"
type="number"
min="1"
max="28"
value={billingDay}
onChange={(e) => setBillingDay(e.target.value)}
hint="Day of month invoices are generated"
/>
<Input
label="Late Fee Amount (₱)"
type="number"
min="0"
step="0.01"
value={lateFeeAmount}
onChange={(e) => setLateFeeAmount(e.target.value)}
/>
<Input
label="Late Fee Grace Days"
type="number"
min="0"
value={graceDays}
onChange={(e) => setGraceDays(e.target.value)}
hint="Days after due date before late fee applies"
/>
{saveMutation.isError && (
<p className="text-sm text-orange-600 bg-orange-50 rounded-lg px-3 py-2">
Save endpoint not available yet changes not persisted.
</p>
)}
<Button type="submit" isLoading={saveMutation.isPending}>
Save Billing Settings
</Button>
</form>
</CardContent>
</Card>
);
}
// ─── Sub-page: Areas & Zones ──────────────────────────────────────────────────
interface Area {
id: string;
name: string;
zones?: Zone[];
}
interface Zone {
id: string;
name: string;
areaId: string;
}
function AreasSettings() {
const [showAddArea, setShowAddArea] = useState(false);
const [showAddZone, setShowAddZone] = useState(false);
const [areaName, setAreaName] = useState("");
const [zoneName, setZoneName] = useState("");
const [zoneAreaId, setZoneAreaId] = useState("");
const { data: areas, isLoading, refetch } = useQuery<Area[]>({
queryKey: ["areas"],
queryFn: async () => {
try {
const res = await api.get<Area[] | { data: Area[] }>("/api/v1/areas");
const d = res.data;
return Array.isArray(d) ? d : (d as { data: Area[] }).data ?? [];
} catch {
return [];
}
},
});
const addAreaMutation = useMutation({
mutationFn: async () => {
await api.post("/api/v1/areas", { name: areaName });
},
onSuccess: () => {
toast.success("Area added");
setAreaName("");
setShowAddArea(false);
refetch();
},
onError: () => toast.error("Failed to add area"),
});
const addZoneMutation = useMutation({
mutationFn: async () => {
await api.post("/api/v1/zones", { name: zoneName, areaId: zoneAreaId });
},
onSuccess: () => {
toast.success("Zone added");
setZoneName("");
setZoneAreaId("");
setShowAddZone(false);
refetch();
},
onError: () => toast.error("Failed to add zone"),
});
const areaList = areas ?? [];
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Areas & Zones</CardTitle>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setShowAddArea(true)}>
+ Area
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAddZone(true)}>
+ Zone
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHead>
<TableRow>
<Th>Area Name</Th>
<Th>Zones</Th>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}>
<Td><div className="h-4 w-32 animate-pulse bg-gray-100 rounded" /></Td>
<Td><div className="h-4 w-24 animate-pulse bg-gray-100 rounded" /></Td>
</TableRow>
))
) : areaList.length === 0 ? (
<EmptyState message="No areas configured" />
) : (
areaList.map((a) => (
<TableRow key={a.id}>
<Td className="font-medium">{a.name}</Td>
<Td className="text-sm text-gray-500">
{a.zones?.length
? a.zones.map((z) => z.name).join(", ")
: <span className="text-gray-300">No zones</span>}
</Td>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Add Area Modal */}
<Modal isOpen={showAddArea} onClose={() => setShowAddArea(false)} title="Add Area">
<form onSubmit={(e) => { e.preventDefault(); addAreaMutation.mutate(); }} className="space-y-4">
<Input
label="Area Name"
value={areaName}
onChange={(e) => setAreaName(e.target.value)}
placeholder="e.g. North Sector"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddArea(false)}>Cancel</Button>
<Button type="submit" size="sm" isLoading={addAreaMutation.isPending} disabled={!areaName.trim()}>
Add Area
</Button>
</div>
</form>
</Modal>
{/* Add Zone Modal */}
<Modal isOpen={showAddZone} onClose={() => setShowAddZone(false)} title="Add Zone">
<form onSubmit={(e) => { e.preventDefault(); addZoneMutation.mutate(); }} className="space-y-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Area</label>
<select
className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={zoneAreaId}
onChange={(e) => setZoneAreaId(e.target.value)}
>
<option value="">Select area</option>
{areaList.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<Input
label="Zone Name"
value={zoneName}
onChange={(e) => setZoneName(e.target.value)}
placeholder="e.g. Zone 1"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddZone(false)}>Cancel</Button>
<Button
type="submit"
size="sm"
isLoading={addZoneMutation.isPending}
disabled={!zoneName.trim() || !zoneAreaId}
>
Add Zone
</Button>
</div>
</form>
</Modal>
</div>
);
}
// ─── Sub-page: Plans ──────────────────────────────────────────────────────────
function PlansSettings() {
const [showAdd, setShowAdd] = useState(false);
const [planName, setPlanName] = useState("");
const [planType, setPlanType] = useState("POSTPAID");
const [speedDown, setSpeedDown] = useState("");
const [speedUp, setSpeedUp] = useState("");
const [price, setPrice] = useState("");
const [description, setDescription] = useState("");
const { data, isLoading, refetch } = useQuery<Plan[]>({
queryKey: ["plans"],
queryFn: async () => {
try {
const res = await api.get<Plan[] | { data: Plan[] }>("/api/v1/plans");
const d = res.data;
return Array.isArray(d) ? d : (d as { data: Plan[] }).data ?? [];
} catch {
return [];
}
},
});
const addPlanMutation = useMutation({
mutationFn: async () => {
await api.post("/api/v1/plans", {
name: planName,
type: planType,
speedDownMbps: parseInt(speedDown),
speedUpMbps: parseInt(speedUp),
monthlyPrice: parseFloat(price),
description: description || undefined,
});
},
onSuccess: () => {
toast.success("Plan created");
resetAdd();
setShowAdd(false);
refetch();
},
onError: () => toast.error("Failed to create plan"),
});
const toggleActiveMutation = useMutation({
mutationFn: async ({ id, isActive }: { id: string; isActive: boolean }) => {
await api.patch(`/api/v1/plans/${id}`, { isActive: !isActive });
},
onSuccess: () => {
toast.success("Plan updated");
refetch();
},
onError: () => toast.error("Failed to update plan"),
});
function resetAdd() {
setPlanName(""); setPlanType("POSTPAID");
setSpeedDown(""); setSpeedUp(""); setPrice(""); setDescription("");
}
const plans = data ?? [];
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Plans</CardTitle>
<Button size="sm" onClick={() => setShowAdd(true)}>
+ Add Plan
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHead>
<TableRow>
<Th>Name</Th>
<Th>Type</Th>
<Th>Speed</Th>
<Th>Price</Th>
<Th>Status</Th>
<Th>Actions</Th>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}>
{[1,2,3,4,5,6].map((j) => (
<Td key={j}><div className="h-4 w-20 animate-pulse bg-gray-100 rounded" /></Td>
))}
</TableRow>
))
) : plans.length === 0 ? (
<EmptyState message="No plans configured" />
) : (
plans.map((p) => (
<TableRow key={p.id}>
<Td className="font-medium">{p.name}</Td>
<Td><Badge variant="muted">{p.type}</Badge></Td>
<Td className="text-sm">{p.speedDownMbps}/{p.speedUpMbps} Mbps</Td>
<Td className="font-semibold">{formatCurrency(Number(p.monthlyPrice))}</Td>
<Td>
<Badge variant={p.isActive ? "success" : "muted"}>
{p.isActive ? "Active" : "Inactive"}
</Badge>
</Td>
<Td>
<Button
size="sm"
variant="ghost"
onClick={() => toggleActiveMutation.mutate({ id: p.id, isActive: p.isActive })}
>
{p.isActive ? "Archive" : "Restore"}
</Button>
</Td>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Add Plan Modal */}
<Modal isOpen={showAdd} onClose={() => { setShowAdd(false); resetAdd(); }} title="Add Plan" className="max-w-lg">
<form onSubmit={(e) => { e.preventDefault(); addPlanMutation.mutate(); }} className="space-y-4">
<Input label="Plan Name" value={planName} onChange={(e) => setPlanName(e.target.value)} placeholder="e.g. Basic 10 Mbps" />
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Type</label>
<select
className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={planType}
onChange={(e) => setPlanType(e.target.value)}
>
<option value="POSTPAID">Postpaid</option>
<option value="PREPAID">Prepaid</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Download (Mbps)" type="number" min="1" value={speedDown} onChange={(e) => setSpeedDown(e.target.value)} />
<Input label="Upload (Mbps)" type="number" min="1" value={speedUp} onChange={(e) => setSpeedUp(e.target.value)} />
</div>
<Input label="Monthly Price (₱)" type="number" min="0" step="0.01" value={price} onChange={(e) => setPrice(e.target.value)} />
<Input label="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="outline" size="sm" onClick={() => { setShowAdd(false); resetAdd(); }}>Cancel</Button>
<Button
type="submit"
size="sm"
isLoading={addPlanMutation.isPending}
disabled={!planName.trim() || !speedDown || !speedUp || !price}
>
Create Plan
</Button>
</div>
</form>
</Modal>
</div>
);
}
// ─── Sub-page: Users ──────────────────────────────────────────────────────────
interface UserItem {
id: string;
firstName: string;
lastName: string;
email: string;
isActive: boolean;
}
function UsersSettings() {
const { data, isLoading } = useQuery<UserItem[]>({
queryKey: ["users-settings"],
queryFn: async () => {
const res = await api.get<UserItem[]>("/api/v1/users");
return Array.isArray(res.data) ? res.data : [];
},
});
const users = data ?? [];
return (
<Card>
<CardHeader><CardTitle>Users</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHead>
<TableRow>
<Th>Name</Th>
<Th>Email</Th>
<Th>Status</Th>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}>
{[1,2,3].map((j) => <Td key={j}><div className="h-4 w-28 animate-pulse bg-gray-100 rounded" /></Td>)}
</TableRow>
))
) : users.length === 0 ? (
<EmptyState message="No users found" />
) : (
users.map((u) => (
<TableRow key={u.id}>
<Td className="font-medium">{u.firstName} {u.lastName}</Td>
<Td className="text-sm text-gray-600">{u.email}</Td>
<Td>
<Badge variant={u.isActive ? "success" : "muted"}>
{u.isActive ? "Active" : "Inactive"}
</Badge>
</Td>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
// ─── Main Page ─────────────────────────────────────────────────────────────────
const SUB_NAV = [
{ key: "tenant", label: "Tenant", icon: Building2 },
{ key: "billing", label: "Billing", icon: CreditCard },
{ key: "areas", label: "Areas & Zones", icon: Map },
{ key: "plans", label: "Plans", icon: Wifi },
{ key: "users", label: "Users", icon: Users },
] as const;
type SubPage = typeof SUB_NAV[number]["key"];
export default function SettingsPage() {
const [active, setActive] = useState<SubPage>("tenant");
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="text-sm text-gray-500">Manage your ISP configuration</p>
</div>
<div className="flex gap-6">
{/* Left sub-nav */}
<aside className="w-48 flex-shrink-0">
<nav className="space-y-0.5">
{SUB_NAV.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActive(key)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
active === key
? "bg-blue-50 text-blue-700"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
}`}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span className="flex-1 text-left">{label}</span>
{active === key && <ChevronRight className="h-3 w-3 opacity-50" />}
</button>
))}
</nav>
</aside>
{/* Content */}
<div className="flex-1 min-w-0">
{active === "tenant" && <TenantSettings />}
{active === "billing" && <BillingSettings />}
{active === "areas" && <AreasSettings />}
{active === "plans" && <PlansSettings />}
{active === "users" && <UsersSettings />}
</div>
</div>
</div>
);
}