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:
1 parent
0cca57f21e
commit
f56f6365d2
11 files changed
+285
-718
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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(() => {
|
||||||
|
|||||||
Reference in new issue
Block a user