Initial commit: HelpDesk ICHN - .NET 10 backend + Next.js 16 frontend

This commit is contained in:
Nurfog committed 2026-09-10 10:20:51 -03:00
commit 359f0caf85
90 files changed
+13395

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+38
View File
@@ -0,0 +1,38 @@
FROM node:24-alpine AS base
# --- DEPENDENCIES ---
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# --- BUILD ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# --- PRODUCTION ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+7273
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@types/react-bootstrap": "^1.0.1",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"next": "16.3.4",
"react": "^19.3.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -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>
);
}
+110
View File
@@ -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>
);
}
+204
View File
@@ -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>
);
}
+191
View File
@@ -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>
);
}
+176
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
+169
View File
@@ -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

+800
View File
@@ -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;
}
+36
View File
@@ -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>
);
}
+15
View File
@@ -0,0 +1,15 @@
const priorityConfig: Record<string, { className: string; label: string }> = {
low: { className: "badge-low", label: "Baja" },
medium: { className: "badge-medium", label: "Media" },
high: { className: "badge-high", label: "Alta" },
urgent: { className: "badge-urgent", label: "Urgente" },
"1": { className: "badge-low", label: "Baja" },
"2": { className: "badge-medium", label: "Media" },
"3": { className: "badge-high", label: "Alta" },
"4": { className: "badge-urgent", label: "Urgente" },
};
export default function BadgePriority({ priority }: { priority: string }) {
const config = priorityConfig[priority] || { className: "badge-medium", label: priority };
return <span className={`badge-priority ${config.className}`}>{config.label}</span>;
}
+17
View File
@@ -0,0 +1,17 @@
const statusConfig: Record<string, { className: string; label: string }> = {
open: { className: "badge-open", label: "Abierto" },
pending: { className: "badge-pending", label: "Pendiente" },
in_progress: { className: "badge-in-progress", label: "En Progreso" },
resolved: { className: "badge-resolved", label: "Resuelto" },
closed: { className: "badge-closed", label: "Cerrado" },
"1": { className: "badge-open", label: "Abierto" },
"2": { className: "badge-pending", label: "Pendiente" },
"3": { className: "badge-in-progress", label: "En Progreso" },
"4": { className: "badge-resolved", label: "Resuelto" },
"5": { className: "badge-closed", label: "Cerrado" },
};
export default function BadgeStatus({ status }: { status: string }) {
const config = statusConfig[status] || { className: "badge-open", label: status };
return <span className={`badge-status ${config.className}`}>{config.label}</span>;
}
+53
View File
@@ -0,0 +1,53 @@
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function PaginationComponent({ currentPage, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) return null;
const pages: (number | "...")[] = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
pages.push(i);
} else if (pages[pages.length - 1] !== "...") {
pages.push("...");
}
}
return (
<div className="pagination-wrapper">
<nav>
<ul className="pagination mb-0">
<li className={`page-item ${currentPage === 1 ? "disabled" : ""}`}>
<button className="page-link" onClick={() => onPageChange(currentPage - 1)}>
<i className="bi bi-chevron-left"></i>
</button>
</li>
{pages.map((page, idx) => (
<li
key={idx}
className={`page-item ${page === currentPage ? "active" : ""} ${
page === "..." ? "disabled" : ""
}`}
>
{page === "..." ? (
<span className="page-link">…</span>
) : (
<button className="page-link" onClick={() => onPageChange(page)}>
{page}
</button>
)}
</li>
))}
<li className={`page-item ${currentPage === totalPages ? "disabled" : ""}`}>
<button className="page-link" onClick={() => onPageChange(currentPage + 1)}>
<i className="bi bi-chevron-right"></i>
</button>
</li>
</ul>
</nav>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth";
export default function Sidebar() {
const pathname = usePathname();
const { user, logout } = useAuth();
const navItems = [
{ href: "/", icon: "bi-speedometer2", label: "Dashboard" },
{ href: "/tickets", icon: "bi-ticket-detailed", label: "Tickets" },
{ href: "/knowledge-base", icon: "bi-journal-bookmark", label: "Knowledge Base" },
{ href: "/users", icon: "bi-people", label: "Users" },
{ href: "/settings", icon: "bi-gear", label: "Settings" },
];
return (
<aside className="sidebar" id="sidebar">
<div className="sidebar-brand">
<i className="bi bi-headset fs-3"></i>
<h5>HelpDesk ICHN</h5>
</div>
<nav className="flex-column mt-3">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`nav-link ${
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href))
? "active"
: ""
}`}
>
<i className={`bi ${item.icon}`}></i>
<span>{item.label}</span>
</Link>
))}
</nav>
<div className="sidebar-user">
<div className="avatar">
{user?.name
?.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) || "U"}
</div>
<div className="sidebar-user-info">
<div className="name">{user?.name || "Usuario"}</div>
<div className="role">{user?.role || "Agente"}</div>
</div>
<button
onClick={logout}
className="btn btn-link text-white p-1"
title="Cerrar sesión"
>
<i className="bi bi-box-arrow-right fs-5"></i>
</button>
</div>
</aside>
);
}
+22
View File
@@ -0,0 +1,22 @@
interface StatsCardProps {
icon: string;
value: string | number;
label: string;
color?: string;
}
export default function StatsCard({ icon, value, label, color = "#00285d" }: StatsCardProps) {
return (
<div className="stats-card">
<div className="d-flex align-items-center gap-3">
<div className="stats-icon" style={{ backgroundColor: `${color}15`, color }}>
<i className={`bi ${icon}`}></i>
</div>
<div>
<div className="stats-value">{value}</div>
<div className="stats-label">{label}</div>
</div>
</div>
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000";
async function fetchApi<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token =
typeof window !== "undefined" ? localStorage.getItem("helpdesk_token") : null;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...((options.headers as Record<string, string>) || {}),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || `API Error: ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// Auth
export async function login(email: string, password: string) {
return fetchApi<{ token: string; user: User }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, 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() {
return fetchApi<User>("/api/auth/me");
}
// Tickets
export async function getTickets(params?: {
status?: string;
priority?: string;
assigneeId?: string;
requesterId?: string;
search?: string;
page?: number;
pageSize?: number;
}) {
const query = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") query.set(key, String(value));
});
}
return fetchApi<{ tickets: Ticket[]; total: number; page: number; pageSize: number }>(
`/api/tickets?${query.toString()}`
);
}
export async function getTicket(id: string) {
return fetchApi<Ticket>(`/api/tickets/${id}`);
}
export async function createTicket(data: {
subject: string;
description: string;
priority?: string;
category?: string;
assigneeId?: number;
}) {
return fetchApi<Ticket>("/api/tickets", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function updateTicket(id: string, data: Partial<Ticket>) {
return fetchApi<Ticket>(`/api/tickets/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function deleteTicket(id: string) {
return fetchApi<void>(`/api/tickets/${id}`, {
method: "DELETE",
});
}
export async function updateTicketStatus(id: string, status: number) {
return fetchApi<Ticket>(`/api/tickets/${id}/status`, {
method: "PUT",
body: JSON.stringify({ status }),
});
}
export async function assignTicket(id: string, assigneeId: number) {
return fetchApi<Ticket>(`/api/tickets/${id}/assign`, {
method: "PUT",
body: JSON.stringify({ assigneeId }),
});
}
export async function addComment(
ticketId: string,
content: string,
isInternal: boolean = false
) {
return fetchApi<Comment>(`/api/tickets/${ticketId}/comments`, {
method: "POST",
body: JSON.stringify({ content, isInternal }),
});
}
export async function getTicketStats() {
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() {
return fetchApi<KBArticle[]>("/api/kb/articles/published");
}
export async function searchKB(query: string) {
return fetchApi<KBArticle[]>(`/api/kb/search?q=${encodeURIComponent(query)}`);
}
// Dashboard
export async function getDashboardStats() {
return fetchApi<DashboardStats>("/api/dashboard/stats");
}
// Groups & SLA
export async function getGroups() {
return fetchApi<Group[]>("/api/groups");
}
export async function getSLAPolicies() {
return fetchApi<SLAPolicy[]>("/api/sla");
}
// Types
export interface User {
id: number;
name: string;
email: string;
role: "admin" | "agent" | "customer";
createdAt?: string;
}
export interface Ticket {
id: number;
ticketNumber: string;
subject: string;
description: string;
status: string;
priority: string;
category: string;
requesterId: number;
requester?: User;
assigneeId: number | null;
assignee?: User;
groupId: number | null;
group?: Group;
comments?: Comment[];
createdAt: string;
updatedAt: string;
}
export interface Comment {
id: number;
ticketId: number;
userId: number;
user?: User;
content: string;
isInternal: boolean;
createdAt: string;
}
export interface Group {
id: number;
name: string;
description?: string;
}
export interface KBArticle {
id: number;
title: string;
content: string;
category: string;
author?: User;
publishedAt: string;
}
export interface SLAPolicy {
id: number;
name: string;
priority: string;
responseTime: string;
resolutionTime: string;
}
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 {
totalTickets: number;
openTickets: number;
pendingTickets: number;
resolvedTickets: number;
closedTickets: number;
avgResponseTime: string;
ticketsByPriority: Record<string, number>;
recentTickets: Ticket[];
agentWorkload: { agent: string; count: number }[];
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import { getMe, login as apiLogin, type User } from "./api";
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType>({
user: null,
login: async () => {},
logout: () => {},
isAuthenticated: false,
isLoading: true,
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("helpdesk_token");
if (token) {
getMe()
.then((u) => {
setUser(u);
})
.catch(() => {
localStorage.removeItem("helpdesk_token");
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, []);
const login = useCallback(async (email: string, password: string) => {
const { token, user } = await apiLogin(email, password);
localStorage.setItem("helpdesk_token", token);
setUser(user);
}, []);
const logout = useCallback(() => {
localStorage.removeItem("helpdesk_token");
setUser(null);
}, []);
return (
<AuthContext.Provider
value={{ user, login, logout, isAuthenticated: !!user, isLoading }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}