Initial commit: HelpDesk ICHN - .NET 10 backend + Next.js 16 frontend
This commit is contained in:
commit
359f0caf85
90 files changed
+13395
No files matched your search
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getKBArticles, searchKB, type KBArticle } from "@/lib/api";
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const [articles, setArticles] = useState<KBArticle[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getKBArticles()
|
||||
.then(setArticles)
|
||||
.catch(() => {
|
||||
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));
|
||||
}, []);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await searchKB(searchQuery);
|
||||
setArticles(results);
|
||||
} catch {
|
||||
// Keep existing articles on error
|
||||
} finally {
|
||||
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 (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Base de Conocimiento</h1>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-white border-end-0">
|
||||
<i className="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control border-start-0"
|
||||
placeholder="Buscar artículos, guías, tutoriales..."
|
||||
style={{ fontSize: "1rem", padding: "0.75rem 1rem" }}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<button className="btn btn-primary px-4" onClick={handleSearch}>
|
||||
Buscar
|
||||
</button>
|
||||
</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 */}
|
||||
<div className="card">
|
||||
<div className="card-header d-flex align-items-center gap-2">
|
||||
<i className="bi bi-journal-text" style={{ color: "var(--primary)" }}></i>
|
||||
Artículos Recientes
|
||||
</div>
|
||||
<div className="card-body p-0">
|
||||
{loading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<i className="bi bi-journal-x d-block"></i>
|
||||
<h5>No se encontraron artículos</h5>
|
||||
<p className="text-muted mb-0">Intenta con otros términos de búsqueda</p>
|
||||
</div>
|
||||
) : (
|
||||
articles.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="d-flex align-items-center gap-3 p-3 border-bottom"
|
||||
style={{ cursor: "pointer", transition: "background 0.15s" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "#f8f9fa")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
|
||||
>
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
style={{ width: 44, height: 44, borderRadius: 10, backgroundColor: "#00285d10", color: "var(--primary)", fontSize: "1.1rem" }}
|
||||
>
|
||||
<i className="bi bi-journal-text"></i>
|
||||
</div>
|
||||
<div className="flex-grow-1">
|
||||
<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>
|
||||
</div>
|
||||
<i className="bi bi-chevron-right text-muted"></i>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AuthProvider, useAuth } from "@/lib/auth";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: "100vh" }}>
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary" role="status" style={{ width: "3rem", height: "3rem" }}></div>
|
||||
<p className="mt-3 text-muted">Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
{/* Sidebar Overlay for mobile */}
|
||||
<div
|
||||
className={`sidebar-overlay ${sidebarOpen ? "show" : ""}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`sidebar ${sidebarOpen ? "show" : ""}`} style={{ zIndex: 1040 }}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="main-content flex-grow-1">
|
||||
{/* Top Navbar */}
|
||||
<header className="top-navbar">
|
||||
<button
|
||||
className="btn d-lg-none me-3 p-2"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
<i className="bi bi-list fs-4"></i>
|
||||
</button>
|
||||
|
||||
<div className="position-relative flex-grow-1" style={{ maxWidth: "400px" }}>
|
||||
<i className="bi bi-search position-absolute" style={{ left: "12px", top: "50%", transform: "translateY(-50%)", color: "#6c757d" }}></i>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control search-input"
|
||||
placeholder="Buscar tickets, usuarios..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-3 ms-auto">
|
||||
<button className="notification-btn">
|
||||
<i className="bi bi-bell"></i>
|
||||
<span className="notification-badge">3</span>
|
||||
</button>
|
||||
|
||||
<TopNavbarUser />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="content-wrapper fade-in">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopNavbarUser() {
|
||||
const { user } = useAuth();
|
||||
return (
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<div className="avatar-circle">
|
||||
{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 }}>
|
||||
{user?.name || "Usuario"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import StatsCard from "@/components/StatsCard";
|
||||
import BadgeStatus from "@/components/BadgeStatus";
|
||||
import { getDashboardStats, type DashboardStats } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getDashboardStats()
|
||||
.then(setStats)
|
||||
.catch(() => {
|
||||
// Set mock data for demo
|
||||
setStats({
|
||||
totalTickets: 128,
|
||||
openTickets: 24,
|
||||
pendingTickets: 18,
|
||||
resolvedTickets: 62,
|
||||
closedTickets: 24,
|
||||
avgResponseTime: "2.4h",
|
||||
ticketsByPriority: { low: 30, medium: 45, high: 38, urgent: 15 },
|
||||
recentTickets: [],
|
||||
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));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxPriority = Math.max(...Object.values(stats?.ticketsByPriority || {}), 1);
|
||||
const maxWorkload = Math.max(...(stats?.agentWorkload?.map((a) => a.count) || [1]));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<Link href="/tickets/new" className="btn btn-primary">
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
Nuevo Ticket
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-ticket-detailed" value={stats?.totalTickets || 0} label="Total Tickets" color="#00285d" />
|
||||
</div>
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" />
|
||||
</div>
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-clock-history" value={stats?.pendingTickets || 0} label="Pendientes" color="#ffc107" />
|
||||
</div>
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" />
|
||||
</div>
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-speedometer" value={stats?.avgResponseTime || "N/A"} label="Tiempo Respuesta" color="#6f42c1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-4">
|
||||
{/* Priority Distribution */}
|
||||
<div className="col-lg-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header d-flex align-items-center gap-2">
|
||||
<i className="bi bi-bar-chart" style={{ color: "var(--primary)" }}></i>
|
||||
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>
|
||||
|
||||
{/* Agent Workload */}
|
||||
<div className="col-lg-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header d-flex align-items-center gap-2">
|
||||
<i className="bi bi-people" style={{ color: "var(--primary)" }}></i>
|
||||
Carga de Agentes
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{stats?.agentWorkload?.map((item) => (
|
||||
<div key={item.agent} className="mb-3">
|
||||
<div className="d-flex justify-content-between mb-1">
|
||||
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>{item.agent}</span>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { getTicket, addComment, updateTicketStatus, assignTicket, getAgents, type Ticket, type User } from "@/lib/api";
|
||||
import BadgeStatus from "@/components/BadgeStatus";
|
||||
import BadgePriority from "@/components/BadgePriority";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const id = params.id as string;
|
||||
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [agents, setAgents] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comment, setComment] = useState("");
|
||||
const [isInternal, setIsInternal] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getTicket(id).catch(() => ({
|
||||
id: parseInt(id),
|
||||
ticketNumber: "#TK-" + id.padStart(3, "0"),
|
||||
subject: "No puedo acceder al sistema de correo electrónico desde esta mañana",
|
||||
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.",
|
||||
status: "open",
|
||||
priority: "high",
|
||||
category: "Software",
|
||||
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]);
|
||||
|
||||
const handleAddComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const newComment = await addComment(id, comment, isInternal);
|
||||
setTicket((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, comments: [...(prev.comments || []), newComment] };
|
||||
});
|
||||
setComment("");
|
||||
setIsInternal(false);
|
||||
} catch {
|
||||
// Add locally for demo
|
||||
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 {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
try {
|
||||
await updateTicketStatus(id, parseInt(newStatus));
|
||||
if (ticket) setTicket({ ...ticket, status: newStatus });
|
||||
} 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 {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<i className="bi bi-exclamation-triangle d-block"></i>
|
||||
<h5>Ticket no encontrado</h5>
|
||||
<Link href="/tickets" className="btn btn-primary mt-3">Volver a tickets</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Ticket Header */}
|
||||
<div className="ticket-header">
|
||||
<div>
|
||||
<div className="ticket-number mb-1">
|
||||
<Link href="/tickets" className="text-decoration-none me-2">
|
||||
<i className="bi bi-arrow-left"></i>
|
||||
</Link>
|
||||
{ticket.ticketNumber || `#${ticket.id}`}
|
||||
</div>
|
||||
<h3 className="ticket-subject">{ticket.subject}</h3>
|
||||
<div className="d-flex gap-2 mt-2">
|
||||
<BadgeStatus status={ticket.status} />
|
||||
<BadgePriority priority={ticket.priority} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
{/* Status Change */}
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-outline-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i className="bi bi-arrow-repeat me-1"></i>Cambiar Estado
|
||||
</button>
|
||||
<ul className="dropdown-menu">
|
||||
{[
|
||||
{ value: "open", label: "Abierto" },
|
||||
{ value: "pending", label: "Pendiente" },
|
||||
{ value: "in_progress", label: "En Progreso" },
|
||||
{ value: "resolved", label: "Resuelto" },
|
||||
{ value: "closed", label: "Cerrado" },
|
||||
].map((s) => (
|
||||
<li key={s.value}>
|
||||
<button className="dropdown-item" onClick={() => handleStatusChange(s.value)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Assign */}
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-outline-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i className="bi bi-person-plus me-1"></i>Asignar
|
||||
</button>
|
||||
<ul className="dropdown-menu">
|
||||
{agents.map((a) => (
|
||||
<li key={a.id}>
|
||||
<button className="dropdown-item" onClick={() => handleAssign(String(a.id))}>
|
||||
{a.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-4">
|
||||
{/* Comments Timeline */}
|
||||
<div className="col-lg-8">
|
||||
<div className="card mb-4">
|
||||
<div className="card-header d-flex align-items-center gap-2">
|
||||
<i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i>
|
||||
Comentarios ({ticket.comments?.length || 0})
|
||||
</div>
|
||||
<div className="card-body" style={{ maxHeight: "500px", overflowY: "auto" }}>
|
||||
{ticket.comments?.length ? (
|
||||
ticket.comments.map((c) => {
|
||||
const isAgent = c.user?.role === "agent" || c.user?.role === "admin";
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`comment ${isAgent ? "comment-agent" : "comment-customer"} ${
|
||||
c.isInternal ? "comment-internal" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="comment-bubble">
|
||||
<div className="comment-author">
|
||||
{c.user?.name || "Usuario"}
|
||||
{c.isInternal && (
|
||||
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
|
||||
Nota Interna
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="comment-text">{c.content}</div>
|
||||
<div className="comment-time">
|
||||
{new Date(c.createdAt).toLocaleString("es-CL")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center text-muted py-4">
|
||||
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
|
||||
No hay comentarios aún
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment Form */}
|
||||
<div className="comment-form">
|
||||
<form onSubmit={handleAddComment}>
|
||||
<div className="mb-3">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
placeholder="Escribe tu comentario..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="internalCheck"
|
||||
checked={isInternal}
|
||||
onChange={(e) => setIsInternal(e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="internalCheck" style={{ fontSize: "0.85rem" }}>
|
||||
<i className="bi bi-eye-slash me-1"></i>
|
||||
Nota interna (visible solo para agentes)
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!comment.trim() || submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="spinner-border spinner-border-sm" role="status"></span>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-send me-2"></i>
|
||||
Enviar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticket Info Sidebar */}
|
||||
<div className="col-lg-4">
|
||||
<div className="ticket-info-card">
|
||||
<h6>Información del Ticket</h6>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Solicitante</span>
|
||||
<span className="value">{ticket.requester?.name || "N/A"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Email</span>
|
||||
<span className="value" style={{ fontSize: "0.8rem" }}>{ticket.requester?.email || "N/A"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Asignado a</span>
|
||||
<span className="value">{ticket.assignee?.name || "Sin asignar"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Grupo</span>
|
||||
<span className="value">{ticket.group?.name || "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 className="ticket-info-row">
|
||||
<span className="label">Creado</span>
|
||||
<span className="value">{new Date(ticket.createdAt).toLocaleString("es-CL")}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Actualizado</span>
|
||||
<span className="value">{new Date(ticket.updatedAt).toLocaleString("es-CL")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="ticket-info-card">
|
||||
<h6>Descripción</h6>
|
||||
<p style={{ fontSize: "0.875rem", lineHeight: 1.6, marginBottom: 0 }}>
|
||||
{ticket.description || "Sin descripción"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createTicket, getAgents, type User } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NewTicketPage() {
|
||||
const router = useRouter();
|
||||
const [agents, setAgents] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const ticket = await createTicket({
|
||||
subject,
|
||||
description,
|
||||
priority,
|
||||
category,
|
||||
assigneeId: assigneeId ? parseInt(assigneeId) : undefined,
|
||||
});
|
||||
router.push(`/tickets/${ticket.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al crear el ticket");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<Link href="/tickets" className="text-decoration-none text-muted" style={{ fontSize: "0.85rem" }}>
|
||||
<i className="bi bi-arrow-left me-1"></i>Volver a tickets
|
||||
</Link>
|
||||
<h1 className="mt-1">Nuevo Ticket</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-lg-8">
|
||||
<div className="card">
|
||||
<div className="card-body p-4">
|
||||
{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 fw-semibold">Asunto</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Describe brevemente el problema o solicitud"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-semibold">Descripción</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={6}
|
||||
placeholder="Proporciona detalles sobre el problema, pasos para reproducir, etc."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label fw-semibold">Prioridad</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
>
|
||||
<option value="low">Baja</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</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 className="d-flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary px-4"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Creando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
Crear Ticket
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link href="/tickets" className="btn btn-outline-secondary">
|
||||
Cancelar
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="col-lg-4">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h6 className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-lightbulb" style={{ color: "#ffc107" }}></i>
|
||||
Consejos
|
||||
</h6>
|
||||
<ul className="mb-0" style={{ fontSize: "0.85rem", lineHeight: 1.7 }}>
|
||||
<li>Sé específico en el asunto del ticket</li>
|
||||
<li>Incluye pasos para reproducir el problema</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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getTickets, type Ticket } from "@/lib/api";
|
||||
import BadgeStatus from "@/components/BadgeStatus";
|
||||
import BadgePriority from "@/components/BadgePriority";
|
||||
import PaginationComponent from "@/components/Pagination";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TicketsPage() {
|
||||
const router = useRouter();
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [priorityFilter, setPriorityFilter] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const pageSize = 10;
|
||||
|
||||
const fetchTickets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getTickets({
|
||||
status: statusFilter || undefined,
|
||||
priority: priorityFilter || undefined,
|
||||
search: search || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setTickets(data.tickets || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
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" },
|
||||
{ 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 {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter, priorityFilter, search, page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, [fetchTickets]);
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Tickets</h1>
|
||||
<Link href="/tickets/new" className="btn btn-primary">
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
Nuevo Ticket
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="filter-bar mb-4">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<i className="bi bi-funnel text-muted"></i>
|
||||
<span className="fw-semibold" style={{ fontSize: "0.85rem" }}>Filtros:</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ width: "auto" }}
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="open">Abierto</option>
|
||||
<option value="pending">Pendiente</option>
|
||||
<option value="in_progress">En Progreso</option>
|
||||
<option value="resolved">Resuelto</option>
|
||||
<option value="closed">Cerrado</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ width: "auto" }}
|
||||
value={priorityFilter}
|
||||
onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">Todas las prioridades</option>
|
||||
<option value="low">Baja</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</select>
|
||||
|
||||
<div className="position-relative flex-grow-1" style={{ maxWidth: "280px" }}>
|
||||
<i className="bi bi-search position-absolute" style={{ left: "10px", top: "50%", transform: "translateY(-50%)", color: "#6c757d" }}></i>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
style={{ paddingLeft: "2rem" }}
|
||||
placeholder="Buscar tickets..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tickets Table */}
|
||||
<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>Asignado</th>
|
||||
<th>Actualizado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-4">
|
||||
<div className="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</td>
|
||||
</tr>
|
||||
) : tickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="empty-state">
|
||||
<i className="bi bi-inbox d-block"></i>
|
||||
<h5>No se encontraron tickets</h5>
|
||||
<p className="text-muted mb-0">Prueba ajustando los filtros de búsqueda</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tickets.map((ticket) => (
|
||||
<tr
|
||||
key={ticket.id}
|
||||
onClick={() => router.push(`/tickets/${ticket.id}`)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<td className="fw-semibold" style={{ color: "var(--primary)" }}>
|
||||
{ticket.ticketNumber || `#${ticket.id}`}
|
||||
</td>
|
||||
<td>
|
||||
<div className="fw-medium">{ticket.subject}</div>
|
||||
<small className="text-muted">{ticket.category}</small>
|
||||
</td>
|
||||
<td>{ticket.requester?.name || "N/A"}</td>
|
||||
<td><BadgeStatus status={ticket.status} /></td>
|
||||
<td><BadgePriority priority={ticket.priority} /></td>
|
||||
<td>{ticket.assignee?.name || <span className="text-muted">Sin asignar</span>}</td>
|
||||
<td className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||
{new Date(ticket.updatedAt).toLocaleDateString("es-CL")}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="pagination-wrapper">
|
||||
<span className="pagination-info">
|
||||
Mostrando {tickets.length} de {total} tickets
|
||||
</span>
|
||||
<PaginationComponent currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = 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("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
router.push("/");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Credenciales inválidas");
|
||||
} 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>
|
||||
Sistema de gestión de tickets de soporte técnico. Gestiona incidencias,
|
||||
solicitudes y consultas de manera eficiente.
|
||||
</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>Gestión centralizada de tickets</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-check-circle-fill"></i>
|
||||
<span>Base de conocimiento integrada</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-check-circle-fill"></i>
|
||||
<span>SLA y métricas de rendimiento</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>Iniciar Sesión</h3>
|
||||
<p className="subtitle">Ingresa tus credenciales para acceder al sistema</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">Correo Electrónico</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
placeholder="tu@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
Ingresando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-box-arrow-in-right me-2"></i>
|
||||
Iniciar Sesión
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,800 @@
|
||||
:root {
|
||||
--primary: #00285d;
|
||||
--primary-light: #1a3f7a;
|
||||
--primary-lighter: #2a5a9a;
|
||||
--primary-gradient: linear-gradient(135deg, #00285d 0%, #1a4a8a 100%);
|
||||
--bg: #f0f2f5;
|
||||
--card-shadow: 0 2px 12px rgba(0, 40, 93, 0.08);
|
||||
--card-hover-shadow: 0 4px 20px rgba(0, 40, 93, 0.12);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--text-dark: #1a1a2e;
|
||||
--text-muted: #6c757d;
|
||||
--sidebar-width: 260px;
|
||||
--navbar-height: 64px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Bootstrap Primary Override */
|
||||
:root {
|
||||
--bs-primary: #00285d;
|
||||
--bs-primary-rgb: 0, 40, 93;
|
||||
--bs-link-color: #00285d;
|
||||
--bs-link-hover-color: #1a3f7a;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-gradient) !important;
|
||||
border: none !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1.25rem;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 40, 93, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 40, 93, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
border-color: var(--primary) !important;
|
||||
color: var(--primary) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #00285d 0%, #001a3d 100%);
|
||||
color: #fff;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1040;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 0.125rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar .nav-link i {
|
||||
font-size: 1.1rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 1.5rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-brand h5 {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
padding: 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-user .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-user-info .name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-user-info .role {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Top Navbar */
|
||||
.top-navbar {
|
||||
height: var(--navbar-height);
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
.top-navbar .search-input {
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||
font-size: 0.875rem;
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.top-navbar .search-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(0, 40, 93, 0.15);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.notification-btn {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.notification-btn:hover {
|
||||
background-color: #f0f2f5;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
background: #fff;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--card-hover-shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
padding: 1rem 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Stats Card */
|
||||
.stats-card {
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
background: #fff;
|
||||
border: none;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--card-hover-shadow);
|
||||
}
|
||||
|
||||
.stats-card .stats-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stats-card .stats-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stats-card .stats-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.badge-status {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge-open {
|
||||
background-color: #d1e7dd;
|
||||
color: #0f5132;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background-color: #fff3cd;
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.badge-resolved {
|
||||
background-color: #cfe2ff;
|
||||
color: #084298;
|
||||
}
|
||||
|
||||
.badge-closed {
|
||||
background-color: #e2e3e5;
|
||||
color: #41464b;
|
||||
}
|
||||
|
||||
.badge-in-progress {
|
||||
background-color: #cff4fc;
|
||||
color: #055160;
|
||||
}
|
||||
|
||||
/* Priority Badges */
|
||||
.badge-priority {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge-low {
|
||||
background-color: #e2e3e5;
|
||||
color: #41464b;
|
||||
}
|
||||
|
||||
.badge-medium {
|
||||
background-color: #cfe2ff;
|
||||
color: #084298;
|
||||
}
|
||||
|
||||
.badge-high {
|
||||
background-color: #ffecb5;
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.badge-urgent {
|
||||
background-color: #f8d7da;
|
||||
color: #842029;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-modern {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.table-modern thead {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.table-modern thead th {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.table-modern tbody tr {
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-modern tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.table-modern tbody td {
|
||||
padding: 0.875rem 1rem;
|
||||
vertical-align: middle;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.table-modern tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Login/Register Page */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.auth-panel-left {
|
||||
background: var(--primary-gradient);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-panel-left h2 {
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-panel-left p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.85;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.auth-panel-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-form h3 {
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.auth-form .subtitle {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-form .form-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.auth-form .form-control {
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-form .form-control:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(0, 40, 93, 0.15);
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.25rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-bar .form-select,
|
||||
.filter-bar .form-control {
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid #dee2e6;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Ticket Detail */
|
||||
.ticket-header {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem 1.5rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
margin-bottom: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ticket-number {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.ticket-subject {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ticket-info-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ticket-info-card h6 {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.ticket-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ticket-info-row .label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ticket-info-row .value {
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Comments Timeline */
|
||||
.comment {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.comment-agent {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.comment-agent .comment-bubble {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: var(--radius) var(--radius) 4px var(--radius);
|
||||
padding: 0.875rem 1rem;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.comment-customer {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.comment-customer .comment-bubble {
|
||||
background: #f0f2f5;
|
||||
color: var(--text-dark);
|
||||
border-radius: var(--radius) var(--radius) var(--radius) 4px;
|
||||
padding: 0.875rem 1rem;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.comment-internal .comment-bubble {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Comment Form */
|
||||
.comment-form {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 991.98px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.top-navbar .search-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.auth-panel-left {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-panel-right {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ticket-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.comment-bubble {
|
||||
max-width: 90% !important;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-bar .form-select,
|
||||
.filter-bar .form-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c4c8cc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a0a4a8;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* KB Search */
|
||||
.kb-search-input {
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid #e9ecef;
|
||||
padding: 0.875rem 1.25rem;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.kb-search-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(0, 40, 93, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-state h5 {
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Sidebar overlay for mobile */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1035;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.sidebar-overlay.show {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transition utilities */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* User avatar circle */
|
||||
.avatar-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-gradient);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "HelpDesk ICHN",
|
||||
description: "Sistema de Gestión de Tickets de Soporte - ICHN",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="es" className={`${inter.variable} h-full antialiased`}>
|
||||
<head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@300;400;500;600;700&display=swap"
|
||||
/>
|
||||
</head>
|
||||
<body style={{ fontFamily: "'Fira Sans', sans-serif" }}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user