feat(tickets): filtro Todos/Activos/Archivados + botón archivar
This commit is contained in:
1 parent
f536f11dfd
commit
24dcb6d770
4 files changed
+103
-4
No files matched your search
@@ -75,7 +75,7 @@ push a `main` → webhook Gitea → build + up en `.4`
|
|||||||
Base: `NEXT_PUBLIC_API_URL/api/helpdesk`
|
Base: `NEXT_PUBLIC_API_URL/api/helpdesk`
|
||||||
|
|
||||||
- `POST /login {idUsuario, password}` · `GET /me`
|
- `POST /login {idUsuario, password}` · `GET /me`
|
||||||
- `GET /tickets?...` · `GET /ticket/{id}` · `POST /tickets` · `PUT /ticket/{id}` (+ `/status`, `/assign`) · `DELETE /ticket/{id}`
|
- `GET /tickets?...` (con `archived=false` solo activos [default UI], `true` solo archivados, ausente todos) · `GET /ticket/{id}` · `POST /tickets` · `PUT /ticket/{id}` (+ `/status`, `/assign`, `/archive`) · `DELETE /ticket/{id}`
|
||||||
- `GET|POST /comments/{ticketId}`
|
- `GET|POST /comments/{ticketId}`
|
||||||
- `GET /kb/published` · `GET /kb/search?q=` · `GET /kb/categories`
|
- `GET /kb/published` · `GET /kb/search?q=` · `GET /kb/categories`
|
||||||
- `GET /stats`
|
- `GET /stats`
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getTicketEmails,
|
getTicketEmails,
|
||||||
addComment,
|
addComment,
|
||||||
updateTicketStatus,
|
updateTicketStatus,
|
||||||
|
setTicketArchived,
|
||||||
assignTicket,
|
assignTicket,
|
||||||
getGroups,
|
getGroups,
|
||||||
getTeam,
|
getTeam,
|
||||||
@@ -286,6 +287,24 @@ export default function TicketDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleArchiveToggle = async () => {
|
||||||
|
if (!ticket) return;
|
||||||
|
const toArchived = !ticket.archived;
|
||||||
|
if (toArchived && !window.confirm("¿Archivar este ticket? Saldrá de la vista de activos, conservando su estado.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await setTicketArchived(id, toArchived);
|
||||||
|
await refreshTicket();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "No se pudo cambiar el archivado");
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAssignAgent = async (idUsuario: string | null) => {
|
const handleAssignAgent = async (idUsuario: string | null) => {
|
||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
setAssignOpen(false);
|
setAssignOpen(false);
|
||||||
@@ -338,6 +357,11 @@ export default function TicketDetailPage() {
|
|||||||
<div className="d-flex gap-2 mt-2">
|
<div className="d-flex gap-2 mt-2">
|
||||||
<BadgeStatus status={ticket.status} />
|
<BadgeStatus status={ticket.status} />
|
||||||
<BadgePriority priority={ticket.priority} />
|
<BadgePriority priority={ticket.priority} />
|
||||||
|
{ticket.archived && (
|
||||||
|
<span className="badge bg-secondary" title="Ticket archivado">
|
||||||
|
<i className="bi bi-archive me-1"></i>Archivado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isTeam && (
|
{isTeam && (
|
||||||
@@ -437,6 +461,17 @@ export default function TicketDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Archivar / Desarchivar (conserva el estado del ticket) */}
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${ticket.archived ? "btn-outline-secondary" : "btn-outline-dark"}`}
|
||||||
|
disabled={acting}
|
||||||
|
title={ticket.archived ? "Volver a la vista de activos" : "Sacar de la vista de activos"}
|
||||||
|
onClick={handleArchiveToggle}
|
||||||
|
>
|
||||||
|
<i className={`bi ${ticket.archived ? "bi-box-arrow-up" : "bi-archive"} me-1`}></i>
|
||||||
|
{ticket.archived ? "Desarchivar" : "Archivar"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ import Link from "next/link";
|
|||||||
|
|
||||||
type View = "all" | "mine" | "assigned" | "unassigned";
|
type View = "all" | "mine" | "assigned" | "unassigned";
|
||||||
|
|
||||||
|
// Alcance de archivado: por defecto solo activos. "all" = todos,
|
||||||
|
// "active" = no archivados, "archived" = solo archivados.
|
||||||
|
type ArchiveScope = "all" | "active" | "archived";
|
||||||
|
|
||||||
|
function parseArchiveScope(v: string | null): ArchiveScope | null {
|
||||||
|
if (v === null) return null;
|
||||||
|
const k = v.trim().toLowerCase();
|
||||||
|
if (k === "all" || k === "todos") return "all";
|
||||||
|
if (k === "active" || k === "activos" || k === "false" || k === "0") return "active";
|
||||||
|
if (k === "archived" || k === "archivados" || k === "true" || k === "1") return "archived";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: "Open", label: "Abierto" },
|
{ value: "Open", label: "Abierto" },
|
||||||
{ value: "Pending", label: "Pendiente" },
|
{ value: "Pending", label: "Pendiente" },
|
||||||
@@ -64,6 +77,7 @@ function TicketsContent() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
const [view, setView] = useState<View>("all");
|
const [view, setView] = useState<View>("all");
|
||||||
|
const [archiveScope, setArchiveScope] = useState<ArchiveScope>("active");
|
||||||
// Vacío = Todos los estados. Con 1 o más = filtra por esos estados.
|
// Vacío = Todos los estados. Con 1 o más = filtra por esos estados.
|
||||||
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
@@ -98,6 +112,7 @@ function TicketsContent() {
|
|||||||
unique.length >= STATUS_OPTIONS.length ? [] : unique;
|
unique.length >= STATUS_OPTIONS.length ? [] : unique;
|
||||||
const priority = searchParams.get("priority") ?? "";
|
const priority = searchParams.get("priority") ?? "";
|
||||||
const viewParam = searchParams.get("view") as View | null;
|
const viewParam = searchParams.get("view") as View | null;
|
||||||
|
const archiveParam = parseArchiveScope(searchParams.get("archived"));
|
||||||
const q = searchParams.get("search") ?? "";
|
const q = searchParams.get("search") ?? "";
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
setStatusFilters(normalized);
|
setStatusFilters(normalized);
|
||||||
@@ -105,6 +120,8 @@ function TicketsContent() {
|
|||||||
if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") {
|
if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") {
|
||||||
setView(viewParam);
|
setView(viewParam);
|
||||||
}
|
}
|
||||||
|
// Sin ?archived en la URL el default es solo activos.
|
||||||
|
setArchiveScope(archiveParam ?? "active");
|
||||||
setSearch(q);
|
setSearch(q);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
});
|
});
|
||||||
@@ -134,6 +151,7 @@ function TicketsContent() {
|
|||||||
: view === "unassigned"
|
: view === "unassigned"
|
||||||
? 0
|
? 0
|
||||||
: undefined,
|
: undefined,
|
||||||
|
archived: archiveScope === "all" ? undefined : archiveScope === "archived",
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -148,10 +166,14 @@ function TicketsContent() {
|
|||||||
setLoadError(err instanceof Error ? err.message : "Error al cargar tickets");
|
setLoadError(err instanceof Error ? err.message : "Error al cargar tickets");
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [statusFilters, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]);
|
}, [statusFilters, priorityFilter, search, page, view, archiveScope, numericId, hasNumericId, isTeam]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
const hasActiveFilters = statusFilters.length > 0 || priorityFilter !== "" || view !== "all" || search !== "";
|
const hasActiveFilters = statusFilters.length > 0 || priorityFilter !== "" || view !== "all" || search !== "" || archiveScope !== "active";
|
||||||
|
|
||||||
|
function archiveScopeLabel(s: ArchiveScope): string {
|
||||||
|
return s === "all" ? "Todos" : s === "archived" ? "Archivados" : "Activos";
|
||||||
|
}
|
||||||
|
|
||||||
const toggleStatus = (value: string) => {
|
const toggleStatus = (value: string) => {
|
||||||
setStatusFilters((prev) => {
|
setStatusFilters((prev) => {
|
||||||
@@ -182,6 +204,7 @@ function TicketsContent() {
|
|||||||
setStatusOpen(false);
|
setStatusOpen(false);
|
||||||
setPriorityFilter("");
|
setPriorityFilter("");
|
||||||
setView("all");
|
setView("all");
|
||||||
|
setArchiveScope("active");
|
||||||
setSearch("");
|
setSearch("");
|
||||||
setPage(1);
|
setPage(1);
|
||||||
router.replace("/tickets");
|
router.replace("/tickets");
|
||||||
@@ -226,6 +249,7 @@ function TicketsContent() {
|
|||||||
{statusFilters.length > 0 && <> · Estado: <strong>{statusFilters.map(statusLabel).join(", ")}</strong></>}
|
{statusFilters.length > 0 && <> · Estado: <strong>{statusFilters.map(statusLabel).join(", ")}</strong></>}
|
||||||
{priorityFilter && <> · Prioridad: <strong>{priorityFilter}</strong></>}
|
{priorityFilter && <> · Prioridad: <strong>{priorityFilter}</strong></>}
|
||||||
{view !== "all" && <> · Vista: <strong>{view === "unassigned" ? "Sin asignar" : view}</strong></>}
|
{view !== "all" && <> · Vista: <strong>{view === "unassigned" ? "Sin asignar" : view}</strong></>}
|
||||||
|
{archiveScope !== "active" && <> · Archivo: <strong>{archiveScopeLabel(archiveScope)}</strong></>}
|
||||||
{search && <> · Búsqueda: <strong>{search}</strong></>}
|
{search && <> · Búsqueda: <strong>{search}</strong></>}
|
||||||
{" "}(desde el Dashboard)
|
{" "}(desde el Dashboard)
|
||||||
</span>
|
</span>
|
||||||
@@ -238,6 +262,7 @@ function TicketsContent() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
{isTeam ? (
|
{isTeam ? (
|
||||||
<div className="filter-bar mb-3">
|
<div className="filter-bar mb-3">
|
||||||
|
<span className="text-muted" style={{ fontSize: "0.8rem" }}>Vista:</span>
|
||||||
<div className="btn-group" role="group" aria-label="Vista">
|
<div className="btn-group" role="group" aria-label="Vista">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
@@ -260,6 +285,28 @@ function TicketsContent() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-muted" style={{ fontSize: "0.8rem" }}>Archivo:</span>
|
||||||
|
<div className="btn-group" role="group" aria-label="Archivo">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["all", "Todos"],
|
||||||
|
["active", "Activos"],
|
||||||
|
["archived", "Archivados"],
|
||||||
|
] as const
|
||||||
|
).map(([v, label]) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${archiveScope === v ? "btn-primary" : "btn-outline-secondary"}`}
|
||||||
|
onClick={() => {
|
||||||
|
setArchiveScope(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="filter-bar mb-3">
|
<div className="filter-bar mb-3">
|
||||||
@@ -392,7 +439,14 @@ function TicketsContent() {
|
|||||||
<div className="fw-medium">{ticket.title}</div>
|
<div className="fw-medium">{ticket.title}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{ticket.requesterName || "N/A"}</td>
|
<td>{ticket.requesterName || "N/A"}</td>
|
||||||
<td><BadgeStatus status={ticket.status} /></td>
|
<td>
|
||||||
|
<BadgeStatus status={ticket.status} />
|
||||||
|
{ticket.archived && (
|
||||||
|
<span className="badge bg-secondary ms-1" title="Ticket archivado">
|
||||||
|
<i className="bi bi-archive me-1"></i>Archivado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td><BadgePriority priority={ticket.priority} /></td>
|
<td><BadgePriority priority={ticket.priority} /></td>
|
||||||
<td>{ticket.assigneeName || <span className="text-muted">Sin asignar</span>}</td>
|
<td>{ticket.assigneeName || <span className="text-muted">Sin asignar</span>}</td>
|
||||||
<td className="text-muted" style={{ fontSize: "0.85rem" }}>
|
<td className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export async function getTickets(params?: {
|
|||||||
assigneeId?: number;
|
assigneeId?: number;
|
||||||
requesterId?: number;
|
requesterId?: number;
|
||||||
groupId?: number;
|
groupId?: number;
|
||||||
|
archived?: boolean;
|
||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -109,6 +110,13 @@ export async function updateTicketStatus(id: number, status: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setTicketArchived(id: number, archived: boolean) {
|
||||||
|
return fetchApi<void>(`/api/helpdesk/ticket/${id}/archive`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ archived }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function assignTicket(id: number, assigneeId: number, groupId?: number) {
|
export async function assignTicket(id: number, assigneeId: number, groupId?: number) {
|
||||||
return fetchApi<void>(`/api/helpdesk/ticket/${id}/assign`, {
|
return fetchApi<void>(`/api/helpdesk/ticket/${id}/assign`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -226,6 +234,8 @@ export interface Ticket {
|
|||||||
firstResponseAt?: string;
|
firstResponseAt?: string;
|
||||||
resolvedAt?: string;
|
resolvedAt?: string;
|
||||||
closedAt?: string;
|
closedAt?: string;
|
||||||
|
archived: boolean;
|
||||||
|
archivedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketListResult {
|
export interface TicketListResult {
|
||||||
|
|||||||
Reference in new issue
Block a user