From 557a2791d31ae501cc78991934a273799fb2c044 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 09:43:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20client=20activation=20flow=20=E2=80=94?= =?UTF-8?q?=20prepaid=20&=20postpaid=20step-by-step=20wizard=20(FIBEROPS-2?= =?UTF-8?q?27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(app)/clients/[id]/page.tsx | 407 ++++++++++++++++++++++++++++++-- src/types/index.ts | 3 + 2 files changed, 394 insertions(+), 16 deletions(-) diff --git a/app/(app)/clients/[id]/page.tsx b/app/(app)/clients/[id]/page.tsx index 208cb36..054ad9f 100644 --- a/app/(app)/clients/[id]/page.tsx +++ b/app/(app)/clients/[id]/page.tsx @@ -3,7 +3,10 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useParams, useRouter } from "next/navigation"; -import { ArrowLeft, Wifi, FileText, Ticket, CreditCard, RefreshCw } from "lucide-react"; +import { + ArrowLeft, FileText, Ticket, CreditCard, RefreshCw, + CheckCircle2, Circle, MapPin, Zap, AlertTriangle, +} from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; @@ -40,6 +43,351 @@ const channelColors: Record = { CHECK: "bg-gray-100 text-gray-700", }; +// ─── Activation Flow Types ──────────────────────────────────────────────────── + +interface ActivationState { + installationTicketId: string | null; // existing INSTALLATION ticket (OPEN/IN_PROGRESS) + installationResolved: boolean; + locationPinned: boolean; // client has lat/lng set + invoiceCreated: boolean; // at least one invoice exists (prepaid: before activation) + activationTicketId: string | null; // the INSTALLATION ticket used for activation step + activationResolved: boolean; + clientActive: boolean; +} + +// ─── Activation Flow Card ───────────────────────────────────────────────────── + +function ActivationFlowCard({ + client, + subscriptions, + tickets, + invoices, + onRefresh, +}: { + client: Client; + subscriptions: Subscription[]; + tickets: TicketType[]; + invoices: Invoice[]; + onRefresh: () => void; +}) { + const qc = useQueryClient(); + const [showPinModal, setShowPinModal] = useState(false); + const [pinForm, setPinForm] = useState({ lat: String(client.lat ?? ""), lng: String(client.lng ?? "") }); + const [showActivationTicketModal, setShowActivationTicketModal] = useState(false); + + const pendingSub = subscriptions.find(s => s.status === "PENDING"); + const billingType = pendingSub?.type ?? "POSTPAID"; + + // Derive state from data + const installationTickets = tickets.filter(t => t.type === "INSTALLATION"); + const openInstallTicket = installationTickets.find(t => t.status === "OPEN" || t.status === "IN_PROGRESS"); + const resolvedInstallTickets = installationTickets.filter(t => t.status === "RESOLVED" || t.status === "CLOSED"); + + // Activation ticket = an INSTALLATION ticket created after the first one is resolved + // We identify it by: resolved install exists + there's another ticket also INSTALLATION + const activationTicket = resolvedInstallTickets.length > 1 + ? resolvedInstallTickets[resolvedInstallTickets.length - 1] // latest resolved = activation + : installationTickets.find(t => + (t.status === "OPEN" || t.status === "IN_PROGRESS") && + resolvedInstallTickets.length > 0 + ); + + const state: ActivationState = { + installationTicketId: openInstallTicket?.id ?? null, + installationResolved: resolvedInstallTickets.length > 0, + locationPinned: !!(client.lat && client.lng), + invoiceCreated: invoices.length > 0, + activationTicketId: activationTicket?.id ?? null, + activationResolved: !!(activationTicket && (activationTicket.status === "RESOLVED" || activationTicket.status === "CLOSED")), + clientActive: client.isActive && (!pendingSub || pendingSub.status !== "PENDING"), + }; + + // PREPAID steps: resolve install → pin location → create invoice → create activation ticket → resolve → activate + // POSTPAID steps: resolve install → pin location → create activation ticket → resolve → activate → create invoice + const isPrepaid = billingType === "PREPAID"; + + // Mutations + const resolveInstallMutation = useMutation({ + mutationFn: async (ticketId: string) => { + await api.patch(`/api/v1/tickets/${ticketId}`, { status: "RESOLVED" }); + }, + onSuccess: () => { toast.success("Installation ticket resolved!"); onRefresh(); qc.invalidateQueries({ queryKey: ["client-tickets", client.id] }); }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to resolve ticket"), + }); + + const pinLocationMutation = useMutation({ + mutationFn: async () => { + const lat = parseFloat(pinForm.lat); + const lng = parseFloat(pinForm.lng); + if (isNaN(lat) || isNaN(lng)) throw new Error("Invalid coordinates"); + await api.patch(`/api/v1/clients/${client.id}`, { lat, lng }); + }, + onSuccess: () => { + toast.success("Location pinned!"); + setShowPinModal(false); + onRefresh(); + qc.invalidateQueries({ queryKey: ["client", client.id] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? e.message ?? "Failed to save location"), + }); + + const generateInvoiceMutation = useMutation({ + mutationFn: async () => { + await api.post(`/api/v1/invoices/generate/${client.id}`, {}); + }, + onSuccess: () => { + toast.success("Invoice generated!"); + onRefresh(); + qc.invalidateQueries({ queryKey: ["client-invoices", client.id] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to generate invoice"), + }); + + const createActivationTicketMutation = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/tickets", { + clientId: client.id, + subject: `Activation — ${client.firstName} ${client.lastName} (${client.accountNumber})`, + type: "INSTALLATION", + priority: "HIGH", + }); + }, + onSuccess: () => { + toast.success("Activation ticket created!"); + setShowActivationTicketModal(false); + onRefresh(); + qc.invalidateQueries({ queryKey: ["client-tickets", client.id] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create ticket"), + }); + + const resolveActivationMutation = useMutation({ + mutationFn: async (ticketId: string) => { + // 1. Resolve activation ticket + await api.patch(`/api/v1/tickets/${ticketId}`, { status: "RESOLVED" }); + // 2. Set client to active + await api.patch(`/api/v1/clients/${client.id}`, { isActive: true }); + // 3. Activate pending subscription if any + if (pendingSub) { + await api.patch(`/api/v1/clients/${client.id}/subscriptions/${pendingSub.id}/activate`, {}); + } + }, + onSuccess: () => { + toast.success("Client activated! 🎉"); + onRefresh(); + qc.invalidateQueries({ queryKey: ["client", client.id] }); + qc.invalidateQueries({ queryKey: ["client-subscriptions", client.id] }); + qc.invalidateQueries({ queryKey: ["client-tickets", client.id] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Activation failed"), + }); + + const generatePostpaidInvoiceMutation = useMutation({ + mutationFn: async () => { + await api.post(`/api/v1/invoices/generate/${client.id}`, {}); + }, + onSuccess: () => { + toast.success("First month invoice generated!"); + onRefresh(); + qc.invalidateQueries({ queryKey: ["client-invoices", client.id] }); + }, + onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to generate invoice"), + }); + + // Build steps based on billing type + type StepStatus = "done" | "active" | "pending"; + + interface Step { + label: string; + description: string; + status: StepStatus; + action?: React.ReactNode; + } + + const steps: Step[] = isPrepaid + ? [ + { + label: "Resolve Installation", + description: "Mark the installation ticket as resolved", + status: state.installationResolved ? "done" : "active", + action: !state.installationResolved && state.installationTicketId ? ( + + ) : undefined, + }, + { + label: "Pin Installation Location", + description: "Save the client's GPS coordinates", + status: state.locationPinned ? "done" : state.installationResolved ? "active" : "pending", + action: !state.locationPinned && state.installationResolved ? ( + + ) : undefined, + }, + { + label: "Create Invoice", + description: "Generate the first month invoice (client pays before activation)", + status: state.invoiceCreated ? "done" : (state.installationResolved && state.locationPinned) ? "active" : "pending", + action: !state.invoiceCreated && state.installationResolved && state.locationPinned ? ( + + ) : undefined, + }, + { + label: "Create Activation Ticket", + description: "Open a ticket to confirm service activation", + status: state.activationTicketId ? "done" : (state.invoiceCreated) ? "active" : "pending", + action: !state.activationTicketId && state.invoiceCreated ? ( + + ) : undefined, + }, + { + label: "Resolve Activation → Client Active", + description: "Resolve the activation ticket — client status will be set to Active automatically", + status: state.clientActive ? "done" : (state.activationTicketId && !state.activationResolved) ? "active" : "pending", + action: state.activationTicketId && !state.activationResolved && !state.clientActive ? ( + + ) : undefined, + }, + ] + : [ + { + label: "Resolve Installation", + description: "Mark the installation ticket as resolved", + status: state.installationResolved ? "done" : "active", + action: !state.installationResolved && state.installationTicketId ? ( + + ) : undefined, + }, + { + label: "Pin Installation Location", + description: "Save the client's GPS coordinates", + status: state.locationPinned ? "done" : state.installationResolved ? "active" : "pending", + action: !state.locationPinned && state.installationResolved ? ( + + ) : undefined, + }, + { + label: "Create Activation Ticket", + description: "Open a ticket to confirm service activation", + status: state.activationTicketId ? "done" : (state.installationResolved && state.locationPinned) ? "active" : "pending", + action: !state.activationTicketId && state.installationResolved && state.locationPinned ? ( + + ) : undefined, + }, + { + label: "Resolve Activation → Client Active", + description: "Resolve the activation ticket — client status will be set to Active automatically", + status: state.clientActive ? "done" : (state.activationTicketId && !state.activationResolved) ? "active" : "pending", + action: state.activationTicketId && !state.activationResolved && !state.clientActive ? ( + + ) : undefined, + }, + { + label: "Generate First Month Invoice", + description: "Create the first invoice after client is active (postpaid)", + status: (state.clientActive && state.invoiceCreated) ? "done" : state.clientActive ? "active" : "pending", + action: state.clientActive && !state.invoiceCreated ? ( + + ) : undefined, + }, + ]; + + const allDone = steps.every(s => s.status === "done"); + if (allDone) return null; + + return ( + <> + + +
+ + + Activation Flow — {isPrepaid ? "Prepaid" : "Postpaid"} + + {pendingSub?.plan?.name ?? "Pending"} +
+
+ +
    + {steps.map((step, i) => ( +
  1. +
    + {step.status === "done" ? ( + + ) : step.status === "active" ? ( +
    +
    +
    + ) : ( + + )} +
    +
    +

    {step.label}

    + {step.status !== "done" && ( +

    {step.description}

    + )} +
    + {step.action &&
    {step.action}
    } +
  2. + ))} +
