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:
2026-04-01 08:09:44 +00:00
parent 34549fc4b6
commit b07e2faee5
10 changed files with 772 additions and 237 deletions

View File

@@ -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>