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

328 lines
18 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, Ticket, Plus, Send, RotateCcw, User } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
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 { formatDate } from "@/lib/utils";
import api from "@/lib/api";
import { toast } from "sonner";
interface TicketComment { id: string; body: string; createdAt: string; sender?: { firstName: string; lastName: string }; author?: { firstName: string; lastName: string }; }
interface TicketItem {
id: string; ticketNumber?: string; subject: string; description?: string;
type: string; priority: string; status: string; clientId?: string;
client?: { id: string; firstName: string; lastName: string; accountNumber: string };
assignedTo?: { id: string; firstName: string; lastName: string } | string;
createdAt: string; updatedAt?: string;
comments?: TicketComment[];
}
interface PaginatedResponse<T> { data: T[]; meta: { total: number; page: number; limit: number; }; }
const statusVariant: Record<string, "success" | "warning" | "danger" | "muted"> = {
OPEN: "warning", IN_PROGRESS: "default" as any, RESOLVED: "success", CLOSED: "muted",
};
const priorityVariant: Record<string, "danger" | "warning" | "muted"> = {
HIGH: "danger", NORMAL: "muted", LOW: "muted",
};
const statusFilters = ["", "OPEN", "IN_PROGRESS", "RESOLVED", "CLOSED"];
const typeFilters = ["", "SUPPORT", "BILLING", "INSTALLATION"];
// Previous status mapping (revert flow)
const prevStatus: Record<string, string> = {
CLOSED: "RESOLVED",
RESOLVED: "IN_PROGRESS",
IN_PROGRESS: "OPEN",
};
export default function TicketsPage() {
const qc = useQueryClient();
const { user } = useAuth();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [assignedToMe, setAssignedToMe] = useState(false);
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<TicketItem | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [comment, setComment] = useState("");
const [newForm, setNewForm] = useState({ subject: "", description: "", type: "SUPPORT", priority: "NORMAL", clientSearch: "", clientId: "" });
const { data, isLoading, refetch } = useQuery<PaginatedResponse<TicketItem>>({
queryKey: ["tickets", search, statusFilter, typeFilter, assignedToMe, page],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (search) params.set("search", search);
if (statusFilter) params.set("status", statusFilter);
if (typeFilter) params.set("type", typeFilter);
if (assignedToMe && user?.id) params.set("assignedToId", user.id);
const res = await api.get<PaginatedResponse<TicketItem>>(`/api/v1/tickets?${params}`);
return res.data;
},
});
const { data: ticketDetail, refetch: refetchDetail } = useQuery<TicketItem>({
queryKey: ["ticket", selected?.id],
queryFn: async () => {
const res = await api.get<TicketItem>(`/api/v1/tickets/${selected!.id}`);
return res.data;
},
enabled: !!selected?.id,
});
const { data: clientSearch = [], isFetching: searchingClients } = useQuery({
queryKey: ["client-search", newForm.clientSearch],
queryFn: async () => {
if (!newForm.clientSearch || newForm.clientSearch.length < 2) return [];
const res = await api.get(`/api/v1/clients?search=${encodeURIComponent(newForm.clientSearch)}&limit=10`);
return res.data?.data ?? [];
},
enabled: newForm.clientSearch.length >= 2,
});
const updateStatus = useMutation({
mutationFn: async ({ id, status }: { id: string; status: string }) => {
await api.patch(`/api/v1/tickets/${id}`, { status });
},
onSuccess: () => { toast.success("Status updated"); refetch(); refetchDetail(); qc.invalidateQueries({ queryKey: ["ticket"] }); },
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed"),
});
const addComment = useMutation({
mutationFn: async () => {
await api.post(`/api/v1/tickets/${selected!.id}/messages`, { body: comment });
},
onSuccess: () => { toast.success("Comment added"); setComment(""); refetchDetail(); },
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed"),
});
const createTicket = useMutation({
mutationFn: async () => {
await api.post("/api/v1/tickets", {
subject: newForm.subject, description: newForm.description,
type: newForm.type, priority: newForm.priority,
clientId: newForm.clientId || undefined,
});
},
onSuccess: () => {
toast.success("Ticket created!");
setShowCreate(false);
setNewForm({ subject: "", description: "", type: "SUPPORT", priority: "NORMAL", clientSearch: "", clientId: "" });
qc.invalidateQueries({ queryKey: ["tickets"] });
},
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create ticket"),
});
const revertStatus = useMutation({
mutationFn: async ({ id, status }: { id: string; status: string }) => {
await api.patch(`/api/v1/tickets/${id}`, { status });
},
onSuccess: () => { toast.success("Status reverted"); refetch(); refetchDetail(); qc.invalidateQueries({ queryKey: ["ticket"] }); },
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed"),
});
const tickets = data?.data ?? [];
const total = data?.meta?.total ?? 0;
const detail = ticketDetail ?? selected;
const comments = (ticketDetail as any)?.messages ?? [];
const nextStatus: Record<string, string> = { OPEN: "IN_PROGRESS", IN_PROGRESS: "RESOLVED", RESOLVED: "CLOSED" };
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Tickets</h1>
<p className="text-sm text-gray-500 mt-1">{total} total tickets</p>
</div>
<div className="flex gap-2">
<Button onClick={() => refetch()} variant="outline" size="sm"><RefreshCw size={14} className="mr-1" />Refresh</Button>
<Button onClick={() => setShowCreate(true)} size="sm"><Plus size={14} className="mr-1" />New Ticket</Button>
</div>
</div>
<Card>
<CardHeader>
<div className="flex flex-wrap gap-3">
<input className="flex-1 min-w-[200px] border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search tickets..." value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} />
<div className="flex gap-2 flex-wrap">
{statusFilters.map(s => (
<button key={s} onClick={() => { setStatusFilter(s); setPage(1); }}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${statusFilter === s ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
{s || "All Status"}
</button>
))}
</div>
<div className="flex gap-2 flex-wrap">
{typeFilters.map(t => (
<button key={t} onClick={() => { setTypeFilter(t); setPage(1); }}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${typeFilter === t ? "bg-purple-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
{t || "All Types"}
</button>
))}
</div>
<button
onClick={() => { setAssignedToMe(v => !v); setPage(1); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${assignedToMe ? "bg-green-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
title="Show only tickets assigned to me"
>
<User size={12} /> Assigned to me
</button>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHead>
<TableRow><Th>Subject</Th><Th>Client</Th><Th>Type</Th><Th>Priority</Th><Th>Status</Th><Th>Created</Th><Th></Th></TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<TableRow key={i}><Td colSpan={7}><div className="h-4 bg-gray-100 rounded animate-pulse" /></Td></TableRow>
))
) : tickets.length === 0 ? (
<EmptyState colSpan={7} message="No tickets found" icon={<Ticket size={24} />} />
) : tickets.map(t => (
<TableRow key={t.id} onClick={() => setSelected(t)} className="cursor-pointer hover:bg-blue-50 transition-colors">
<Td className="font-medium max-w-[200px] truncate">{t.subject}</Td>
<Td className="text-sm text-gray-600">{t.client ? `${t.client.firstName} ${t.client.lastName}` : "—"}</Td>
<Td><Badge variant="muted">{t.type}</Badge></Td>
<Td><Badge variant={priorityVariant[t.priority] ?? "muted"}>{t.priority}</Badge></Td>
<Td><Badge variant={statusVariant[t.status] ?? "muted"}>{t.status}</Badge></Td>
<Td className="text-xs text-gray-400">{formatDate(t.createdAt)}</Td>
<Td className="text-gray-400 text-xs">View </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>
{/* Ticket Detail Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title={`Ticket — ${detail?.subject ?? ""}`} className="max-w-2xl">
{detail && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm bg-gray-50 rounded-lg p-4">
<div><span className="text-gray-500">Client</span><p className="font-medium">{detail.client ? `${detail.client.firstName} ${detail.client.lastName}` : "—"}</p></div>
<div><span className="text-gray-500">Type</span><p><Badge variant="muted">{detail.type}</Badge></p></div>
<div><span className="text-gray-500">Priority</span><p><Badge variant={priorityVariant[detail.priority] ?? "muted"}>{detail.priority}</Badge></p></div>
<div><span className="text-gray-500">Status</span><p><Badge variant={statusVariant[detail.status] ?? "muted"}>{detail.status}</Badge></p></div>
<div className="col-span-2"><span className="text-gray-500">Created</span><p>{formatDate(detail.createdAt)}</p></div>
{detail.description && <div className="col-span-2"><span className="text-gray-500">Description</span><p className="mt-1 text-gray-800 whitespace-pre-wrap">{detail.description}</p></div>}
</div>
{/* Status actions */}
<div className="flex gap-2 flex-wrap">
{nextStatus[detail.status] && (
<Button size="sm" onClick={() => updateStatus.mutate({ id: detail.id, status: nextStatus[detail.status] })} isLoading={updateStatus.isPending}>
Move to {nextStatus[detail.status].replace("_", " ")}
</Button>
)}
{prevStatus[detail.status] && (
<Button size="sm" variant="outline" onClick={() => revertStatus.mutate({ id: detail.id, status: prevStatus[detail.status] })} isLoading={revertStatus.isPending}>
<RotateCcw size={13} className="mr-1" /> Revert to {prevStatus[detail.status].replace("_", " ")}
</Button>
)}
</div>
{/* Comments */}
<div>
<p className="text-sm font-semibold text-gray-700 mb-2">Comments ({comments.length})</p>
<div className="space-y-2 max-h-48 overflow-y-auto mb-3">
{comments.length === 0 ? <p className="text-sm text-gray-400">No comments yet.</p> :
comments.map((c: TicketComment) => (
<div key={c.id} className="bg-white border rounded-lg px-3 py-2 text-sm">
<p className="font-medium text-gray-700 text-xs">{(c.sender || c.author) ? `${(c.sender || c.author)!.firstName} ${(c.sender || c.author)!.lastName}` : "Staff"} · {formatDate(c.createdAt)}</p>
<p className="text-gray-800 mt-0.5">{c.body}</p>
</div>
))
}
</div>
<div className="flex gap-2">
<input className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add a comment..." value={comment} onChange={e => setComment(e.target.value)}
onKeyDown={e => e.key === "Enter" && !e.shiftKey && comment.trim() && addComment.mutate()} />
<Button size="sm" onClick={() => addComment.mutate()} isLoading={addComment.isPending} disabled={!comment.trim()}>
<Send size={14} />
</Button>
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setSelected(null)}>Close</Button>
</div>
</div>
)}
</Modal>
{/* Create Ticket Modal */}
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="New Ticket">
<div className="space-y-4">
<Input label="Subject *" value={newForm.subject} onChange={e => setNewForm(f => ({ ...f, subject: e.target.value }))} />
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea className="border rounded-lg px-3 py-2 text-sm resize-none h-24 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={newForm.description} onChange={e => setNewForm(f => ({ ...f, description: e.target.value }))} />
</div>
<div className="grid grid-cols-2 gap-3">
<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={newForm.type} onChange={e => setNewForm(f => ({ ...f, type: e.target.value }))}>
<option value="SUPPORT">Support</option>
<option value="BILLING">Billing</option>
<option value="INSTALLATION">Installation</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Priority</label>
<select className="border rounded-lg px-3 py-2 text-sm" value={newForm.priority} onChange={e => setNewForm(f => ({ ...f, priority: e.target.value }))}>
<option value="NORMAL">Normal</option>
<option value="HIGH">High</option>
</select>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Link to Client (optional)</label>
<input className="border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search client by name..." value={newForm.clientSearch}
onChange={e => setNewForm(f => ({ ...f, clientSearch: e.target.value, clientId: "" }))} />
{(clientSearch as any[]).length > 0 && !newForm.clientId && (
<div className="border rounded-lg divide-y max-h-40 overflow-y-auto shadow-sm">
{(clientSearch as any[]).map((c: any) => (
<button key={c.id} onClick={() => setNewForm(f => ({ ...f, clientId: c.id, clientSearch: `${c.firstName} ${c.lastName}` }))}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 transition-colors">
<span className="font-medium">{c.firstName} {c.lastName}</span>
<span className="text-gray-400 ml-2 text-xs">{c.accountNumber}</span>
</button>
))}
</div>
)}
{newForm.clientId && <p className="text-xs text-green-600"> Client linked</p>}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
<Button onClick={() => createTicket.mutate()} isLoading={createTicket.isPending} disabled={!newForm.subject}>Create Ticket</Button>
</div>
</div>
</Modal>
</div>
);
}