+
+
+ + {/* Pin Location Modal */} + setShowPinModal(false)} title="Pin Installation Location"> +
+

Enter the GPS coordinates for this installation site.

+ setPinForm(f => ({ ...f, lat: e.target.value }))} /> + setPinForm(f => ({ ...f, lng: e.target.value }))} /> +

💡 Tip: Get coordinates from Google Maps → right-click the location → copy lat/lng.

+
+ + +
+
+
+ + ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + export default function ClientDetailPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); @@ -53,16 +401,28 @@ export default function ClientDetailPage() { queryFn: async () => { const r = await api.get(`/api/v1/clients/${id}`); return r.data; }, }); - const { data: subscriptions, isError: subsError } = useQuery({ + const { data: subscriptions = [] } = useQuery({ queryKey: ["client-subscriptions", id], - queryFn: async () => { const r = await api.get<{ data: Subscription[] }>(`/api/v1/clients/${id}/subscriptions`); return (r.data as any).data ?? []; }, - enabled: activeTab === "subscriptions", + queryFn: async () => { + const r = await api.get<{ data: Subscription[] }>(`/api/v1/clients/${id}/subscriptions`); + return (r.data as any).data ?? []; + }, + }); + + const { data: allTickets = [] } = useQuery({ + queryKey: ["client-tickets-all", id], + queryFn: async () => { + const r = await api.get>(`/api/v1/tickets?clientId=${id}&page=1&limit=50`); + return (r.data as any).data ?? []; + }, }); const { data: invoicesData, refetch: refetchInvoices } = useQuery>({ queryKey: ["client-invoices", id], - queryFn: async () => { const r = await api.get>(`/api/v1/invoices?clientId=${id}&page=1&limit=30`); return r.data; }, - enabled: activeTab === "invoices", + queryFn: async () => { + const r = await api.get>(`/api/v1/invoices?clientId=${id}&page=1&limit=30`); + return r.data; + }, }); const { data: paymentsData } = useQuery>({ @@ -74,12 +434,6 @@ export default function ClientDetailPage() { enabled: activeTab === "payments", }); - const { data: ticketsData } = useQuery>({ - queryKey: ["client-tickets", id], - queryFn: async () => { const r = await api.get>(`/api/v1/tickets?clientId=${id}&page=1&limit=20`); return r.data; }, - enabled: activeTab === "tickets", - }); - const recordPayment = useMutation({ mutationFn: async () => { await api.post("/api/v1/payments", { @@ -125,6 +479,15 @@ export default function ClientDetailPage() { const sub = client.subscriptions?.[0]; const clientStatus = sub?.status ?? (client.isActive ? "ACTIVE" : "INACTIVE"); + const hasPendingSub = subscriptions.some(s => s.status === "PENDING"); + const needsActivation = !client.isActive || hasPendingSub; + + const handleRefresh = () => { + refetchClient(); + qc.invalidateQueries({ queryKey: ["client-subscriptions", id] }); + qc.invalidateQueries({ queryKey: ["client-tickets-all", id] }); + qc.invalidateQueries({ queryKey: ["client-invoices", id] }); + }; return (
@@ -138,9 +501,20 @@ export default function ClientDetailPage() {

{client.accountNumber} · {client.area?.name ?? "No area"}

{clientStatus} - + + {/* Activation Flow Banner — only when client is not yet active */} + {needsActivation && ( + + )} + {/* Tabs */}
{tabs.map(tab => ( @@ -165,6 +539,7 @@ export default function ClientDetailPage() { { label: "Address", value: client.address || "—" }, { label: "Area", value: client.area?.name || "—" }, { label: "Status", value: client.isActive ? "Active" : "Inactive" }, + { label: "Location", value: client.lat && client.lng ? `${client.lat}, ${client.lng}` : "Not pinned" }, { label: "Joined", value: formatDate(client.createdAt) }, ].map(({ label, value }) => (
@@ -185,7 +560,7 @@ export default function ClientDetailPage() { - {!subscriptions || subscriptions.length === 0 ? : + {subscriptions.length === 0 ? : subscriptions.map(sub => ( @@ -270,8 +645,8 @@ export default function ClientDetailPage() {
PlanTypeStatusStart DateMonthly Rate {sub.plan?.name ?? "—"}
- {!ticketsData?.data || ticketsData.data.length === 0 ? : - ticketsData.data.map(t => ( + {allTickets.length === 0 ? : + allTickets.map(t => ( diff --git a/src/types/index.ts b/src/types/index.ts index 2fc66d1..b74579c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,6 +46,8 @@ export interface Client { phone: string; address?: string; isActive: boolean; + lat?: number | null; + lng?: number | null; createdAt: string; updatedAt: string; area?: { id: string; name: string }; @@ -63,6 +65,7 @@ export interface Subscription { endDate?: string; monthlyRate?: number; mrc?: number; + monthlyPrice?: number | string; createdAt: string; } -- 2.43.0
SubjectTypePriorityStatusCreated {t.subject} {t.type}