Adapt frontend to SAM API: RUT auth, new types, helpdesk endpoints

- Login: RUT-based (SAM auth) instead of email
- API client: updated all endpoints to /api/helpdesk/*
- Types: match SAM response format (nombre, apPaterno, perfil)
- Dashboard: uses new DashboardStats with dailyCounts
- Tickets: title instead of subject, requesterName/assigneeName
- KB: categoryName/authorName instead of nested objects
- Removed register page (SAM manages users)
This commit is contained in:
Nurfog committed 2026-09-10 11:27:35 -03:00
1 parent 0cca57f21e
commit f56f6365d2
11 files changed
+285 -718

No files matched your search

+4 -38
View File
@@ -11,16 +11,7 @@ export default function KnowledgeBasePage() {
useEffect(() => { useEffect(() => {
getKBArticles() getKBArticles()
.then(setArticles) .then(setArticles)
.catch(() => { .catch(() => setArticles([]))
setArticles([
{ id: 1, title: "Cómo configurar tu correo en Outlook", content: "Guía paso a paso para configurar...", category: "Correo", publishedAt: "2026-09-01" },
{ id: 2, title: "Solicitar acceso a una carpeta compartida", content: "Proceso para solicitar acceso...", category: "Acceso", publishedAt: "2026-09-02" },
{ id: 3, title: "Problemas comunes con la impresora", content: "Soluciones a problemas frecuentes...", category: "Hardware", publishedAt: "2026-09-03" },
{ id: 4, title: "Instalación de software aprobado", content: "Lista de software aprobado y cómo instalarlo...", category: "Software", publishedAt: "2026-09-04" },
{ id: 5, title: "Conectar a la VPN corporativa", content: "Instrucciones para conectarse a la VPN...", category: "Red", publishedAt: "2026-09-05" },
{ id: 6, title: "Resetear tu contraseña", content: "Cómo restablecer tu contraseña del dominio...", category: "Acceso", publishedAt: "2026-09-06" },
]);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -31,20 +22,12 @@ export default function KnowledgeBasePage() {
const results = await searchKB(searchQuery); const results = await searchKB(searchQuery);
setArticles(results); setArticles(results);
} catch { } catch {
// Keep existing articles on error // Keep existing
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const categories = [
{ name: "Software", icon: "bi-laptop", color: "#0d6efd", count: articles.filter((a) => a.category === "Software").length },
{ name: "Hardware", icon: "bi-pc-display", color: "#198754", count: articles.filter((a) => a.category === "Hardware").length },
{ name: "Red", icon: "bi-wifi", color: "#6f42c1", count: articles.filter((a) => a.category === "Red").length },
{ name: "Acceso", icon: "bi-key", color: "#fd7e14", count: articles.filter((a) => a.category === "Acceso").length },
{ name: "Correo", icon: "bi-envelope", color: "#dc3545", count: articles.filter((a) => a.category === "Correo").length },
];
return ( return (
<div> <div>
<div className="page-header"> <div className="page-header">
@@ -74,28 +57,11 @@ export default function KnowledgeBasePage() {
</div> </div>
</div> </div>
{/* Categories */}
<div className="row g-3 mb-4">
{categories.map((cat) => (
<div key={cat.name} className="col-6 col-md-4 col-lg">
<div className="card h-100 text-center" style={{ cursor: "pointer" }}
onClick={() => { setSearchQuery(cat.name); handleSearch(); }}
>
<div className="card-body py-4">
<i className={`bi ${cat.icon} fs-2 mb-2`} style={{ color: cat.color }}></i>
<h6 className="mb-1" style={{ fontWeight: 600 }}>{cat.name}</h6>
<small className="text-muted">{cat.count} artículos</small>
</div>
</div>
</div>
))}
</div>
{/* Articles List */} {/* Articles List */}
<div className="card"> <div className="card">
<div className="card-header d-flex align-items-center gap-2"> <div className="card-header d-flex align-items-center gap-2">
<i className="bi bi-journal-text" style={{ color: "var(--primary)" }}></i> <i className="bi bi-journal-text" style={{ color: "var(--primary)" }}></i>
Artículos Recientes Artículos
</div> </div>
<div className="card-body p-0"> <div className="card-body p-0">
{loading ? ( {loading ? (
@@ -125,7 +91,7 @@ export default function KnowledgeBasePage() {
</div> </div>
<div className="flex-grow-1"> <div className="flex-grow-1">
<div className="fw-medium" style={{ fontSize: "0.9rem" }}>{article.title}</div> <div className="fw-medium" style={{ fontSize: "0.9rem" }}>{article.title}</div>
<small className="text-muted">{article.category} · {new Date(article.publishedAt).toLocaleDateString("es-CL")}</small> <small className="text-muted">{article.categoryName || "General"} · {article.authorName || "Autor"}</small>
</div> </div>
<i className="bi bi-chevron-right text-muted"></i> <i className="bi bi-chevron-right text-muted"></i>
</div> </div>
+9 -14
View File
@@ -32,20 +32,16 @@ function AppShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="d-flex"> <div className="d-flex">
{/* Sidebar Overlay for mobile */}
<div <div
className={`sidebar-overlay ${sidebarOpen ? "show" : ""}`} className={`sidebar-overlay ${sidebarOpen ? "show" : ""}`}
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
/> />
{/* Sidebar */}
<div className={`sidebar ${sidebarOpen ? "show" : ""}`} style={{ zIndex: 1040 }}> <div className={`sidebar ${sidebarOpen ? "show" : ""}`} style={{ zIndex: 1040 }}>
<Sidebar /> <Sidebar />
</div> </div>
{/* Main Content */}
<div className="main-content flex-grow-1"> <div className="main-content flex-grow-1">
{/* Top Navbar */}
<header className="top-navbar"> <header className="top-navbar">
<button <button
className="btn d-lg-none me-3 p-2" className="btn d-lg-none me-3 p-2"
@@ -73,7 +69,6 @@ function AppShell({ children }: { children: React.ReactNode }) {
</div> </div>
</header> </header>
{/* Page Content */}
<div className="content-wrapper fade-in"> <div className="content-wrapper fade-in">
{children} {children}
</div> </div>
@@ -84,18 +79,18 @@ function AppShell({ children }: { children: React.ReactNode }) {
function TopNavbarUser() { function TopNavbarUser() {
const { user } = useAuth(); const { user } = useAuth();
const initials = user?.nombre
?.split(" ")
.map((n: string) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) || "U";
return ( return (
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<div className="avatar-circle"> <div className="avatar-circle">{initials}</div>
{user?.name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) || "U"}
</div>
<span className="d-none d-md-inline" style={{ fontSize: "0.85rem", fontWeight: 500 }}> <span className="d-none d-md-inline" style={{ fontSize: "0.85rem", fontWeight: 500 }}>
{user?.name || "Usuario"} {user?.nombre || "Usuario"}
</span> </span>
</div> </div>
); );
+37 -140
View File
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import StatsCard from "@/components/StatsCard"; import StatsCard from "@/components/StatsCard";
import BadgeStatus from "@/components/BadgeStatus"; import BadgeStatus from "@/components/BadgeStatus";
import { getDashboardStats, type DashboardStats } from "@/lib/api"; import { getTicketStats, type DashboardStats } from "@/lib/api";
import Link from "next/link"; import Link from "next/link";
export default function DashboardPage() { export default function DashboardPage() {
@@ -11,25 +11,18 @@ export default function DashboardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
getDashboardStats() getTicketStats()
.then(setStats) .then(setStats)
.catch(() => { .catch(() => {
// Set mock data for demo
setStats({ setStats({
totalTickets: 128, totalTickets: 0,
openTickets: 24, openTickets: 0,
pendingTickets: 18, inProgressTickets: 0,
resolvedTickets: 62, resolvedTickets: 0,
closedTickets: 24, closedTickets: 0,
avgResponseTime: "2.4h", unassignedTickets: 0,
ticketsByPriority: { low: 30, medium: 45, high: 38, urgent: 15 }, criticalTickets: 0,
recentTickets: [], dailyCounts: [],
agentWorkload: [
{ agent: "Carlos Méndez", count: 12 },
{ agent: "Ana García", count: 9 },
{ agent: "Roberto Díaz", count: 7 },
{ agent: "María López", count: 5 },
],
}); });
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -43,8 +36,7 @@ export default function DashboardPage() {
); );
} }
const maxPriority = Math.max(...Object.values(stats?.ticketsByPriority || {}), 1); const maxDaily = Math.max(...(stats?.dailyCounts?.map((d) => d.count) || [1]));
const maxWorkload = Math.max(...(stats?.agentWorkload?.map((a) => a.count) || [1]));
return ( return (
<div> <div>
@@ -65,140 +57,45 @@ export default function DashboardPage() {
<StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" /> <StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" />
</div> </div>
<div className="col-6 col-lg"> <div className="col-6 col-lg">
<StatsCard icon="bi-clock-history" value={stats?.pendingTickets || 0} label="Pendientes" color="#ffc107" /> <StatsCard icon="bi-clock-history" value={stats?.inProgressTickets || 0} label="En Progreso" color="#ffc107" />
</div> </div>
<div className="col-6 col-lg"> <div className="col-6 col-lg">
<StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" /> <StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" />
</div> </div>
<div className="col-6 col-lg"> <div className="col-6 col-lg">
<StatsCard icon="bi-speedometer" value={stats?.avgResponseTime || "N/A"} label="Tiempo Respuesta" color="#6f42c1" /> <StatsCard icon="bi-exclamation-triangle" value={stats?.criticalTickets || 0} label="Críticos" color="#dc3545" />
</div>
<div className="col-6 col-lg">
<StatsCard icon="bi-person-dash" value={stats?.unassignedTickets || 0} label="Sin Asignar" color="#6f42c1" />
</div> </div>
</div> </div>
<div className="row g-4"> {/* Daily Tickets Chart */}
{/* Priority Distribution */} {stats?.dailyCounts && stats.dailyCounts.length > 0 && (
<div className="col-lg-6"> <div className="card mb-4">
<div className="card h-100"> <div className="card-header d-flex align-items-center gap-2">
<div className="card-header d-flex align-items-center gap-2"> <i className="bi bi-graph-up" style={{ color: "var(--primary)" }}></i>
<i className="bi bi-bar-chart" style={{ color: "var(--primary)" }}></i> Tickets por Día (últimos 30 días)
Tickets por Prioridad
</div>
<div className="card-body">
{Object.entries(stats?.ticketsByPriority || {}).map(([priority, count]) => {
const labels: Record<string, string> = { low: "Baja", medium: "Media", high: "Alta", urgent: "Urgente" };
const colors: Record<string, string> = { low: "#6c757d", medium: "#0d6efd", high: "#fd7e14", urgent: "#dc3545" };
return (
<div key={priority} className="mb-3">
<div className="d-flex justify-content-between mb-1">
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>{labels[priority] || priority}</span>
<span className="text-muted" style={{ fontSize: "0.85rem" }}>{count}</span>
</div>
<div className="progress" style={{ height: "8px", borderRadius: "4px" }}>
<div
className="progress-bar"
role="progressbar"
style={{
width: `${(count / maxPriority) * 100}%`,
backgroundColor: colors[priority],
borderRadius: "4px",
}}
></div>
</div>
</div>
);
})}
</div>
</div> </div>
</div> <div className="card-body">
<div className="d-flex align-items-end gap-1" style={{ height: "120px" }}>
{/* Agent Workload */} {stats.dailyCounts.map((d) => (
<div className="col-lg-6"> <div
<div className="card h-100"> key={d.date}
<div className="card-header d-flex align-items-center gap-2"> className="flex-fill rounded"
<i className="bi bi-people" style={{ color: "var(--primary)" }}></i> style={{
Carga de Agentes height: `${(d.count / maxDaily) * 100}%`,
</div> backgroundColor: "var(--primary)",
<div className="card-body"> opacity: 0.7,
{stats?.agentWorkload?.map((item) => ( minHeight: "4px",
<div key={item.agent} className="mb-3"> }}
<div className="d-flex justify-content-between mb-1"> title={`${d.date}: ${d.count} tickets`}
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>{item.agent}</span> ></div>
<span className="text-muted" style={{ fontSize: "0.85rem" }}>{item.count} tickets</span>
</div>
<div className="progress" style={{ height: "8px", borderRadius: "4px" }}>
<div
className="progress-bar"
role="progressbar"
style={{
width: `${(item.count / maxWorkload) * 100}%`,
background: "var(--primary-gradient)",
borderRadius: "4px",
}}
></div>
</div>
</div>
))} ))}
{(!stats?.agentWorkload || stats.agentWorkload.length === 0) && (
<div className="text-center text-muted py-4">
<i className="bi bi-people fs-1 mb-2 d-block" style={{ opacity: 0.3 }}></i>
Sin datos de agentes disponibles
</div>
)}
</div> </div>
</div> </div>
</div> </div>
</div> )}
{/* Recent Tickets */}
<div className="card mt-4">
<div className="card-header d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center gap-2">
<i className="bi bi-clock-history" style={{ color: "var(--primary)" }}></i>
Tickets Recientes
</div>
<Link href="/tickets" className="btn btn-sm btn-outline-primary">
Ver todos
</Link>
</div>
<div className="table-responsive">
<table className="table table-modern mb-0">
<thead>
<tr>
<th>#</th>
<th>Asunto</th>
<th>Solicitante</th>
<th>Estado</th>
<th>Prioridad</th>
<th>Actualizado</th>
</tr>
</thead>
<tbody>
{stats?.recentTickets?.length ? (
stats.recentTickets.map((ticket) => (
<tr key={ticket.id} onClick={() => window.location.href = `/tickets/${ticket.id}`}>
<td className="fw-semibold" style={{ color: "var(--primary)" }}>
{ticket.ticketNumber || `#${ticket.id}`}
</td>
<td>{ticket.subject}</td>
<td>{ticket.requester?.name || "N/A"}</td>
<td><BadgeStatus status={ticket.status} /></td>
<td><span className={`badge-priority badge-${ticket.priority}`}>{ticket.priority}</span></td>
<td className="text-muted">{new Date(ticket.updatedAt).toLocaleDateString("es-CL")}</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="text-center text-muted py-4">
<i className="bi bi-inbox fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
No hay tickets recientes
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div> </div>
); );
} }
+65 -118
View File
@@ -1,21 +1,26 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams } from "next/navigation";
import { getTicket, addComment, updateTicketStatus, assignTicket, getAgents, type Ticket, type User } from "@/lib/api"; import {
getTicket,
getComments,
addComment,
updateTicketStatus,
assignTicket,
type Ticket,
type Comment,
} from "@/lib/api";
import BadgeStatus from "@/components/BadgeStatus"; import BadgeStatus from "@/components/BadgeStatus";
import BadgePriority from "@/components/BadgePriority"; import BadgePriority from "@/components/BadgePriority";
import { useAuth } from "@/lib/auth";
import Link from "next/link"; import Link from "next/link";
export default function TicketDetailPage() { export default function TicketDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const id = Number(params.id);
const { user } = useAuth();
const id = params.id as string;
const [ticket, setTicket] = useState<Ticket | null>(null); const [ticket, setTicket] = useState<Ticket | null>(null);
const [agents, setAgents] = useState<User[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [isInternal, setIsInternal] = useState(false); const [isInternal, setIsInternal] = useState(false);
@@ -23,33 +28,14 @@ export default function TicketDetailPage() {
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
getTicket(id).catch(() => ({ getTicket(id).catch(() => null),
id: parseInt(id), getComments(id).catch(() => []),
ticketNumber: "#TK-" + id.padStart(3, "0"), ])
subject: "No puedo acceder al sistema de correo electrónico desde esta mañana", .then(([ticketData, commentsData]) => {
description: "Buenos días, desde esta mañana no puedo acceder a mi correo electrónico. Ya intenté reiniciar la computadora pero el problema persiste. Necesito acceso urgente para revisar correos importantes del proyecto.", setTicket(ticketData);
status: "open", setComments(commentsData);
priority: "high", })
category: "Software", .finally(() => setLoading(false));
requesterId: 1,
groupId: null,
requester: { id: 1, name: "María García", email: "maria@test.com", role: "customer" as const },
assigneeId: 2,
assignee: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const },
group: { id: 1, name: "Soporte TI" },
comments: [
{ id: 1, ticketId: parseInt(id), userId: 2, user: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const }, content: "Estoy revisando el problema. Verificaré la configuración de su cuenta de correo.", isInternal: false, createdAt: "2026-09-07T10:30:00Z" },
{ id: 2, ticketId: parseInt(id), userId: 1, user: { id: 1, name: "María García", email: "maria@test.com", role: "customer" as const }, content: "Gracias, estoy esperando. Necesito acceso lo antes posible.", isInternal: false, createdAt: "2026-09-07T11:15:00Z" },
{ id: 3, ticketId: parseInt(id), userId: 2, user: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const }, content: "Nota interna: Verificar con el equipo de infra si hay problemas con el servidor de Exchange.", isInternal: true, createdAt: "2026-09-07T11:30:00Z" },
],
createdAt: "2026-09-07T09:00:00Z",
updatedAt: "2026-09-07T11:30:00Z",
} as Ticket)),
getAgents().catch(() => []),
]).then(([ticketData, agentsData]) => {
setTicket(ticketData);
setAgents(agentsData);
}).finally(() => setLoading(false));
}, [id]); }, [id]);
const handleAddComment = async (e: React.FormEvent) => { const handleAddComment = async (e: React.FormEvent) => {
@@ -57,29 +43,13 @@ export default function TicketDetailPage() {
if (!comment.trim()) return; if (!comment.trim()) return;
setSubmitting(true); setSubmitting(true);
try { try {
const newComment = await addComment(id, comment, isInternal); await addComment(id, comment, isInternal);
setTicket((prev) => { const updated = await getComments(id).catch(() => []);
if (!prev) return prev; setComments(updated);
return { ...prev, comments: [...(prev.comments || []), newComment] };
});
setComment(""); setComment("");
setIsInternal(false); setIsInternal(false);
} catch { } catch {
// Add locally for demo // ignore
if (ticket) {
const localComment = {
id: Date.now(),
ticketId: parseInt(id),
userId: user?.id || 0,
user: user || { id: 0, name: "Usuario", email: "", role: "agent" as const },
content: comment,
isInternal,
createdAt: new Date().toISOString(),
};
setTicket({ ...ticket, comments: [...(ticket.comments || []), localComment] });
setComment("");
setIsInternal(false);
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -87,19 +57,8 @@ export default function TicketDetailPage() {
const handleStatusChange = async (newStatus: string) => { const handleStatusChange = async (newStatus: string) => {
try { try {
await updateTicketStatus(id, parseInt(newStatus)); await updateTicketStatus(id, newStatus);
if (ticket) setTicket({ ...ticket, status: newStatus }); setTicket((prev) => (prev ? { ...prev, status: newStatus } : prev));
} catch {
if (ticket) setTicket({ ...ticket, status: newStatus });
}
};
const handleAssign = async (agentId: string) => {
if (!agentId) return;
try {
await assignTicket(id, parseInt(agentId));
const agent = agents.find((a) => a.id === parseInt(agentId));
if (ticket && agent) setTicket({ ...ticket, assigneeId: agent.id, assignee: agent });
} catch { } catch {
// ignore // ignore
} }
@@ -132,9 +91,9 @@ export default function TicketDetailPage() {
<Link href="/tickets" className="text-decoration-none me-2"> <Link href="/tickets" className="text-decoration-none me-2">
<i className="bi bi-arrow-left"></i> <i className="bi bi-arrow-left"></i>
</Link> </Link>
{ticket.ticketNumber || `#${ticket.id}`} {ticket.ticketNumber}
</div> </div>
<h3 className="ticket-subject">{ticket.subject}</h3> <h3 className="ticket-subject">{ticket.title}</h3>
<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} />
@@ -148,11 +107,11 @@ export default function TicketDetailPage() {
</button> </button>
<ul className="dropdown-menu"> <ul className="dropdown-menu">
{[ {[
{ value: "open", label: "Abierto" }, { value: "Open", label: "Abierto" },
{ value: "pending", label: "Pendiente" }, { value: "Pending", label: "Pendiente" },
{ value: "in_progress", label: "En Progreso" }, { value: "InProgress", label: "En Progreso" },
{ value: "resolved", label: "Resuelto" }, { value: "Resolved", label: "Resuelto" },
{ value: "closed", label: "Cerrado" }, { value: "Closed", label: "Cerrado" },
].map((s) => ( ].map((s) => (
<li key={s.value}> <li key={s.value}>
<button className="dropdown-item" onClick={() => handleStatusChange(s.value)}> <button className="dropdown-item" onClick={() => handleStatusChange(s.value)}>
@@ -169,13 +128,11 @@ export default function TicketDetailPage() {
<i className="bi bi-person-plus me-1"></i>Asignar <i className="bi bi-person-plus me-1"></i>Asignar
</button> </button>
<ul className="dropdown-menu"> <ul className="dropdown-menu">
{agents.map((a) => ( <li>
<li key={a.id}> <button className="dropdown-item" onClick={() => assignTicket(id, 0)}>
<button className="dropdown-item" onClick={() => handleAssign(String(a.id))}> Sin asignar
{a.name} </button>
</button> </li>
</li>
))}
</ul> </ul>
</div> </div>
</div> </div>
@@ -187,36 +144,31 @@ export default function TicketDetailPage() {
<div className="card mb-4"> <div className="card mb-4">
<div className="card-header d-flex align-items-center gap-2"> <div className="card-header d-flex align-items-center gap-2">
<i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i> <i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i>
Comentarios ({ticket.comments?.length || 0}) Comentarios ({comments.length})
</div> </div>
<div className="card-body" style={{ maxHeight: "500px", overflowY: "auto" }}> <div className="card-body" style={{ maxHeight: "500px", overflowY: "auto" }}>
{ticket.comments?.length ? ( {comments.length > 0 ? (
ticket.comments.map((c) => { comments.map((c) => (
const isAgent = c.user?.role === "agent" || c.user?.role === "admin"; <div
return ( key={c.id}
<div className={`comment comment-agent ${c.isInternal ? "comment-internal" : ""}`}
key={c.id} >
className={`comment ${isAgent ? "comment-agent" : "comment-customer"} ${ <div className="comment-bubble">
c.isInternal ? "comment-internal" : "" <div className="comment-author">
}`} {c.userName || "Usuario"}
> {c.isInternal && (
<div className="comment-bubble"> <span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
<div className="comment-author"> Nota Interna
{c.user?.name || "Usuario"} </span>
{c.isInternal && ( )}
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}> </div>
Nota Interna <div className="comment-text">{c.content}</div>
</span> <div className="comment-time">
)} {new Date(c.createdAt).toLocaleString("es-CL")}
</div>
<div className="comment-text">{c.content}</div>
<div className="comment-time">
{new Date(c.createdAt).toLocaleString("es-CL")}
</div>
</div> </div>
</div> </div>
); </div>
}) ))
) : ( ) : (
<div className="text-center text-muted py-4"> <div className="text-center text-muted py-4">
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i> <i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
@@ -250,7 +202,7 @@ export default function TicketDetailPage() {
/> />
<label className="form-check-label" htmlFor="internalCheck" style={{ fontSize: "0.85rem" }}> <label className="form-check-label" htmlFor="internalCheck" style={{ fontSize: "0.85rem" }}>
<i className="bi bi-eye-slash me-1"></i> <i className="bi bi-eye-slash me-1"></i>
Nota interna (visible solo para agentes) Nota interna (solo agentes)
</label> </label>
</div> </div>
<button <button
@@ -262,8 +214,7 @@ export default function TicketDetailPage() {
<span className="spinner-border spinner-border-sm" role="status"></span> <span className="spinner-border spinner-border-sm" role="status"></span>
) : ( ) : (
<> <>
<i className="bi bi-send me-2"></i> <i className="bi bi-send me-2"></i>Enviar
Enviar
</> </>
)} )}
</button> </button>
@@ -278,23 +229,19 @@ export default function TicketDetailPage() {
<h6>Información del Ticket</h6> <h6>Información del Ticket</h6>
<div className="ticket-info-row"> <div className="ticket-info-row">
<span className="label">Solicitante</span> <span className="label">Solicitante</span>
<span className="value">{ticket.requester?.name || "N/A"}</span> <span className="value">{ticket.requesterName || "N/A"}</span>
</div> </div>
<div className="ticket-info-row"> <div className="ticket-info-row">
<span className="label">Email</span> <span className="label">Email</span>
<span className="value" style={{ fontSize: "0.8rem" }}>{ticket.requester?.email || "N/A"}</span> <span className="value" style={{ fontSize: "0.8rem" }}>{ticket.requesterEmail || "N/A"}</span>
</div> </div>
<div className="ticket-info-row"> <div className="ticket-info-row">
<span className="label">Asignado a</span> <span className="label">Asignado a</span>
<span className="value">{ticket.assignee?.name || "Sin asignar"}</span> <span className="value">{ticket.assigneeName || "Sin asignar"}</span>
</div> </div>
<div className="ticket-info-row"> <div className="ticket-info-row">
<span className="label">Grupo</span> <span className="label">Grupo</span>
<span className="value">{ticket.group?.name || "N/A"}</span> <span className="value">{ticket.groupName || "N/A"}</span>
</div>
<div className="ticket-info-row">
<span className="label">Categoría</span>
<span className="value">{ticket.category || "N/A"}</span>
</div> </div>
<div className="ticket-info-row"> <div className="ticket-info-row">
<span className="label">Creado</span> <span className="label">Creado</span>
+21 -71
View File
@@ -1,42 +1,26 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { createTicket, getAgents, type User } from "@/lib/api"; import { createTicket } from "@/lib/api";
import Link from "next/link"; import Link from "next/link";
export default function NewTicketPage() { export default function NewTicketPage() {
const router = useRouter(); const router = useRouter();
const [agents, setAgents] = useState<User[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [subject, setSubject] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [priority, setPriority] = useState("medium"); const [priority, setPriority] = useState("Medium");
const [category, setCategory] = useState("");
const [assigneeId, setAssigneeId] = useState("");
useEffect(() => {
getAgents().catch((): User[] => [
{ id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const },
{ id: 4, name: "Ana García", email: "ana@test.com", role: "agent" as const },
]).then((data) => setAgents(data));
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
const ticket = await createTicket({ const result = await createTicket({ title, description, priority });
subject, router.push(`/tickets/${result.id}`);
description,
priority,
category,
assigneeId: assigneeId ? parseInt(assigneeId) : undefined,
});
router.push(`/tickets/${ticket.id}`);
} catch (err: any) { } catch (err: any) {
setError(err.message || "Error al crear el ticket"); setError(err.message || "Error al crear el ticket");
} finally { } finally {
@@ -73,8 +57,8 @@ export default function NewTicketPage() {
type="text" type="text"
className="form-control" className="form-control"
placeholder="Describe brevemente el problema o solicitud" placeholder="Describe brevemente el problema o solicitud"
value={subject} value={title}
onChange={(e) => setSubject(e.target.value)} onChange={(e) => setTitle(e.target.value)}
required required
autoFocus autoFocus
/> />
@@ -92,52 +76,19 @@ export default function NewTicketPage() {
></textarea> ></textarea>
</div> </div>
<div className="row g-3 mb-4"> <div className="mb-4">
<div className="col-md-4"> <label className="form-label fw-semibold">Prioridad</label>
<label className="form-label fw-semibold">Prioridad</label> <select
<select className="form-select"
className="form-select" style={{ maxWidth: "200px" }}
value={priority} value={priority}
onChange={(e) => setPriority(e.target.value)} onChange={(e) => setPriority(e.target.value)}
> >
<option value="low">Baja</option> <option value="Low">Baja</option>
<option value="medium">Media</option> <option value="Medium">Media</option>
<option value="high">Alta</option> <option value="High">Alta</option>
<option value="urgent">Urgente</option> <option value="Critical">Crítica</option>
</select> </select>
</div>
<div className="col-md-4">
<label className="form-label fw-semibold">Categoría</label>
<select
className="form-select"
value={category}
onChange={(e) => setCategory(e.target.value)}
required
>
<option value="">Seleccionar categoría</option>
<option value="Software">Software</option>
<option value="Hardware">Hardware</option>
<option value="Red">Red</option>
<option value="Acceso">Acceso</option>
<option value="Correo">Correo</option>
<option value="Otro">Otro</option>
</select>
</div>
<div className="col-md-4">
<label className="form-label fw-semibold">Asignar a</label>
<select
className="form-select"
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
>
<option value="">Sin asignar</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
</div> </div>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
@@ -179,7 +130,6 @@ export default function NewTicketPage() {
<li>Sé específico en el asunto del ticket</li> <li>Sé específico en el asunto del ticket</li>
<li>Incluye pasos para reproducir el problema</li> <li>Incluye pasos para reproducir el problema</li>
<li>Adjunta capturas de pantalla si es posible</li> <li>Adjunta capturas de pantalla si es posible</li>
<li>Selecciona la categoría correcta para una atención más rápida</li>
<li>Asigna la prioridad adecuada según el impacto</li> <li>Asigna la prioridad adecuada según el impacto</li>
</ul> </ul>
</div> </div>
+15 -21
View File
@@ -29,16 +29,11 @@ export default function TicketsPage() {
page, page,
pageSize, pageSize,
}); });
setTickets(data.tickets || []); setTickets(data.items || []);
setTotal(data.total || 0); setTotal(data.total || 0);
} catch { } catch {
setTickets([ setTickets([]);
{ id: 1, ticketNumber: "#TK-001", subject: "No puedo acceder a mi correo", description: "", status: "open", priority: "high", category: "Software", requesterId: 1, groupId: null, requester: { id: 1, name: "María García", email: "maria@test.com", role: "customer" }, assigneeId: 2, assignee: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" }, createdAt: "2026-09-01", updatedAt: "2026-09-07" }, setTotal(0);
{ id: 2, ticketNumber: "#TK-002", subject: "Impresora del piso 3 no funciona", description: "", status: "pending", priority: "medium", category: "Hardware", requesterId: 3, groupId: null, requester: { id: 3, name: "Pedro Soto", email: "pedro@test.com", role: "customer" }, assigneeId: 4, assignee: { id: 4, name: "Ana García", email: "ana@test.com", role: "agent" }, createdAt: "2026-09-05", updatedAt: "2026-09-07" },
{ id: 3, ticketNumber: "#TK-003", subject: "Solicitud de acceso al sistema contable", description: "", status: "resolved", priority: "low", category: "Acceso", requesterId: 5, groupId: null, requester: { id: 5, name: "Laura Fuentes", email: "laura@test.com", role: "customer" }, assigneeId: null, createdAt: "2026-09-03", updatedAt: "2026-09-06" },
{ id: 4, ticketNumber: "#TK-004", subject: "Laptop con pantalla parpadeante", description: "", status: "in_progress", priority: "urgent", category: "Hardware", requesterId: 6, groupId: null, requester: { id: 6, name: "Roberto Díaz", email: "roberto@test.com", role: "customer" }, assigneeId: 2, assignee: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" }, createdAt: "2026-09-08", updatedAt: "2026-09-08" },
]);
setTotal(4);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -74,11 +69,11 @@ export default function TicketsPage() {
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }} onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
> >
<option value="">Todos los estados</option> <option value="">Todos los estados</option>
<option value="open">Abierto</option> <option value="Open">Abierto</option>
<option value="pending">Pendiente</option> <option value="Pending">Pendiente</option>
<option value="in_progress">En Progreso</option> <option value="InProgress">En Progreso</option>
<option value="resolved">Resuelto</option> <option value="Resolved">Resuelto</option>
<option value="closed">Cerrado</option> <option value="Closed">Cerrado</option>
</select> </select>
<select <select
@@ -88,10 +83,10 @@ export default function TicketsPage() {
onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); }} onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); }}
> >
<option value="">Todas las prioridades</option> <option value="">Todas las prioridades</option>
<option value="low">Baja</option> <option value="Low">Baja</option>
<option value="medium">Media</option> <option value="Medium">Media</option>
<option value="high">Alta</option> <option value="High">Alta</option>
<option value="urgent">Urgente</option> <option value="Critical">Crítica</option>
</select> </select>
<div className="position-relative flex-grow-1" style={{ maxWidth: "280px" }}> <div className="position-relative flex-grow-1" style={{ maxWidth: "280px" }}>
@@ -147,13 +142,12 @@ export default function TicketsPage() {
{ticket.ticketNumber || `#${ticket.id}`} {ticket.ticketNumber || `#${ticket.id}`}
</td> </td>
<td> <td>
<div className="fw-medium">{ticket.subject}</div> <div className="fw-medium">{ticket.title}</div>
<small className="text-muted">{ticket.category}</small>
</td> </td>
<td>{ticket.requester?.name || "N/A"}</td> <td>{ticket.requesterName || "N/A"}</td>
<td><BadgeStatus status={ticket.status} /></td> <td><BadgeStatus status={ticket.status} /></td>
<td><BadgePriority priority={ticket.priority} /></td> <td><BadgePriority priority={ticket.priority} /></td>
<td>{ticket.assignee?.name || <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" }}>
{new Date(ticket.updatedAt).toLocaleDateString("es-CL")} {new Date(ticket.updatedAt).toLocaleDateString("es-CL")}
</td> </td>
+7 -17
View File
@@ -2,11 +2,10 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(""); const [rut, setRut] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -18,7 +17,7 @@ export default function LoginPage() {
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
await login(email, password); await login(rut, password);
router.push("/"); router.push("/");
} catch (err: any) { } catch (err: any) {
setError(err.message || "Credenciales inválidas"); setError(err.message || "Credenciales inválidas");
@@ -75,13 +74,13 @@ export default function LoginPage() {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="mb-3"> <div className="mb-3">
<label className="form-label">Correo Electrónico</label> <label className="form-label">RUT</label>
<input <input
type="email" type="text"
className="form-control" className="form-control"
placeholder="tu@email.com" placeholder="12.345.678-9"
value={email} value={rut}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setRut(e.target.value)}
required required
autoFocus autoFocus
/> />
@@ -117,15 +116,6 @@ export default function LoginPage() {
)} )}
</button> </button>
</form> </form>
<div className="text-center mt-4">
<span className="text-muted" style={{ fontSize: "0.9rem" }}>
¿No tienes cuenta?{" "}
<Link href="/register" style={{ color: "var(--primary)", fontWeight: 500 }}>
Regístrate aquí
</Link>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
-169
View File
@@ -1,169 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { register as apiRegister } from "@/lib/api";
import { useAuth } from "@/lib/auth";
export default function RegisterPage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("Las contraseñas no coinciden");
return;
}
setLoading(true);
try {
await apiRegister({ name, email, password });
await login(email, password);
router.push("/");
} catch (err: any) {
setError(err.message || "Error al registrar usuario");
} finally {
setLoading(false);
}
};
return (
<div className="auth-page d-flex">
{/* Left Panel */}
<div className="auth-panel-left d-none d-md-flex col-md-5">
<div>
<i className="bi bi-headset display-1 mb-3"></i>
<h2>HelpDesk ICHN</h2>
<p>
Crea tu cuenta para comenzar a gestionar tickets de soporte técnico
y acceder a la base de conocimiento.
</p>
<div className="mt-4">
<div className="d-flex align-items-center gap-2 mb-3">
<i className="bi bi-check-circle-fill"></i>
<span>Acceso completo al sistema</span>
</div>
<div className="d-flex align-items-center gap-2 mb-3">
<i className="bi bi-check-circle-fill"></i>
<span>Crear y responder tickets</span>
</div>
<div className="d-flex align-items-center gap-2 mb-3">
<i className="bi bi-check-circle-fill"></i>
<span>Buscar en la base de conocimiento</span>
</div>
</div>
</div>
</div>
{/* Right Panel - Form */}
<div className="auth-panel-right col-md-7 col-12">
<div className="auth-form">
<div className="d-md-none text-center mb-4">
<i className="bi bi-headset display-4" style={{ color: "var(--primary)" }}></i>
<h4 className="mt-2" style={{ color: "var(--primary)" }}>HelpDesk ICHN</h4>
</div>
<h3>Crear Cuenta</h3>
<p className="subtitle">Regístrate para acceder al sistema de soporte</p>
{error && (
<div className="alert alert-danger py-2" role="alert">
<i className="bi bi-exclamation-circle me-2"></i>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label">Nombre Completo</label>
<input
type="text"
className="form-control"
placeholder="Juan Pérez"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
/>
</div>
<div className="mb-3">
<label className="form-label">Correo Electrónico</label>
<input
type="email"
className="form-control"
placeholder="tu@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="mb-3">
<label className="form-label">Contraseña</label>
<input
type="password"
className="form-control"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="mb-4">
<label className="form-label">Confirmar Contraseña</label>
<input
type="password"
className="form-control"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
/>
</div>
<button
type="submit"
className="btn btn-primary w-100 py-2"
disabled={loading}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Creando cuenta...
</>
) : (
<>
<i className="bi bi-person-plus me-2"></i>
Crear Cuenta
</>
)}
</button>
</form>
<div className="text-center mt-4">
<span className="text-muted" style={{ fontSize: "0.9rem" }}>
¿Ya tienes cuenta?{" "}
<Link href="/login" style={{ color: "var(--primary)", fontWeight: 500 }}>
Inicia sesión
</Link>
</span>
</div>
</div>
</div>
</div>
);
}
+10 -12
View File
@@ -12,10 +12,15 @@ export default function Sidebar() {
{ href: "/", icon: "bi-speedometer2", label: "Dashboard" }, { href: "/", icon: "bi-speedometer2", label: "Dashboard" },
{ href: "/tickets", icon: "bi-ticket-detailed", label: "Tickets" }, { href: "/tickets", icon: "bi-ticket-detailed", label: "Tickets" },
{ href: "/knowledge-base", icon: "bi-journal-bookmark", label: "Knowledge Base" }, { href: "/knowledge-base", icon: "bi-journal-bookmark", label: "Knowledge Base" },
{ href: "/users", icon: "bi-people", label: "Users" },
{ href: "/settings", icon: "bi-gear", label: "Settings" },
]; ];
const initials = user?.nombre
?.split(" ")
.map((n: string) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) || "U";
return ( return (
<aside className="sidebar" id="sidebar"> <aside className="sidebar" id="sidebar">
<div className="sidebar-brand"> <div className="sidebar-brand">
@@ -42,17 +47,10 @@ export default function Sidebar() {
</nav> </nav>
<div className="sidebar-user"> <div className="sidebar-user">
<div className="avatar"> <div className="avatar">{initials}</div>
{user?.name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) || "U"}
</div>
<div className="sidebar-user-info"> <div className="sidebar-user-info">
<div className="name">{user?.name || "Usuario"}</div> <div className="name">{user?.nombre || "Usuario"}</div>
<div className="role">{user?.role || "Agente"}</div> <div className="role">{user?.perfil || "Agente"}</div>
</div> </div>
<button <button
onClick={logout} onClick={logout}
+107 -107
View File
@@ -16,10 +16,7 @@ async function fetchApi<T>(
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
...options,
headers,
});
if (!res.ok) { if (!res.ok) {
const errorData = await res.json().catch(() => ({})); const errorData = await res.json().catch(() => ({}));
@@ -30,36 +27,25 @@ async function fetchApi<T>(
return res.json(); return res.json();
} }
// Auth // ==================== Auth ====================
export async function login(email: string, password: string) { // SAM uses RUT-based auth — adapt as needed
return fetchApi<{ token: string; user: User }>("/api/auth/login", { export async function login(rut: string, password: string) {
method: "POST", return fetchApi<{ success: boolean; message?: string; token?: string; user?: User }>(
body: JSON.stringify({ email, password }), "/api/auth/login",
}); { method: "POST", body: JSON.stringify({ rut, password }) }
} );
export async function register(data: {
name: string;
email: string;
password: string;
role?: string;
}) {
return fetchApi<{ token: string; user: User }>("/api/auth/register", {
method: "POST",
body: JSON.stringify(data),
});
} }
export async function getMe() { export async function getMe() {
return fetchApi<User>("/api/auth/me"); return fetchApi<User>("/api/auth/me");
} }
// Tickets // ==================== Tickets ====================
export async function getTickets(params?: { export async function getTickets(params?: {
status?: string; status?: string;
priority?: string; priority?: string;
assigneeId?: string; assigneeId?: number;
requesterId?: string; groupId?: number;
search?: string; search?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
@@ -67,138 +53,151 @@ export async function getTickets(params?: {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params) { if (params) {
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") query.set(key, String(value)); if (value !== undefined && value !== "" && value !== null)
query.set(key, String(value));
}); });
} }
return fetchApi<{ tickets: Ticket[]; total: number; page: number; pageSize: number }>( return fetchApi<TicketListResult>(`/api/helpdesk/tickets?${query.toString()}`);
`/api/tickets?${query.toString()}`
);
} }
export async function getTicket(id: string) { export async function getTicket(id: number) {
return fetchApi<Ticket>(`/api/tickets/${id}`); return fetchApi<Ticket>(`/api/helpdesk/ticket/${id}`);
} }
export async function createTicket(data: { export async function createTicket(data: {
subject: string; title: string;
description: string; description: string;
priority?: string; priority?: string;
category?: string;
assigneeId?: number; assigneeId?: number;
groupId?: number;
slaPolicyId?: number;
}) { }) {
return fetchApi<Ticket>("/api/tickets", { return fetchApi<{ id: number }>("/api/helpdesk/tickets", {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
export async function updateTicket(id: string, data: Partial<Ticket>) { export async function updateTicket(id: number, data: {
return fetchApi<Ticket>(`/api/tickets/${id}`, { title?: string;
description?: string;
priority?: string;
assigneeId?: number;
groupId?: number;
}) {
return fetchApi<void>(`/api/helpdesk/ticket/${id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
export async function deleteTicket(id: string) { export async function deleteTicket(id: number) {
return fetchApi<void>(`/api/tickets/${id}`, { return fetchApi<void>(`/api/helpdesk/ticket/${id}`, { method: "DELETE" });
method: "DELETE",
});
} }
export async function updateTicketStatus(id: string, status: number) { export async function updateTicketStatus(id: number, status: string) {
return fetchApi<Ticket>(`/api/tickets/${id}/status`, { return fetchApi<void>(`/api/helpdesk/ticket/${id}/status`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ status }), body: JSON.stringify({ status }),
}); });
} }
export async function assignTicket(id: string, assigneeId: number) { export async function assignTicket(id: number, assigneeId: number, groupId?: number) {
return fetchApi<Ticket>(`/api/tickets/${id}/assign`, { return fetchApi<void>(`/api/helpdesk/ticket/${id}/assign`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ assigneeId }), body: JSON.stringify({ assigneeId, groupId }),
}); });
} }
export async function getTicketStats() {
return fetchApi<DashboardStats>("/api/helpdesk/stats");
}
// ==================== Comments ====================
export async function getComments(ticketId: number) {
return fetchApi<Comment[]>(`/api/helpdesk/comments/${ticketId}`);
}
export async function addComment( export async function addComment(
ticketId: string, ticketId: number,
content: string, content: string,
isInternal: boolean = false isInternal: boolean = false
) { ) {
return fetchApi<Comment>(`/api/tickets/${ticketId}/comments`, { return fetchApi<{ id: number }>(`/api/helpdesk/comments/${ticketId}`, {
method: "POST", method: "POST",
body: JSON.stringify({ content, isInternal }), body: JSON.stringify({ content, isInternal }),
}); });
} }
export async function getTicketStats() { // ==================== Knowledge Base ====================
return fetchApi<TicketStats>("/api/tickets/stats");
}
// Users
export async function getUsers() {
return fetchApi<User[]>("/api/users");
}
export async function getAgents() {
return fetchApi<User[]>("/api/users/agents");
}
// Knowledge Base
export async function getKBArticles() { export async function getKBArticles() {
return fetchApi<KBArticle[]>("/api/kb/articles/published"); return fetchApi<KBArticle[]>("/api/helpdesk/kb/published");
} }
export async function searchKB(query: string) { export async function searchKB(query: string) {
return fetchApi<KBArticle[]>(`/api/kb/search?q=${encodeURIComponent(query)}`); return fetchApi<KBArticle[]>(`/api/helpdesk/kb/search?q=${encodeURIComponent(query)}`);
} }
// Dashboard export async function getKBCategories() {
export async function getDashboardStats() { return fetchApi<KBCategory[]>("/api/helpdesk/kb/categories");
return fetchApi<DashboardStats>("/api/dashboard/stats");
} }
// Groups & SLA // ==================== Groups & SLA ====================
export async function getGroups() { export async function getGroups() {
return fetchApi<Group[]>("/api/groups"); return fetchApi<Group[]>("/api/helpdesk/groups");
} }
export async function getSLAPolicies() { export async function getSLAPolicies() {
return fetchApi<SLAPolicy[]>("/api/sla"); return fetchApi<SLAPolicy[]>("/api/helpdesk/sla");
} }
// Types // ==================== Types ====================
export interface User { export interface User {
id: number; usuarioId: number;
name: string; nombre: string;
apPaterno: string;
apMaterno?: string;
email: string; email: string;
role: "admin" | "agent" | "customer"; perfil: string;
createdAt?: string; sedeId?: number;
telefono?: string;
} }
export interface Ticket { export interface Ticket {
id: number; id: number;
ticketNumber: string; title: string;
subject: string;
description: string; description: string;
status: string; status: string;
priority: string; priority: string;
category: string; ticketNumber: string;
requesterId: number; requesterId: number;
requester?: User; requesterName?: string;
assigneeId: number | null; requesterEmail?: string;
assignee?: User; assigneeId?: number;
groupId: number | null; assigneeName?: string;
group?: Group; groupId?: number;
comments?: Comment[]; groupName?: string;
slaPolicyId?: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
firstResponseAt?: string;
resolvedAt?: string;
closedAt?: string;
}
export interface TicketListResult {
items: Ticket[];
total: number;
page: number;
pageSize: number;
totalPages: number;
} }
export interface Comment { export interface Comment {
id: number; id: number;
ticketId: number; ticketId: number;
userId: number; userId: number;
user?: User; userName?: string;
content: string; content: string;
isInternal: boolean; isInternal: boolean;
createdAt: string; createdAt: string;
@@ -208,45 +207,46 @@ export interface Group {
id: number; id: number;
name: string; name: string;
description?: string; description?: string;
isActive: boolean;
} }
export interface KBArticle { export interface KBArticle {
id: number; id: number;
title: string; title: string;
content: string; content: string;
category: string; categoryId: number;
author?: User; categoryName?: string;
publishedAt: string; authorId: number;
authorName?: string;
isPublished: boolean;
viewCount: number;
createdAt: string;
updatedAt: string;
}
export interface KBCategory {
id: number;
name: string;
description?: string;
sortOrder: number;
} }
export interface SLAPolicy { export interface SLAPolicy {
id: number; id: number;
name: string; name: string;
priority: string; description?: string;
responseTime: string; responseTimeHours: number;
resolutionTime: string; resolutionTimeHours: number;
} isActive: boolean;
export interface TicketStats {
total: number;
open: number;
pending: number;
resolved: number;
closed: number;
inProgress: number;
avgResponseTime: string;
byPriority: Record<string, number>;
byCategory: Record<string, number>;
} }
export interface DashboardStats { export interface DashboardStats {
totalTickets: number; totalTickets: number;
openTickets: number; openTickets: number;
pendingTickets: number; inProgressTickets: number;
resolvedTickets: number; resolvedTickets: number;
closedTickets: number; closedTickets: number;
avgResponseTime: string; unassignedTickets: number;
ticketsByPriority: Record<string, number>; criticalTickets: number;
recentTickets: Ticket[]; dailyCounts: { date: string; count: number }[];
agentWorkload: { agent: string; count: number }[];
} }
+10 -11
View File
@@ -5,7 +5,7 @@ import { getMe, login as apiLogin, type User } from "./api";
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
login: (email: string, password: string) => Promise<void>; login: (rut: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
@@ -27,22 +27,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem("helpdesk_token"); const token = localStorage.getItem("helpdesk_token");
if (token) { if (token) {
getMe() getMe()
.then((u) => { .then((u) => setUser(u))
setUser(u); .catch(() => localStorage.removeItem("helpdesk_token"))
})
.catch(() => {
localStorage.removeItem("helpdesk_token");
})
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
} else { } else {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, []);
const login = useCallback(async (email: string, password: string) => { const login = useCallback(async (rut: string, password: string) => {
const { token, user } = await apiLogin(email, password); const result = await apiLogin(rut, password);
localStorage.setItem("helpdesk_token", token); if (!result.success || !result.token) {
setUser(user); throw new Error(result.message || "Credenciales inválidas");
}
localStorage.setItem("helpdesk_token", result.token);
if (result.user) setUser(result.user);
}, []); }, []);
const logout = useCallback(() => { const logout = useCallback(() => {