feat: web sprint 250-262 — plans/clients/tickets/audit/profile/settings/leads/reports (#26)
This commit was merged in pull request #26.
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCw, Ticket, Plus, Send } from "lucide-react";
|
||||
import { RefreshCw, Ticket, Plus, Send, RotateCcw, User, MapPin, Zap } 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";
|
||||
@@ -34,11 +35,20 @@ const priorityVariant: Record<string, "danger" | "warning" | "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);
|
||||
@@ -46,12 +56,13 @@ export default function TicketsPage() {
|
||||
const [newForm, setNewForm] = useState({ subject: "", description: "", type: "SUPPORT", priority: "NORMAL", clientSearch: "", clientId: "" });
|
||||
|
||||
const { data, isLoading, refetch } = useQuery<PaginatedResponse<TicketItem>>({
|
||||
queryKey: ["tickets", search, statusFilter, typeFilter, page],
|
||||
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;
|
||||
},
|
||||
@@ -109,6 +120,22 @@ export default function TicketsPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create ticket"),
|
||||
});
|
||||
|
||||
const activateClient = useMutation({
|
||||
mutationFn: async (clientId: string) => {
|
||||
await api.patch(`/api/v1/clients/${clientId}`, { isActive: true });
|
||||
},
|
||||
onSuccess: () => { toast.success("Client activated! 🎉"); refetch(); refetchDetail(); },
|
||||
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to activate client"),
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -150,6 +177,13 @@ export default function TicketsPage() {
|
||||
</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">
|
||||
@@ -200,14 +234,43 @@ export default function TicketsPage() {
|
||||
<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>}
|
||||
{/* Installation: show Google Maps link using client address */}
|
||||
{detail.type === "INSTALLATION" && detail.client && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">Map</span>
|
||||
<p className="mt-0.5">
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/${encodeURIComponent([(ticketDetail as any)?.client?.address, detail.client.firstName + " " + detail.client.lastName].filter(Boolean).join(", "))}`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
<MapPin size={13} /> View on Google Maps
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status actions */}
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
{/* Installation ticket RESOLVED → show Activate Client button */}
|
||||
{detail.type === "INSTALLATION" && detail.status === "RESOLVED" && detail.clientId && (
|
||||
<Button size="sm" variant="outline" onClick={() => activateClient.mutate(detail.clientId!)} isLoading={activateClient.isPending}
|
||||
className="text-green-700 border-green-300 hover:bg-green-50">
|
||||
<Zap size={13} className="mr-1" /> Activate Client
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user