From 380a83283b0f99a87fb6934dc5f3debf9e9132c1 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 25 Mar 2026 16:16:51 +0800 Subject: [PATCH] =?UTF-8?q?restore:=20tasks,=20users,=20settings,=20dashbo?= =?UTF-8?q?ard=20=E2=80=94=20all=20original=20pages=20recovered=20from=20o?= =?UTF-8?q?ld=20Docker=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(app)/dashboard/page.tsx | 440 ++++++++++++++++------- app/(app)/settings/page.tsx | 676 +++++++++++++++++++++++++++++++++++ app/(app)/tasks/page.tsx | 123 +++++++ app/(app)/users/page.tsx | 110 ++++++ 4 files changed, 1213 insertions(+), 136 deletions(-) create mode 100644 app/(app)/settings/page.tsx create mode 100644 app/(app)/tasks/page.tsx create mode 100644 app/(app)/users/page.tsx diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 3276152..9a7e423 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -1,166 +1,334 @@ -'use client'; +"use client"; -import { useQuery } from '@tanstack/react-query'; -import { api } from '@/lib/api'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Users, Wifi, DollarSign, AlertTriangle, Ticket, ClipboardList } from 'lucide-react'; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { Users, Wifi, FileText, DollarSign, Ticket, CheckSquare, TrendingUp, Database, RefreshCw } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { Badge } from "@/components/ui/Badge"; +import { formatCurrency, formatDateTime } from "@/lib/utils"; +import { toast } from "sonner"; +import api from "@/lib/api"; +import type { DashboardSummary, PaginatedResponse, Ticket as TicketType } from "@/types"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; -interface DashboardSummary { - subscribers: { total: number; active: number; pending: number; suspended: number }; - billing: { unpaidInvoices: number; overdueInvoices: number }; - support: { openTickets: number; inProgressTickets: number }; - tasks: { pending: number }; - revenue: { thisMonth: number; lastMonth: number; trend: number }; - leads: { total: number; new: number }; +interface KpiCardProps { + title: string; + value: string | number; + icon: React.ComponentType<{ className?: string }>; + color: string; + subtitle?: string; + href?: string; } -function KpiCard({ - label, - value, - sub, - icon: Icon, - color, - isLoading, -}: { - label: string; - value: string; - sub?: string; - icon: React.ElementType; - color: string; - isLoading: boolean; -}) { +function KpiCard({ title, value, icon: Icon, color, subtitle, href }: KpiCardProps) { + const router = useRouter(); return ( - - - {label} -
- + router.push(href) : undefined} + > + +
+
- - - {isLoading ? ( - - ) : ( - <> -

{value}

- {sub &&

{sub}

} - +
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+ {href && ( +
)}
); } +const priorityVariant: Record = { + URGENT: "danger", + urgent: "danger", + HIGH: "warning", + high: "warning", + NORMAL: "default", + normal: "default", + MEDIUM: "default", + medium: "default", + LOW: "muted", + low: "muted", +}; + +// Build mock time-series data from revenue for the chart +function buildChartData(stats: DashboardSummary | undefined) { + if (!stats) return []; + // Create a simple 2-month comparison from revenue data + const now = new Date(); + const thisMonth = now.toLocaleString("default", { month: "short" }); + const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1).toLocaleString("default", { month: "short" }); + return [ + { month: lastMonth, revenue: stats.revenue.lastMonth, clients: stats.subscribers.total }, + { month: thisMonth, revenue: stats.revenue.thisMonth, clients: stats.subscribers.active }, + ]; +} + export default function DashboardPage() { - const { data, isLoading, error } = useQuery({ - queryKey: ['dashboard', 'summary'], + const router = useRouter(); + + const { data: stats, isLoading: statsLoading, refetch: refetchStats } = useQuery({ + queryKey: ["dashboard-summary"], queryFn: async () => { - const res = await api.get('/api/v1/dashboard/summary'); + const res = await api.get("/api/v1/dashboard/summary"); return res.data; }, }); - const fmt = (n: number) => n?.toLocaleString() ?? '—'; - const peso = (n: number) => - '₱' + (n ?? 0).toLocaleString('en-PH', { minimumFractionDigits: 2 }); + const { data: ticketsData, refetch: refetchTickets } = useQuery>({ + queryKey: ["recent-tickets"], + queryFn: async () => { + const res = await api.get>("/api/v1/tickets?page=1&limit=5"); + return res.data; + }, + }); + + const seedMutation = useMutation({ + mutationFn: () => api.post<{ message: string }>("/api/v1/_seed", {}), + onSuccess: (res) => { + toast.success(res.data?.message || "Demo data seeded!"); + refetchStats(); + refetchTickets(); + }, + onError: (err: { response?: { data?: { message?: string } } }) => { + toast.error(err?.response?.data?.message || "Seed failed"); + }, + }); + + const recentTickets = ticketsData?.data ?? []; + const chartData = buildChartData(stats); + const hasChartData = chartData.some((d) => d.revenue > 0); return ( -
-
-

Dashboard

-

Overview of your ISP operations

+
+
+
+

Dashboard

+

Overview of your ISP operations

+
+
+ + + + + +
- {error && ( -
- Failed to load dashboard data. + {/* KPI Cards */} + {statsLoading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + +
+ + + ))} +
+ ) : ( +
+ + + + 0 ? "+" : ""}${stats.revenue.growth.toFixed(1)}% vs last month` + : "vs last month" + } + href="/payments" + />
)} - {/* KPI Row 1 */} -
- - - -
- - {/* KPI Row 2 */} -
- - - -
- - {/* Quick stats */} - - - Subscriber Breakdown - - - {isLoading ? ( -
- - + {/* Secondary stats */} +
+ router.push("/tickets?status=OPEN")} + > + +
+
+
+

Open Tickets

+

{stats?.support.openTickets ?? 0}

+
+
+
+ router.push("/tickets?status=IN_PROGRESS")} + > + +
+ +
+
+

In-Progress Tickets

+

{stats?.support.inProgressTickets ?? 0}

+
+
+
+ router.push("/tasks")} + > + +
+ +
+
+

Pending Tasks

+

{stats?.tasks.pending ?? 0}

+
+
+
+
+ + {/* Revenue Chart */} + {!statsLoading && ( + + + Revenue Overview + + + {hasChartData ? ( + + + + + `₱${(v / 1000).toFixed(0)}k`} /> + formatCurrency(Number(v))} /> + + + + + ) : ( +
+ +

Revenue data will appear once payments are recorded.

+ +
+ )} +
+
+ )} + + {/* Recent Tickets */} + + + Recent Tickets + + + + {recentTickets.length === 0 ? ( +
No tickets yet
) : ( -
- {[ - { label: 'Active', value: data?.subscribers?.active ?? 0, color: '#059669' }, - { label: 'Pending', value: data?.subscribers?.pending ?? 0, color: '#D97706' }, - { label: 'Suspended', value: data?.subscribers?.suspended ?? 0, color: '#DC2626' }, - { label: 'Total', value: data?.subscribers?.total ?? 0, color: '#0891B2' }, - ].map((item) => ( -
-

- {item.value} -

-

{item.label}

+
+ {recentTickets.map((ticket) => ( +
router.push("/tickets")} + > +
+

{ticket.subject}

+

+ {ticket.client + ? `${ticket.client.firstName} ${ticket.client.lastName}` + : "No client"}{" "} + • {formatDateTime(ticket.createdAt)} +

+
+
+ + {ticket.priority} + + {ticket.status} +
))}
diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx new file mode 100644 index 0000000..8816337 --- /dev/null +++ b/app/(app)/settings/page.tsx @@ -0,0 +1,676 @@ +"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({ + queryKey: ["tenant-me"], + queryFn: async () => { + const res = await api.get("/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
; + } + + return ( + + Business Information + +
{ e.preventDefault(); saveMutation.mutate(); }} + className="space-y-4 max-w-lg" + > + setName(e.target.value)} + /> + setAddress(e.target.value)} + /> + setEmail(e.target.value)} + /> + setPhone(e.target.value)} + /> + {saveMutation.isError && ( +

+ Save endpoint not available yet — changes not persisted. +

+ )} + +
+
+
+ ); +} + +// ─── 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({ + queryKey: ["tenant-billing-settings"], + queryFn: async () => { + try { + const res = await api.get("/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
; + } + + return ( + + Billing Configuration + +
{ e.preventDefault(); saveMutation.mutate(); }} + className="space-y-4 max-w-lg" + > + setBillingDay(e.target.value)} + hint="Day of month invoices are generated" + /> + setLateFeeAmount(e.target.value)} + /> + setGraceDays(e.target.value)} + hint="Days after due date before late fee applies" + /> + {saveMutation.isError && ( +

+ Save endpoint not available yet — changes not persisted. +

+ )} + +
+
+
+ ); +} + +// ─── 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({ + queryKey: ["areas"], + queryFn: async () => { + try { + const res = await api.get("/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 ( +
+ + +
+ Areas & Zones +
+ + +
+
+
+ + + + + + + + + + {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + + + + )) + ) : areaList.length === 0 ? ( + + ) : ( + areaList.map((a) => ( + + + + + )) + )} + +
Area NameZones
{a.name} + {a.zones?.length + ? a.zones.map((z) => z.name).join(", ") + : No zones} +
+
+
+ + {/* Add Area Modal */} + setShowAddArea(false)} title="Add Area"> +
{ e.preventDefault(); addAreaMutation.mutate(); }} className="space-y-4"> + setAreaName(e.target.value)} + placeholder="e.g. North Sector" + /> +
+ + +
+
+
+ + {/* Add Zone Modal */} + setShowAddZone(false)} title="Add Zone"> +
{ e.preventDefault(); addZoneMutation.mutate(); }} className="space-y-4"> +
+ + +
+ setZoneName(e.target.value)} + placeholder="e.g. Zone 1" + /> +
+ + +
+
+
+
+ ); +} + +// ─── 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({ + queryKey: ["plans"], + queryFn: async () => { + try { + const res = await api.get("/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 ( +
+ + +
+ Plans + +
+
+ + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + {[1,2,3,4,5,6].map((j) => ( + + ))} + + )) + ) : plans.length === 0 ? ( + + ) : ( + plans.map((p) => ( + + + + + + + + + )) + )} + +
NameTypeSpeedPriceStatusActions
{p.name}{p.type}{p.speedDownMbps}/{p.speedUpMbps} Mbps{formatCurrency(Number(p.monthlyPrice))} + + {p.isActive ? "Active" : "Inactive"} + + + +
+
+
+ + {/* Add Plan Modal */} + { setShowAdd(false); resetAdd(); }} title="Add Plan" className="max-w-lg"> +
{ e.preventDefault(); addPlanMutation.mutate(); }} className="space-y-4"> + setPlanName(e.target.value)} placeholder="e.g. Basic 10 Mbps" /> + +
+ + +
+ +
+ setSpeedDown(e.target.value)} /> + setSpeedUp(e.target.value)} /> +
+ + setPrice(e.target.value)} /> + setDescription(e.target.value)} /> + +
+ + +
+
+
+
+ ); +} + +// ─── Sub-page: Users ────────────────────────────────────────────────────────── + +interface UserItem { + id: string; + firstName: string; + lastName: string; + email: string; + isActive: boolean; +} + +function UsersSettings() { + const { data, isLoading } = useQuery({ + queryKey: ["users-settings"], + queryFn: async () => { + const res = await api.get("/api/v1/users"); + return Array.isArray(res.data) ? res.data : []; + }, + }); + + const users = data ?? []; + + return ( + + Users + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + {[1,2,3].map((j) => )} + + )) + ) : users.length === 0 ? ( + + ) : ( + users.map((u) => ( + + + + + + )) + )} + +
NameEmailStatus
{u.firstName} {u.lastName}{u.email} + + {u.isActive ? "Active" : "Inactive"} + +
+
+
+ ); +} + +// ─── 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("tenant"); + + return ( +
+
+

Settings

+

Manage your ISP configuration

+
+ +
+ {/* Left sub-nav */} + + + {/* Content */} +
+ {active === "tenant" && } + {active === "billing" && } + {active === "areas" && } + {active === "plans" && } + {active === "users" && } +
+
+
+ ); +} diff --git a/app/(app)/tasks/page.tsx b/app/(app)/tasks/page.tsx new file mode 100644 index 0000000..84b80fb --- /dev/null +++ b/app/(app)/tasks/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } 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 { formatDate } from "@/lib/utils"; +import api from "@/lib/api"; +import type { Task, PaginatedResponse } from "@/types"; + +const statusVariant: Record = { + done: "success", + DONE: "success", + completed: "success", + COMPLETED: "success", + in_progress: "default", + IN_PROGRESS: "default", + pending: "warning", + PENDING: "warning", + cancelled: "muted", + CANCELLED: "muted", +}; + +export default function TasksPage() { + const [page, setPage] = useState(1); + + const { data, isLoading, refetch } = useQuery>({ + queryKey: ["tasks", page], + queryFn: async () => { + const res = await api.get>(`/api/v1/tasks?page=${page}&limit=20`); + return res.data; + }, + }); + + const tasks = data?.data ?? []; + const meta = data?.meta; + + return ( +
+
+
+

Tasks

+

{meta?.total ?? 0} total tasks

+
+ +
+ + + All Tasks + + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + + ))} + + )) + ) : tasks.length === 0 ? ( + + ) : ( + tasks.map((task) => ( + + + + + + + + + )) + )} + +
TitleTypeStatusAssigned ToDue DateLinked Ticket
{task.title}{task.type} + + {task.status} + + + {task.assignedUser + ? `${task.assignedUser.firstName} ${task.assignedUser.lastName}` + : task.assignedTo ?? "—"} + + {task.dueDate ? formatDate(task.dueDate) : "—"} + + {task.ticket?.subject ?? (task.ticketId ? task.ticketId.slice(0, 8) : "—")} +
+ + {meta && meta.totalPages > 1 && ( +
+

Page {meta.page} of {meta.totalPages}

+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/app/(app)/users/page.tsx b/app/(app)/users/page.tsx new file mode 100644 index 0000000..21ff38a --- /dev/null +++ b/app/(app)/users/page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { RefreshCw } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } 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 { formatDate } from "@/lib/utils"; +import api from "@/lib/api"; +import type { User, UsersResponse } from "@/types"; + +export default function UsersPage() { + const { data: users, isLoading, refetch } = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const res = await api.get("/api/v1/users?page=1&limit=20"); + // Handle both array and paginated response + const d = res.data; + if (Array.isArray(d)) return d; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyD = d as any; + if (anyD.data) return anyD.data as UsersResponse; + return []; + }, + }); + + const userList: User[] = users ?? []; + + return ( +
+
+
+

Users

+

{userList.length} total users

+
+ +
+ + + All Users + + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + + ))} + + )) + ) : userList.length === 0 ? ( + + ) : ( + userList.map((user) => { + const roles = user.roleAssignments?.map((r) => r.role) ?? []; + return ( + + + + + + + + + ); + }) + )} + +
NameEmailRoleStatusLast LoginJoined
{user.firstName} {user.lastName}{user.email} +
+ {roles.length === 0 ? ( + No role + ) : ( + roles.map((role) => ( + + {role} + + )) + )} +
+
+ + {user.isActive ? "Active" : "Inactive"} + + + {user.lastLoginAt ? formatDate(user.lastLoginAt) : "Never"} + {formatDate(user.createdAt)}
+
+
+
+ ); +}