feat: web sprint 250-262 — plans/clients/tickets/audit/profile/settings/leads/reports #26
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { RefreshCw, Ticket, Plus, Send } from "lucide-react";
|
import { RefreshCw, Ticket, Plus, Send, RotateCcw, User } from "lucide-react";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/Card";
|
||||||
import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
|
import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
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 statusFilters = ["", "OPEN", "IN_PROGRESS", "RESOLVED", "CLOSED"];
|
||||||
const typeFilters = ["", "SUPPORT", "BILLING", "INSTALLATION"];
|
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() {
|
export default function TicketsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState("");
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
|
const [assignedToMe, setAssignedToMe] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [selected, setSelected] = useState<TicketItem | null>(null);
|
const [selected, setSelected] = useState<TicketItem | null>(null);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
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 [newForm, setNewForm] = useState({ subject: "", description: "", type: "SUPPORT", priority: "NORMAL", clientSearch: "", clientId: "" });
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery<PaginatedResponse<TicketItem>>({
|
const { data, isLoading, refetch } = useQuery<PaginatedResponse<TicketItem>>({
|
||||||
queryKey: ["tickets", search, statusFilter, typeFilter, page],
|
queryKey: ["tickets", search, statusFilter, typeFilter, assignedToMe, page],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams({ page: String(page), limit: "20" });
|
const params = new URLSearchParams({ page: String(page), limit: "20" });
|
||||||
if (search) params.set("search", search);
|
if (search) params.set("search", search);
|
||||||
if (statusFilter) params.set("status", statusFilter);
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
if (typeFilter) params.set("type", typeFilter);
|
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}`);
|
const res = await api.get<PaginatedResponse<TicketItem>>(`/api/v1/tickets?${params}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -109,6 +120,14 @@ export default function TicketsPage() {
|
|||||||
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create ticket"),
|
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 tickets = data?.data ?? [];
|
||||||
const total = data?.meta?.total ?? 0;
|
const total = data?.meta?.total ?? 0;
|
||||||
const detail = ticketDetail ?? selected;
|
const detail = ticketDetail ?? selected;
|
||||||
@@ -150,6 +169,13 @@ export default function TicketsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -203,11 +229,18 @@ export default function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status actions */}
|
{/* Status actions */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
{nextStatus[detail.status] && (
|
{nextStatus[detail.status] && (
|
||||||
<Button size="sm" onClick={() => updateStatus.mutate({ id: detail.id, status: nextStatus[detail.status] })} isLoading={updateStatus.isPending}>
|
<Button size="sm" onClick={() => updateStatus.mutate({ id: detail.id, status: nextStatus[detail.status] })} isLoading={updateStatus.isPending}>
|
||||||
Move to {nextStatus[detail.status].replace("_", " ")}
|
Move to {nextStatus[detail.status].replace("_", " ")}
|
||||||
</Button>
|
</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 */}
|
{/* Comments */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user