feat: correo via banco mail, rol supervisor y lint en cero
This commit is contained in:
1 parent
2502de4e06
commit
b1cddbac6c
15 files changed
+658
-88
No files matched your search
@@ -41,16 +41,29 @@ pnpm dev
|
|||||||
# Frontend en http://localhost:3000
|
# Frontend en http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
Variable de entorno (`frontend/.env.local`, no versionado):
|
Variables de entorno (`frontend/.env.local`, no versionado):
|
||||||
|
|
||||||
```
|
```
|
||||||
NEXT_PUBLIC_API_URL=https://api.ichn.local # dev (.254)
|
NEXT_PUBLIC_API_URL=https://api.ichn.local # dev (.254), cliente (bundle)
|
||||||
# NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl # prod (.4)
|
# NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl # prod (.4)
|
||||||
|
|
||||||
|
# Solo servidor (route /api/correos → POST /api/email/send, jamás NEXT_PUBLIC_):
|
||||||
|
# MAIL_API_KEY=clave del módulo helpdesk (POST /api/email/modules, se muestra una vez)
|
||||||
|
# SAM_API_URL=https://api.ichn.local # default: NEXT_PUBLIC_API_URL, luego prod
|
||||||
|
# MAIL_MODULE=helpdesk # default helpdesk
|
||||||
```
|
```
|
||||||
|
|
||||||
Auth: login con `idUsuario` + password (valida contra `sige_sam_v3.usuario`
|
Auth: login con `idUsuario` + password (valida contra `sige_sam_v3.usuario`
|
||||||
en el backend). El primer integrante del equipo queda `admin` (bootstrap).
|
en el backend). El primer integrante del equipo queda `admin` (bootstrap).
|
||||||
|
|
||||||
|
Roles del equipo (`helpdesk.team_members`):
|
||||||
|
|
||||||
|
| Rol | Alcance |
|
||||||
|
|---|---|
|
||||||
|
| `admin` | Todo + Configuración (departamentos, equipo, SLA, correo) |
|
||||||
|
| `agent` | Todos los tickets |
|
||||||
|
| `supervisor` | Solo su departamento (+ sus propios tickets); requiere departamento asignado; no puede mover tickets de departamento ni gestionar configuración |
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Lo publica el deployer de SAM desde este repo (módulo `helpdesk`):
|
Lo publica el deployer de SAM desde este repo (módulo `helpdesk`):
|
||||||
@@ -69,4 +82,5 @@ Base: `NEXT_PUBLIC_API_URL/api/helpdesk`
|
|||||||
- `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin)
|
- `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin)
|
||||||
- `GET|POST /users` · `PUT /users/{id}` (admin)
|
- `GET|POST /users` · `PUT /users/{id}` (admin)
|
||||||
- `GET|POST /sla` · `PUT /sla/{id}` (admin)
|
- `GET|POST /sla` · `PUT /sla/{id}` (admin)
|
||||||
- `GET /email/templates` · `PUT /email/templates/{key}` · `GET /email/log` · `POST /email/test` (admin)
|
- `GET /email/templates` · `POST /email/templates` · `PUT /email/templates/{key}` · `GET /email/log?module=helpdesk&limit=` · `GET|POST /email/modules` · `DELETE /email/modules/{m}` (admin, banco único `mail.*`)
|
||||||
|
- `POST /api/correos` (route propio server-side → `POST /api/email/send` con `X-Module: helpdesk` + `X-Api-Key`) · `POST /helpdesk/email/inbound/sync` (admin, inbound Gmail sin cambios)
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import StatsCard from "@/components/StatsCard";
|
import StatsCard from "@/components/StatsCard";
|
||||||
import BadgeStatus from "@/components/BadgeStatus";
|
|
||||||
import { getTicketStats, type DashboardStats } from "@/lib/api";
|
import { getTicketStats, type DashboardStats } from "@/lib/api";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,18 @@ export default function DepartmentsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading || user?.role !== "admin") return;
|
||||||
if (user?.role !== "admin") {
|
getGroups()
|
||||||
setLoading(false);
|
.then((g) => {
|
||||||
return;
|
setGroups(g);
|
||||||
}
|
setError("");
|
||||||
fetchGroups();
|
})
|
||||||
}, [fetchGroups, authLoading, user?.role]);
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Error al cargar");
|
||||||
|
setGroups([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [authLoading, user?.role]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
getEmailTemplates,
|
getEmailTemplates,
|
||||||
|
createEmailTemplate,
|
||||||
updateEmailTemplate,
|
updateEmailTemplate,
|
||||||
getEmailLog,
|
getEmailLog,
|
||||||
sendTestEmail,
|
sendTestEmail,
|
||||||
syncInboundEmail,
|
syncInboundEmail,
|
||||||
|
getEmailModules,
|
||||||
|
registerEmailModule,
|
||||||
|
revokeEmailModule,
|
||||||
type EmailTemplate,
|
type EmailTemplate,
|
||||||
type EmailLogEntry,
|
type EmailLogEntry,
|
||||||
|
type EmailModule,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
@@ -31,6 +36,8 @@ export default function EmailPage() {
|
|||||||
const { user, isLoading: authLoading } = useAuth();
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
const [log, setLog] = useState<EmailLogEntry[]>([]);
|
const [log, setLog] = useState<EmailLogEntry[]>([]);
|
||||||
|
const [modules, setModules] = useState<EmailModule[]>([]);
|
||||||
|
const [logModule, setLogModule] = useState("helpdesk");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -40,6 +47,24 @@ export default function EmailPage() {
|
|||||||
const [isActive, setIsActive] = useState(true);
|
const [isActive, setIsActive] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [modalError, setModalError] = useState("");
|
const [modalError, setModalError] = useState("");
|
||||||
|
// Crear plantilla (POST /api/email/templates)
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newKey, setNewKey] = useState("");
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newSubject, setNewSubject] = useState("");
|
||||||
|
const [newBody, setNewBody] = useState("");
|
||||||
|
const [newModule, setNewModule] = useState("helpdesk");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState("");
|
||||||
|
// Módulos (GET/POST/DELETE /api/email/modules)
|
||||||
|
const [newModuleName, setNewModuleName] = useState("");
|
||||||
|
const [registering, setRegistering] = useState(false);
|
||||||
|
const [registerResult, setRegisterResult] = useState<{
|
||||||
|
ok: boolean;
|
||||||
|
text: string;
|
||||||
|
apiKey?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [revoking, setRevoking] = useState<string | null>(null);
|
||||||
const [testTo, setTestTo] = useState("");
|
const [testTo, setTestTo] = useState("");
|
||||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
@@ -50,26 +75,45 @@ export default function EmailPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [t, l] = await Promise.all([getEmailTemplates(), getEmailLog(50)]);
|
const [t, l, m] = await Promise.all([
|
||||||
|
getEmailTemplates(),
|
||||||
|
getEmailLog(50, logModule),
|
||||||
|
getEmailModules(),
|
||||||
|
]);
|
||||||
setTemplates(t);
|
setTemplates(t);
|
||||||
setLog(l);
|
setLog(l);
|
||||||
|
setModules(m);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Error al cargar");
|
setError(err instanceof Error ? err.message : "Error al cargar");
|
||||||
setTemplates([]);
|
setTemplates([]);
|
||||||
setLog([]);
|
setLog([]);
|
||||||
|
setModules([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [logModule]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading || user?.role !== "admin") return;
|
||||||
if (user?.role !== "admin") {
|
Promise.all([
|
||||||
setLoading(false);
|
getEmailTemplates(),
|
||||||
return;
|
getEmailLog(50, logModule),
|
||||||
}
|
getEmailModules(),
|
||||||
fetchData();
|
])
|
||||||
}, [fetchData, authLoading, user?.role]);
|
.then(([t, l, m]) => {
|
||||||
|
setTemplates(t);
|
||||||
|
setLog(l);
|
||||||
|
setModules(m);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Error al cargar");
|
||||||
|
setTemplates([]);
|
||||||
|
setLog([]);
|
||||||
|
setModules([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [authLoading, user?.role, logModule]);
|
||||||
|
|
||||||
const openEdit = (t: EmailTemplate) => {
|
const openEdit = (t: EmailTemplate) => {
|
||||||
setEditing(t);
|
setEditing(t);
|
||||||
@@ -103,6 +147,87 @@ export default function EmailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setNewKey("");
|
||||||
|
setNewName("");
|
||||||
|
setNewSubject("");
|
||||||
|
setNewBody("");
|
||||||
|
setNewModule("helpdesk");
|
||||||
|
setCreateError("");
|
||||||
|
setShowCreate(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const key = newKey.trim().toLowerCase();
|
||||||
|
const modName = (newModule.trim().toLowerCase() || "helpdesk");
|
||||||
|
if (!/^[a-z0-9_]{2,64}$/.test(key)) {
|
||||||
|
setCreateError("Key inválida ([a-z0-9_], 2-64)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newName.trim() || !newSubject.trim() || !newBody.trim()) {
|
||||||
|
setCreateError("Nombre, asunto y cuerpo son requeridos");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9-]{2,32}$/.test(modName)) {
|
||||||
|
setCreateError("Módulo inválido ([a-z0-9-], 2-32)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
setCreateError("");
|
||||||
|
try {
|
||||||
|
await createEmailTemplate({
|
||||||
|
key,
|
||||||
|
name: newName.trim(),
|
||||||
|
subject: newSubject.trim(),
|
||||||
|
bodyHtml: newBody.trim(),
|
||||||
|
module: modName,
|
||||||
|
});
|
||||||
|
setShowCreate(false);
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err instanceof Error ? err.message : "Error al crear");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
const modName = newModuleName.trim().toLowerCase();
|
||||||
|
if (!/^[a-z0-9-]{2,32}$/.test(modName)) {
|
||||||
|
setRegisterResult({ ok: false, text: "Módulo inválido ([a-z0-9-], 2-32)" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRegistering(true);
|
||||||
|
setRegisterResult(null);
|
||||||
|
try {
|
||||||
|
const r = await registerEmailModule(modName);
|
||||||
|
setRegisterResult({
|
||||||
|
ok: true,
|
||||||
|
text: `Clave de "${r.module}" generada. Se muestra una sola vez.`,
|
||||||
|
apiKey: r.apiKey,
|
||||||
|
});
|
||||||
|
setNewModuleName("");
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
setRegisterResult({ ok: false, text: err instanceof Error ? err.message : "Error al registrar" });
|
||||||
|
} finally {
|
||||||
|
setRegistering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (modName: string) => {
|
||||||
|
if (!window.confirm(`¿Revocar la clave del módulo "${modName}"? Dejará de poder usar POST /api/email/send.`)) return;
|
||||||
|
setRevoking(modName);
|
||||||
|
try {
|
||||||
|
await revokeEmailModule(modName);
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
setRegisterResult({ ok: false, text: err instanceof Error ? err.message : "Error al revocar" });
|
||||||
|
} finally {
|
||||||
|
setRevoking(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
if (!testTo.trim() || !testTo.includes("@")) {
|
if (!testTo.trim() || !testTo.includes("@")) {
|
||||||
setTestResult({ ok: false, text: "Destino inválido" });
|
setTestResult({ ok: false, text: "Destino inválido" });
|
||||||
@@ -111,8 +236,9 @@ export default function EmailPage() {
|
|||||||
setTesting(true);
|
setTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
try {
|
try {
|
||||||
await sendTestEmail(testTo.trim());
|
const r = await sendTestEmail(testTo.trim());
|
||||||
setTestResult({ ok: true, text: `Correo de prueba enviado a ${testTo.trim()}` });
|
const sent = typeof r.sent === "number" ? ` (${r.sent} enviado(s))` : "";
|
||||||
|
setTestResult({ ok: true, text: `Correo de prueba enviado a ${testTo.trim()}${sent} vía módulo helpdesk.` });
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setTestResult({ ok: false, text: err instanceof Error ? err.message : "Error al enviar" });
|
setTestResult({ ok: false, text: err instanceof Error ? err.message : "Error al enviar" });
|
||||||
@@ -155,8 +281,12 @@ export default function EmailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-header">
|
<div className="page-header d-flex justify-content-between align-items-center">
|
||||||
<h1>Plantillas de Correo</h1>
|
<h1>Plantillas de Correo</h1>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||||
|
<i className="bi bi-plus-lg me-1"></i>
|
||||||
|
Nueva plantilla
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -173,6 +303,7 @@ export default function EmailPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Plantilla</th>
|
<th>Plantilla</th>
|
||||||
|
<th>Módulo</th>
|
||||||
<th>Asunto</th>
|
<th>Asunto</th>
|
||||||
<th>Estado</th>
|
<th>Estado</th>
|
||||||
<th className="text-end">Acciones</th>
|
<th className="text-end">Acciones</th>
|
||||||
@@ -181,13 +312,13 @@ export default function EmailPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="text-center py-4">
|
<td colSpan={5} className="text-center py-4">
|
||||||
<div className="spinner-border spinner-border-sm text-primary" role="status"></div>
|
<div className="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : templates.length === 0 ? (
|
) : templates.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="empty-state">
|
<td colSpan={5} className="empty-state">
|
||||||
<i className="bi bi-envelope d-block"></i>
|
<i className="bi bi-envelope d-block"></i>
|
||||||
<h5>Sin plantillas</h5>
|
<h5>Sin plantillas</h5>
|
||||||
</td>
|
</td>
|
||||||
@@ -201,6 +332,9 @@ export default function EmailPage() {
|
|||||||
<code>{t.key}</code>
|
<code>{t.key}</code>
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<code className="small">{t.module ?? "helpdesk"}</code>
|
||||||
|
</td>
|
||||||
<td className="text-muted small">{t.subject}</td>
|
<td className="text-muted small">{t.subject}</td>
|
||||||
<td>
|
<td>
|
||||||
{t.isActive ? (
|
{t.isActive ? (
|
||||||
@@ -227,6 +361,95 @@ export default function EmailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h6 className="fw-semibold mb-3">
|
||||||
|
<i className="bi bi-key me-2"></i>
|
||||||
|
Módulos autorizados
|
||||||
|
</h6>
|
||||||
|
{registerResult && (
|
||||||
|
<div className={`alert ${registerResult.ok ? "alert-success" : "alert-danger"} py-2`} role="alert">
|
||||||
|
{registerResult.text}
|
||||||
|
{registerResult.apiKey && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<code className="user-select-all">{registerResult.apiKey}</code>
|
||||||
|
<div className="small mt-1">
|
||||||
|
Guárdala en el <code>.env</code> del módulo como <code>MAIL_API_KEY</code>. No se vuelve a mostrar.
|
||||||
|
Registrar de nuevo rota la clave anterior.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{modules.length > 0 && (
|
||||||
|
<div className="table-responsive mb-3">
|
||||||
|
<table className="table table-sm table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Módulo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th className="text-end">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{modules.map((m) => (
|
||||||
|
<tr key={m.module}>
|
||||||
|
<td>
|
||||||
|
<code className="small">{m.module}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{m.isActive ? (
|
||||||
|
<span className="badge bg-success">Activo</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-secondary">Revocado</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
onClick={() => handleRevoke(m.module)}
|
||||||
|
disabled={revoking === m.module}
|
||||||
|
title="Revocar clave"
|
||||||
|
>
|
||||||
|
{revoking === m.module ? (
|
||||||
|
<span className="spinner-border spinner-border-sm" role="status" />
|
||||||
|
) : (
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="nombre-modulo (ej: webpay)"
|
||||||
|
value={newModuleName}
|
||||||
|
onChange={(e) => setNewModuleName(e.target.value)}
|
||||||
|
style={{ maxWidth: 320 }}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-outline-primary" onClick={handleRegister} disabled={registering}>
|
||||||
|
{registering ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" />
|
||||||
|
Generando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Registrar / rotar clave"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small className="text-muted d-block mt-2">
|
||||||
|
La clave plana se muestra una sola vez (<code>POST /api/email/modules</code>).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card mb-4">
|
<div className="card mb-4">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h6 className="fw-semibold mb-3">
|
<h6 className="fw-semibold mb-3">
|
||||||
@@ -259,6 +482,8 @@ export default function EmailPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<small className="text-muted d-block mt-2">
|
<small className="text-muted d-block mt-2">
|
||||||
|
Va por el route propio <code>/api/correos</code> → <code>POST /api/email/send</code> con
|
||||||
|
módulo <code>helpdesk</code>. Requiere <code>MAIL_API_KEY</code> en el servidor.
|
||||||
Si falla, revisa la bitácora y la configuración SMTP del backend.
|
Si falla, revisa la bitácora y la configuración SMTP del backend.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,10 +520,25 @@ export default function EmailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 className="fw-semibold mb-2">
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 className="fw-semibold mb-0">
|
||||||
<i className="bi bi-clock-history me-2"></i>
|
<i className="bi bi-clock-history me-2"></i>
|
||||||
Últimos envíos
|
Últimos envíos
|
||||||
</h6>
|
</h6>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
style={{ maxWidth: 220 }}
|
||||||
|
value={logModule}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLogModule(e.target.value);
|
||||||
|
setLoading(true);
|
||||||
|
}}
|
||||||
|
title="Filtrar bitácora por módulo"
|
||||||
|
>
|
||||||
|
<option value="helpdesk">Solo helpdesk</option>
|
||||||
|
<option value="">Todos los módulos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body p-0">
|
<div className="card-body p-0">
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -307,6 +547,7 @@ export default function EmailPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Fecha</th>
|
<th>Fecha</th>
|
||||||
<th>Plantilla</th>
|
<th>Plantilla</th>
|
||||||
|
<th>Módulo</th>
|
||||||
<th>Ticket</th>
|
<th>Ticket</th>
|
||||||
<th>Destinatario</th>
|
<th>Destinatario</th>
|
||||||
<th>Estado</th>
|
<th>Estado</th>
|
||||||
@@ -315,7 +556,7 @@ export default function EmailPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{log.length === 0 ? (
|
{log.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-center text-muted py-3">
|
<td colSpan={6} className="text-center text-muted py-3">
|
||||||
Sin envíos registrados
|
Sin envíos registrados
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -328,6 +569,9 @@ export default function EmailPage() {
|
|||||||
<td>
|
<td>
|
||||||
<code className="small">{e.template}</code>
|
<code className="small">{e.template}</code>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<code className="small">{e.module ?? "helpdesk"}</code>
|
||||||
|
</td>
|
||||||
<td>{e.ticketId ? `#${e.ticketId}` : "—"}</td>
|
<td>{e.ticketId ? `#${e.ticketId}` : "—"}</td>
|
||||||
<td className="small">{e.recipient}</td>
|
<td className="small">{e.recipient}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -403,6 +647,73 @@ export default function EmailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
show={showCreate}
|
||||||
|
title="Nueva plantilla"
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
submitLabel="Crear"
|
||||||
|
submitting={creating}
|
||||||
|
>
|
||||||
|
{createError && (
|
||||||
|
<div className="alert alert-danger py-2" role="alert">
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Key * <small className="text-muted">([a-z0-9_])</small></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control font-monospace"
|
||||||
|
value={newKey}
|
||||||
|
onChange={(e) => setNewKey(e.target.value)}
|
||||||
|
placeholder="ticket_created"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Nombre *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Ticket creado"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Módulo *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control font-monospace"
|
||||||
|
value={newModule}
|
||||||
|
onChange={(e) => setNewModule(e.target.value)}
|
||||||
|
placeholder="helpdesk"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Asunto *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={newSubject}
|
||||||
|
onChange={(e) => setNewSubject(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Cuerpo HTML *</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control font-monospace"
|
||||||
|
rows={8}
|
||||||
|
value={newBody}
|
||||||
|
onChange={(e) => setNewBody(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="alert alert-light border small mb-0">
|
||||||
|
<strong>Variables:</strong> se renderizan como <code>{"{{Clave}}"}</code> (case-sensitive).
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,13 +39,18 @@ export default function SlaPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading || user?.role !== "admin") return;
|
||||||
if (user?.role !== "admin") {
|
getSLAPolicies()
|
||||||
setLoading(false);
|
.then((p) => {
|
||||||
return;
|
setPolicies(p);
|
||||||
}
|
setError("");
|
||||||
fetchPolicies();
|
})
|
||||||
}, [fetchPolicies, authLoading, user?.role]);
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Error al cargar");
|
||||||
|
setPolicies([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [authLoading, user?.role]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
|||||||
@@ -43,13 +43,19 @@ export default function TeamPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading || user?.role !== "admin") return;
|
||||||
if (user?.role !== "admin") {
|
Promise.all([getTeam(), getGroups()])
|
||||||
setLoading(false);
|
.then(([t, g]) => {
|
||||||
return;
|
setTeam(t);
|
||||||
}
|
setGroups(g.filter((x) => x.isActive));
|
||||||
fetchData();
|
setError("");
|
||||||
}, [fetchData, authLoading, user?.role]);
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Error al cargar");
|
||||||
|
setTeam([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [authLoading, user?.role]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -76,6 +82,10 @@ export default function TeamPage() {
|
|||||||
setModalError("El ID de usuario es requerido (debe existir en SAM)");
|
setModalError("El ID de usuario es requerido (debe existir en SAM)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (role === "supervisor" && !departmentId) {
|
||||||
|
setModalError("El supervisor requiere un departamento asignado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setModalError("");
|
setModalError("");
|
||||||
try {
|
try {
|
||||||
@@ -181,6 +191,8 @@ export default function TeamPage() {
|
|||||||
<td>
|
<td>
|
||||||
{m.role === "admin" ? (
|
{m.role === "admin" ? (
|
||||||
<span className="badge bg-primary">Admin</span>
|
<span className="badge bg-primary">Admin</span>
|
||||||
|
) : m.role === "supervisor" ? (
|
||||||
|
<span className="badge bg-warning text-dark">Supervisor</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="badge bg-info text-dark">Agente</span>
|
<span className="badge bg-info text-dark">Agente</span>
|
||||||
)}
|
)}
|
||||||
@@ -248,11 +260,14 @@ export default function TeamPage() {
|
|||||||
<label className="form-label">Rol</label>
|
<label className="form-label">Rol</label>
|
||||||
<select className="form-select" value={role} onChange={(e) => setRole(e.target.value)}>
|
<select className="form-select" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||||
<option value="agent">Agente</option>
|
<option value="agent">Agente</option>
|
||||||
|
<option value="supervisor">Supervisor (su departamento)</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<label className="form-label">Departamento</label>
|
<label className="form-label">
|
||||||
|
Departamento{role === "supervisor" ? " *" : ""}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
value={departmentId}
|
value={departmentId}
|
||||||
@@ -265,6 +280,9 @@ export default function TeamPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{role === "supervisor" && (
|
||||||
|
<div className="form-text">Requerido: el supervisor solo ve su departamento.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{editing && (
|
{editing && (
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export default function TicketDetailPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isTeam = user?.role === "admin" || user?.role === "agent";
|
const isTeam =
|
||||||
|
user?.role === "admin" || user?.role === "agent" || user?.role === "supervisor";
|
||||||
|
|
||||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
@@ -92,8 +93,8 @@ export default function TicketDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await updateTicketStatus(id, newStatus);
|
await updateTicketStatus(id, newStatus);
|
||||||
await refreshTicket();
|
await refreshTicket();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "No se pudo cambiar el estado");
|
setError(err instanceof Error ? err.message : "No se pudo cambiar el estado");
|
||||||
} finally {
|
} finally {
|
||||||
setActing(false);
|
setActing(false);
|
||||||
}
|
}
|
||||||
@@ -107,8 +108,8 @@ export default function TicketDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await assignTicket(id, ticket.assigneeId ?? 0, groupId);
|
await assignTicket(id, ticket.assigneeId ?? 0, groupId);
|
||||||
await refreshTicket();
|
await refreshTicket();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "No se pudo asignar el departamento");
|
setError(err instanceof Error ? err.message : "No se pudo asignar el departamento");
|
||||||
} finally {
|
} finally {
|
||||||
setActing(false);
|
setActing(false);
|
||||||
}
|
}
|
||||||
@@ -126,8 +127,8 @@ export default function TicketDetailPage() {
|
|||||||
ticket.groupId ?? undefined
|
ticket.groupId ?? undefined
|
||||||
);
|
);
|
||||||
await refreshTicket();
|
await refreshTicket();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "No se pudo asignar el agente");
|
setError(err instanceof Error ? err.message : "No se pudo asignar el agente");
|
||||||
} finally {
|
} finally {
|
||||||
setActing(false);
|
setActing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export default function NewTicketPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await createTicket({ title, description, priority, groupId: Number(groupId) });
|
const result = await createTicket({ title, description, priority, groupId: Number(groupId) });
|
||||||
router.push(`/tickets/${result.id}`);
|
router.push(`/tickets/${result.id}`);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "Error al crear el ticket");
|
setError(err instanceof Error ? err.message : "Error al crear el ticket");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { getTickets, type Ticket } from "@/lib/api";
|
import { getTickets, type Ticket } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
@@ -24,12 +24,11 @@ export default function TicketsPage() {
|
|||||||
|
|
||||||
const numericId = parseInt(user?.idUsuario ?? "", 10);
|
const numericId = parseInt(user?.idUsuario ?? "", 10);
|
||||||
const hasNumericId = !isNaN(numericId);
|
const hasNumericId = !isNaN(numericId);
|
||||||
const isTeam = user?.role === "admin" || user?.role === "agent";
|
const isTeam =
|
||||||
|
user?.role === "admin" || user?.role === "agent" || user?.role === "supervisor";
|
||||||
|
|
||||||
const fetchTickets = useCallback(async () => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
getTickets({
|
||||||
try {
|
|
||||||
const data = await getTickets({
|
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
priority: priorityFilter || undefined,
|
priority: priorityFilter || undefined,
|
||||||
requesterId: view === "mine" || !isTeam ? (hasNumericId ? numericId : undefined) : undefined,
|
requesterId: view === "mine" || !isTeam ? (hasNumericId ? numericId : undefined) : undefined,
|
||||||
@@ -42,21 +41,18 @@ export default function TicketsPage() {
|
|||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
});
|
})
|
||||||
|
.then((data) => {
|
||||||
setTickets(data.items || []);
|
setTickets(data.items || []);
|
||||||
setTotal(data.total || 0);
|
setTotal(data.total || 0);
|
||||||
} catch {
|
})
|
||||||
|
.catch(() => {
|
||||||
setTickets([]);
|
setTickets([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
} finally {
|
})
|
||||||
setLoading(false);
|
.finally(() => setLoading(false));
|
||||||
}
|
|
||||||
}, [statusFilter, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]);
|
}, [statusFilter, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTickets();
|
|
||||||
}, [fetchTickets]);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +65,20 @@ export default function TicketsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user?.role === "supervisor" && (
|
||||||
|
<div className="alert alert-warning py-2" role="alert">
|
||||||
|
<i className="bi bi-eye me-2"></i>
|
||||||
|
Viendo solo el departamento
|
||||||
|
{user?.departmentName ? (
|
||||||
|
<>
|
||||||
|
: <strong>{user.departmentName}</strong>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
" asignado"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
{isTeam ? (
|
{isTeam ? (
|
||||||
<div className="filter-bar mb-3">
|
<div className="filter-bar mb-3">
|
||||||
@@ -88,6 +98,7 @@ export default function TicketsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setView(v);
|
setView(v);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
setLoading(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -110,7 +121,7 @@ export default function TicketsPage() {
|
|||||||
className="form-select"
|
className="form-select"
|
||||||
style={{ width: "auto" }}
|
style={{ width: "auto" }}
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); setLoading(true); }}
|
||||||
>
|
>
|
||||||
<option value="">Todos los estados</option>
|
<option value="">Todos los estados</option>
|
||||||
<option value="Open">Abierto</option>
|
<option value="Open">Abierto</option>
|
||||||
@@ -124,7 +135,7 @@ export default function TicketsPage() {
|
|||||||
className="form-select"
|
className="form-select"
|
||||||
style={{ width: "auto" }}
|
style={{ width: "auto" }}
|
||||||
value={priorityFilter}
|
value={priorityFilter}
|
||||||
onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); }}
|
onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); setLoading(true); }}
|
||||||
>
|
>
|
||||||
<option value="">Todas las prioridades</option>
|
<option value="">Todas las prioridades</option>
|
||||||
<option value="Low">Baja</option>
|
<option value="Low">Baja</option>
|
||||||
@@ -141,7 +152,7 @@ export default function TicketsPage() {
|
|||||||
style={{ paddingLeft: "2rem" }}
|
style={{ paddingLeft: "2rem" }}
|
||||||
placeholder="Buscar tickets..."
|
placeholder="Buscar tickets..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); setPage(1); setLoading(true); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await login(idUsuario, password);
|
await login(idUsuario, password);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "Credenciales inválidas");
|
setError(err instanceof Error ? err.message : "Credenciales inválidas");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Route handler server-side para envío de correos vía banco único mail.*.
|
||||||
|
// El navegador NUNCA lleva la X-Api-Key: llama aquí (/api/correos) y este
|
||||||
|
// route llama a SAM.API (POST /api/email/send) con X-Module + X-Api-Key.
|
||||||
|
// Docs: sam/docs/email-modulos.md punto 7.
|
||||||
|
//
|
||||||
|
// Env (servidor, SIN prefijo NEXT_PUBLIC_):
|
||||||
|
// MAIL_API_KEY=clave entregada por POST /api/email/modules (solo servidor)
|
||||||
|
// MAIL_MODULE=helpdesk (opcional, default helpdesk)
|
||||||
|
// SAM_API_URL=https://api.devops.norteamericano.cl (default prod;
|
||||||
|
// en dev cae a NEXT_PUBLIC_API_URL)
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.SAM_API_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
"https://api.devops.norteamericano.cl";
|
||||||
|
const MODULE = process.env.MAIL_MODULE ?? "helpdesk";
|
||||||
|
|
||||||
|
type SendBody = {
|
||||||
|
to: string | string[];
|
||||||
|
templateKey?: string;
|
||||||
|
subject?: string;
|
||||||
|
htmlBody?: string;
|
||||||
|
vars?: Record<string, string | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
async function requireAdmin(req: Request): Promise<Response | null> {
|
||||||
|
const auth = req.headers.get("authorization") ?? "";
|
||||||
|
if (!auth.toLowerCase().startsWith("bearer ")) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Requiere sesión de admin" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const me = await fetch(`${API_BASE}/api/helpdesk/me`, {
|
||||||
|
headers: { Authorization: auth },
|
||||||
|
});
|
||||||
|
if (!me.ok) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Sesión inválida" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const user = (await me.json()) as { role?: string };
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "Requiere rol admin" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: `No se pudo verificar admin: ${String(err)}` },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
const denied = await requireAdmin(req);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const apiKey = process.env.MAIL_API_KEY ?? "";
|
||||||
|
if (!apiKey) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "MAIL_API_KEY no configurada en el servidor del módulo" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: SendBody;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as SendBody;
|
||||||
|
} catch {
|
||||||
|
return Response.json({ success: false, message: "JSON inválido" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = Array.isArray(body.to) ? body.to : [body.to];
|
||||||
|
const clean = [
|
||||||
|
...new Set(
|
||||||
|
raw.map((e) => String(e ?? "").trim()).filter((e) => EMAIL_RE.test(e))
|
||||||
|
),
|
||||||
|
].slice(0, 50);
|
||||||
|
if (clean.length === 0) {
|
||||||
|
return Response.json({ success: false, message: "Sin destinatarios válidos" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!body.templateKey && (!body.subject || !body.htmlBody)) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: "templateKey o subject+htmlBody requeridos" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(`${API_BASE}/api/email/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Module": MODULE,
|
||||||
|
"X-Api-Key": apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
to: clean.length === 1 ? clean[0] : clean,
|
||||||
|
templateKey: body.templateKey,
|
||||||
|
subject: body.subject,
|
||||||
|
htmlBody: body.htmlBody,
|
||||||
|
vars: body.vars ?? {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, message: `No se pudo contactar a SAM.API: ${String(err)}` },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown = null;
|
||||||
|
try {
|
||||||
|
data = await upstream.json();
|
||||||
|
} catch {
|
||||||
|
data = { success: false, message: `SAM.API respondió ${upstream.status} sin JSON` };
|
||||||
|
}
|
||||||
|
return Response.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter, Fira_Sans } from "next/font/google";
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthProvider } from "@/lib/auth";
|
import { AuthProvider } from "@/lib/auth";
|
||||||
@@ -9,6 +9,12 @@ const inter = Inter({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const firaSans = Fira_Sans({
|
||||||
|
variable: "--font-fira-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "HelpDesk ICHN",
|
title: "HelpDesk ICHN",
|
||||||
description: "Sistema de Gestión de Tickets de Soporte - ICHN",
|
description: "Sistema de Gestión de Tickets de Soporte - ICHN",
|
||||||
@@ -20,18 +26,14 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="es" className={`${inter.variable} h-full antialiased`}>
|
<html lang="es" className={`${inter.variable} ${firaSans.variable} h-full antialiased`}>
|
||||||
<head>
|
<head>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
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>
|
</head>
|
||||||
<body style={{ fontFamily: "'Fira Sans', sans-serif" }}>
|
<body style={{ fontFamily: "var(--font-fira-sans), sans-serif" }}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ export default function Sidebar() {
|
|||||||
const fullName = user ? `${user.nombres} ${user.apPaterno}` : "Usuario";
|
const fullName = user ? `${user.nombres} ${user.apPaterno}` : "Usuario";
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === "admin";
|
||||||
const roleLabel =
|
const roleLabel =
|
||||||
user?.role === "admin" ? "Administrador" : user?.role === "agent" ? "Agente" : "Usuario";
|
user?.role === "admin"
|
||||||
|
? "Administrador"
|
||||||
|
: user?.role === "supervisor"
|
||||||
|
? "Supervisor"
|
||||||
|
: user?.role === "agent"
|
||||||
|
? "Agente"
|
||||||
|
: "Usuario";
|
||||||
const initials = user?.nombres
|
const initials = user?.nombres
|
||||||
?.split(" ")
|
?.split(" ")
|
||||||
.map((n: string) => n[0])
|
.map((n: string) => n[0])
|
||||||
|
|||||||
+77
-8
@@ -339,18 +339,20 @@ export async function updateSla(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Correo / plantillas ====================
|
// ==================== Correo / plantillas (banco único mail.* vía /api/email/*) ====================
|
||||||
export interface EmailTemplate {
|
export interface EmailTemplate {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
bodyHtml: string;
|
bodyHtml: string;
|
||||||
|
module?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailLogEntry {
|
export interface EmailLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
template: string;
|
template: string;
|
||||||
|
module?: string;
|
||||||
ticketId?: number;
|
ticketId?: number;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -359,28 +361,95 @@ export interface EmailLogEntry {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmailModule {
|
||||||
|
module: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getEmailTemplates() {
|
export async function getEmailTemplates() {
|
||||||
return fetchApi<EmailTemplate[]>("/api/helpdesk/email/templates");
|
return fetchApi<EmailTemplate[]>("/api/email/templates");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEmailTemplate(data: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
module?: string;
|
||||||
|
}) {
|
||||||
|
return fetchApi<{ key: string }>("/api/email/templates", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmailTemplate(
|
export async function updateEmailTemplate(
|
||||||
key: string,
|
key: string,
|
||||||
data: { subject?: string; bodyHtml?: string; isActive?: boolean }
|
data: { subject?: string; bodyHtml?: string; isActive?: boolean }
|
||||||
) {
|
) {
|
||||||
return fetchApi<void>(`/api/helpdesk/email/templates/${encodeURIComponent(key)}`, {
|
return fetchApi<void>(`/api/email/templates/${encodeURIComponent(key)}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEmailLog(limit = 50) {
|
export async function getEmailLog(limit = 50, module = "helpdesk") {
|
||||||
return fetchApi<EmailLogEntry[]>(`/api/helpdesk/email/log?limit=${limit}`);
|
const query = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (module) query.set("module", module);
|
||||||
|
return fetchApi<EmailLogEntry[]>(`/api/email/log?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmailModules() {
|
||||||
|
return fetchApi<EmailModule[]>("/api/email/modules");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerEmailModule(module: string) {
|
||||||
|
return fetchApi<{ module: string; apiKey: string }>("/api/email/modules", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ module }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeEmailModule(module: string) {
|
||||||
|
return fetchApi<{ module: string }>(`/api/email/modules/${encodeURIComponent(module)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envío vía route handler propio (/api/correos) → POST /api/email/send con
|
||||||
|
// X-Module + X-Api-Key en el servidor (la clave nunca va en el bundle).
|
||||||
|
// Reenvía el Bearer de admin para que el route verifique el rol.
|
||||||
|
export async function sendModuleEmail(data: {
|
||||||
|
to: string | string[];
|
||||||
|
templateKey?: string;
|
||||||
|
subject?: string;
|
||||||
|
htmlBody?: string;
|
||||||
|
vars?: Record<string, string | null>;
|
||||||
|
}) {
|
||||||
|
const token =
|
||||||
|
typeof window !== "undefined" ? localStorage.getItem("helpdesk_token") : null;
|
||||||
|
const res = await fetch("/api/correos", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
(payload as { message?: string }).message || `Error de envío: ${res.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payload as { success: boolean; sent?: number; failed?: number; message?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendTestEmail(to: string) {
|
export async function sendTestEmail(to: string) {
|
||||||
return fetchApi<{ success: boolean; message?: string }>("/api/helpdesk/email/test", {
|
return sendModuleEmail({
|
||||||
method: "POST",
|
to,
|
||||||
body: JSON.stringify({ to }),
|
subject: "[HelpDesk] Prueba de correo",
|
||||||
|
htmlBody: `<p>Prueba de envío desde la Mesa de Ayuda ICHN (${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC).</p><p>Vía banco <code>mail.templates</code> + <code>POST /api/email/send</code> (módulo helpdesk).</p>`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
.catch(() => localStorage.removeItem("helpdesk_token"))
|
.catch(() => localStorage.removeItem("helpdesk_token"))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false);
|
// Diferido (no sincrónico en el effect) para no disparar cascading renders.
|
||||||
|
queueMicrotask(() => setIsLoading(false));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user