Compare commits

..
19 Commits
Author SHA1 Message Date
Nurfog 01ceb8c619 feat(ticket): restaurar tarjeta Descripción del lateral 2026-11-02 16:42:33 -03:00
Nurfog a2bcab9282 feat(ticket): quitar icono duplicado junto a pestañas Mensajes/Correos 2026-11-02 16:41:35 -03:00
Nurfog 318ac083e9 feat(ticket): quitar tarjeta Descripción duplicada del lateral 2026-11-02 16:40:12 -03:00
Nurfog b2112eb390 feat(respuesta): elegir marca, el botón ejecuta 2026-11-02 16:32:10 -03:00
Nurfog 7a3d146892 feat(respuesta): botón único de envío con opción visible 2026-11-02 16:27:57 -03:00
Nurfog b1710da477 feat(respuesta): botón Enviar con opciones (enviar, enviar y resuelto, enviar y cerrado) 2026-11-02 16:15:16 -03:00
Nurfog ce3fa29b69 feat: pestañas Mensajes/Correos, ocultar leídas, solicitud inicial, login al expirar, fusión de tickets (UI) 2026-11-02 15:00:22 -03:00
Nurfog f88d79e2e8 feat(respuesta): CC y adjuntos (10MB) con descarga en el hilo 2026-10-22 13:16:29 -03:00
Nurfog dd1e1a231a feat(tickets): sin botón archivar, Archivo visible a todos, dashboard coherente 2026-10-22 12:23:59 -03:00
Nurfog eace5fbcd7 fix(login): -X solo en usuario, contraseña intacta 2026-10-22 11:45:03 -03:00
Nurfog 4a84271f44 fix(login): quitar -X también del campo usuario 2026-10-22 11:44:18 -03:00
Nurfog 24dcb6d770 feat(tickets): filtro Todos/Activos/Archivados + botón archivar 2026-10-22 11:19:25 -03:00
Nurfog f536f11dfd feat(login): tolerar -X del RUT en clave, se quita al ingresar 2026-10-22 10:34:51 -03:00
Nurfog 580ecff258 fix: mostrar error de carga en vez de tabla vacía silenciosa 2026-10-22 10:12:01 -03:00
Nurfog 148d8b2550 fix: spinner eterno al marcar Todos en filtros de tickets 2026-10-22 10:02:11 -03:00
Nurfog 0a08fa5192 feat: notificaciones toast con sileo (pnpm) + aviso en campana 2026-10-22 10:02:11 -03:00
Nurfog 73e6e96d73 fix: reserva espacio de la flecha en selects de la barra de filtros 2026-10-13 11:09:45 -03:00
Nurfog 9e3c7b8059 feat: filtro de tickets por uno, varios o todos los estados 2026-10-13 10:56:48 -03:00
Nurfog c702a19c1e feat: dashboard solo para el equipo, usuarios van a tickets; altos uniformes 2026-10-06 12:47:29 -03:00
14 changed files with 1151 additions and 362 deletions

No files matched your search

+2 -2
View File
@@ -75,8 +75,8 @@ push a `main` → webhook Gitea → build + up en `.4`
Base: `NEXT_PUBLIC_API_URL/api/helpdesk`
- `POST /login {idUsuario, password}` · `GET /me`
- `GET /tickets?...` · `GET /ticket/{id}` · `POST /tickets` · `PUT /ticket/{id}` (+ `/status`, `/assign`) · `DELETE /ticket/{id}`
- `GET|POST /comments/{ticketId}`
- `GET /tickets?...` (con `archived=false` solo activos [default UI], `true` solo archivados, ausente todos) · `GET /ticket/{id}` · `POST /tickets` · `PUT /ticket/{id}` (+ `/status`, `/assign`, `/archive`) · `POST /ticket/{id}/merge {sourceId}` (mueve historial y cierra origen) · `DELETE /ticket/{id}`
- `GET|POST /comments/{ticketId}` (POST acepta `cc` y `attachmentIds`, solo públicas) · `POST /ticket/{id}/attachments` (multipart, máx 5 × 10 MB) · `GET /ticket/{id}/attachments` · `GET /attachments/{fileId}` (descarga)
- `GET /kb/published` · `GET /kb/search?q=` · `GET /kb/categories`
- `GET /stats`
- `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin)
+2 -1
View File
@@ -15,7 +15,8 @@
"next": "16.3.4",
"react": "^19.3.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.3.0"
"react-dom": "^19.3.0",
"sileo": "^0.1.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+178 -100
View File
@@ -19,7 +19,7 @@ importers:
version: 1.13.1
next:
specifier: 16.3.4
version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
version: 16.3.4(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react:
specifier: ^19.3.0
version: 19.3.0
@@ -29,6 +29,9 @@ importers:
react-dom:
specifier: ^19.3.0
version: 19.3.0(react@19.3.0)
sileo:
specifier: ^0.1.6
version: 0.1.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@@ -44,10 +47,10 @@ importers:
version: 19.2.7(@types/react@19.2.18)
eslint:
specifier: ^9
version: 9.39.5(jiti@2.7.0)
version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-config-next:
specifier: 16.3.4
version: 16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
version: 16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
tailwindcss:
specifier: ^4
version: 4.3.3
@@ -1275,6 +1278,20 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
framer-motion@12.43.0:
resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -1673,6 +1690,26 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
motion-dom@12.43.0:
resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
motion@12.43.0:
resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1961,6 +1998,12 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
sileo@0.1.6:
resolution: {integrity: sha512-a1IIJ72BMskiXfbWlO+eTWu1yxz7wQtFIW7SvyaAax0WHqPi/WUsQH9yKvbWo2dDSxel4LdGRLYLzJCIIE6eHA==}
peerDependencies:
react: '>=18'
react-dom: '>=18'
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2173,20 +2216,20 @@ snapshots:
'@babel/compat-data@7.29.7': {}
'@babel/core@7.29.7':
'@babel/core@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
'@babel/traverse': 7.29.8
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -2211,19 +2254,19 @@ snapshots:
'@babel/helper-globals@7.29.7': {}
'@babel/helper-module-imports@7.29.7':
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/traverse': 7.29.8
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-module-imports': 7.29.7
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.8
'@babel/traverse': 7.29.8(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -2250,7 +2293,7 @@ snapshots:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
'@babel/traverse@7.29.8':
'@babel/traverse@7.29.8(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
@@ -2258,7 +2301,7 @@ snapshots:
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
'@babel/types': 7.29.8
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -2288,22 +2331,22 @@ snapshots:
tslib: 2.8.1
optional: true
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))':
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
eslint: 9.39.5(jiti@2.7.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))':
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
eslint: 9.39.5(jiti@2.7.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.21.2':
'@eslint/config-array@0.21.2(supports-color@7.2.0)':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -2316,10 +2359,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/eslintrc@3.3.7':
'@eslint/eslintrc@3.3.7(supports-color@7.2.0)':
dependencies:
ajv: 6.15.0
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -2502,9 +2545,9 @@ snapshots:
'@next/env@16.3.4': {}
'@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))':
'@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
fast-glob: 3.3.1
transitivePeerDependencies:
- eslint
@@ -2698,15 +2741,15 @@ snapshots:
'@types/warning@3.0.4': {}
'@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.70.0
'@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.70.0
eslint: 9.39.5(jiti@2.7.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
ignore: 7.0.9
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -2714,23 +2757,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.70.0
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.70.0
debug: 4.4.3
eslint: 9.39.5(jiti@2.7.0)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.70.0(typescript@5.9.3)':
'@typescript-eslint/project-service@8.70.0(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3)
'@typescript-eslint/types': 8.70.0
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -2744,13 +2787,13 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/type-utils@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.5(jiti@2.7.0)
'@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -2758,13 +2801,13 @@ snapshots:
'@typescript-eslint/types@8.70.0': {}
'@typescript-eslint/typescript-estree@8.70.0(typescript@5.9.3)':
'@typescript-eslint/typescript-estree@8.70.0(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.70.0(typescript@5.9.3)
'@typescript-eslint/project-service': 8.70.0(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3)
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/visitor-keys': 8.70.0
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
@@ -2773,13 +2816,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
'@typescript-eslint/utils@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
'@typescript-eslint/scope-manager': 8.70.0
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)
'@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -3066,13 +3109,17 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
debug@3.2.7:
debug@3.2.7(supports-color@7.2.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
debug@4.4.3:
debug@4.4.3(supports-color@7.2.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
deep-is@0.1.4: {}
@@ -3231,18 +3278,18 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-next@16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
eslint-config-next@16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0))
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0))
'@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
globals: 16.4.0
typescript-eslint: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
typescript-eslint: 8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -3251,52 +3298,52 @@ snapshots:
- eslint-plugin-import-x
- supports-color
eslint-import-resolver-node@0.3.10:
eslint-import-resolver-node@0.3.10(supports-color@7.2.0):
dependencies:
debug: 3.2.7
debug: 3.2.7(supports-color@7.2.0)
is-core-module: 2.16.2
resolve: 2.0.0-next.7
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
eslint: 9.39.5(jiti@2.7.0)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
get-tsconfig: 4.14.3
is-bun-module: 2.0.0
stable-hash: 0.0.5
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
debug: 3.2.7
debug: 3.2.7(supports-color@7.2.0)
optionalDependencies:
'@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
'@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
array.prototype.findlastindex: 1.2.6
array.prototype.flat: 1.3.3
array.prototype.flatmap: 1.3.3
debug: 3.2.7
debug: 3.2.7(supports-color@7.2.0)
doctrine: 2.1.0
eslint: 9.39.5(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -3308,13 +3355,13 @@ snapshots:
string.prototype.trimend: 1.0.10
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)):
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -3324,7 +3371,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
eslint: 9.39.5(jiti@2.7.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
hasown: 2.0.4
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -3333,18 +3380,18 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)):
eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@babel/core': 7.29.7
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/parser': 7.29.8
eslint: 9.39.5(jiti@2.7.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
hermes-parser: 0.25.1
zod: 4.5.4
zod-validation-error: 4.0.2(zod@4.5.4)
transitivePeerDependencies:
- supports-color
eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)):
eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -3352,7 +3399,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.4.0
eslint: 9.39.5(jiti@2.7.0)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
estraverse: 5.3.0
hasown: 2.0.4
jsx-ast-utils: 3.3.5
@@ -3377,14 +3424,14 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@9.39.5(jiti@2.7.0):
eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0):
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-array': 0.21.2(supports-color@7.2.0)
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.7
'@eslint/eslintrc': 3.3.7(supports-color@7.2.0)
'@eslint/js': 9.39.5
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
@@ -3394,7 +3441,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -3482,6 +3529,15 @@ snapshots:
dependencies:
is-callable: 1.2.7
framer-motion@12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
motion-dom: 12.43.0
motion-utils: 12.39.0
tslib: 2.8.1
optionalDependencies:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
function-bind@1.1.2: {}
function.prototype.name@1.2.0:
@@ -3859,6 +3915,20 @@ snapshots:
minimist@1.2.8: {}
motion-dom@12.43.0:
dependencies:
motion-utils: 12.39.0
motion-utils@12.39.0: {}
motion@12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
framer-motion: 12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
tslib: 2.8.1
optionalDependencies:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
ms@2.1.3: {}
nanoid@3.3.18: {}
@@ -3867,7 +3937,7 @@ snapshots:
natural-compare@1.4.0: {}
next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
next@16.3.4(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
'@next/env': 16.3.4
'@swc/helpers': 0.5.23
@@ -3876,7 +3946,7 @@ snapshots:
postcss: 8.5.23
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0)
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.3.4
'@next/swc-darwin-x64': 16.3.4
@@ -4233,6 +4303,14 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
sileo@0.1.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
motion: 12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
transitivePeerDependencies:
- '@emotion/is-prop-valid'
source-map-js@1.2.1: {}
stable-hash@0.0.5: {}
@@ -4297,12 +4375,12 @@ snapshots:
strip-json-comments@3.1.1: {}
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.3.0):
styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.0):
dependencies:
client-only: 0.0.1
react: 19.3.0
optionalDependencies:
'@babel/core': 7.29.7
'@babel/core': 7.29.7(supports-color@7.2.0)
supports-color@7.2.0:
dependencies:
@@ -4373,13 +4451,13 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typescript-eslint@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
typescript-eslint@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)
'@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
+21 -124
View File
@@ -2,60 +2,46 @@
import { useEffect, useState } from "react";
import StatsCard from "@/components/StatsCard";
import BadgeStatus from "@/components/BadgeStatus";
import BadgePriority from "@/components/BadgePriority";
import { getTicketStats, getTickets, type DashboardStats, type Ticket } from "@/lib/api";
import { getTicketStats, type DashboardStats } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { formatDate } from "@/lib/format";
import Link from "next/link";
import { useRouter } from "next/navigation";
const emptyStats: DashboardStats = {
totalTickets: 0,
openTickets: 0,
inProgressTickets: 0,
resolvedTickets: 0,
closedTickets: 0,
unassignedTickets: 0,
criticalTickets: 0,
dailyCounts: [],
};
export default function DashboardPage() {
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [myTickets, setMyTickets] = useState<Ticket[]>([]);
const [myTotal, setMyTotal] = useState(0);
// Equipo (ve todo o su área) vs usuario normal (solo sus tickets).
// El backend ya filtra stats y tickets por solicitante cuando no es equipo.
// Dashboard solo para el equipo. Los usuarios normales solo ven
// Tickets y Base de Conocimientos: se les redirige a sus tickets.
const isTeam =
user?.role === "admin" || user?.role === "agent" || user?.role === "supervisor";
useEffect(() => {
if (authLoading) return;
if (!isTeam) {
router.replace("/tickets");
return;
}
getTicketStats()
.then(setStats)
.catch(() => {
setStats(emptyStats);
setStats({
totalTickets: 0,
openTickets: 0,
inProgressTickets: 0,
resolvedTickets: 0,
closedTickets: 0,
unassignedTickets: 0,
criticalTickets: 0,
dailyCounts: [],
});
})
.finally(() => setLoading(false));
if (!isTeam) {
getTickets({ page: 1, pageSize: 10 })
.then((data) => {
setMyTickets(data.items || []);
setMyTotal(data.total || 0);
})
.catch(() => {
setMyTickets([]);
setMyTotal(0);
});
}
}, [authLoading, isTeam]);
}, [authLoading, isTeam, router]);
if (authLoading || loading) {
if (authLoading || loading || !isTeam) {
return (
<div className="loading-spinner">
<div className="spinner-border text-primary" role="status"></div>
@@ -63,95 +49,6 @@ export default function DashboardPage() {
);
}
// Usuario normal: sin mensaje de permisos, solo sus tickets.
if (!isTeam) {
return (
<div>
<div className="page-header">
<h1>Mis Tickets</h1>
<Link href="/tickets/new" className="btn btn-primary">
<i className="bi bi-plus-lg me-2"></i>
Nuevo Ticket
</Link>
</div>
<div className="row g-3 mb-4">
<div className="col-6 col-lg">
<StatsCard icon="bi-ticket-detailed" value={stats?.totalTickets || 0} label="Mis Tickets" color="#00285d" href="/tickets" />
</div>
<div className="col-6 col-lg">
<StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" href="/tickets?status=Open" />
</div>
<div className="col-6 col-lg">
<StatsCard icon="bi-clock-history" value={stats?.inProgressTickets || 0} label="En Progreso" color="#ffc107" href="/tickets?status=InProgress" />
</div>
<div className="col-6 col-lg">
<StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" href="/tickets?status=Resolved" />
</div>
<div className="col-6 col-lg">
<StatsCard icon="bi-exclamation-triangle" value={stats?.criticalTickets || 0} label="Críticos" color="#dc3545" href="/tickets?priority=Critical" />
</div>
</div>
<div className="card">
<div className="card-header d-flex align-items-center justify-content-between">
<span>
<i className="bi bi-ticket-detailed me-2" style={{ color: "var(--primary)" }}></i>
Últimos tickets ({myTotal})
</span>
<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>Estado</th>
<th>Prioridad</th>
<th>Actualizado</th>
</tr>
</thead>
<tbody>
{myTickets.length === 0 ? (
<tr>
<td colSpan={5} className="empty-state">
<i className="bi bi-inbox d-block"></i>
<h5>Sin tickets</h5>
<p className="text-muted mb-0">Aún no has creado ningún ticket</p>
</td>
</tr>
) : (
myTickets.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.title}</div>
</td>
<td><BadgeStatus status={ticket.status} /></td>
<td><BadgePriority priority={ticket.priority} /></td>
<td className="text-muted" style={{ fontSize: "0.85rem" }}>
{formatDate(ticket.updatedAt)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
const maxDaily = Math.max(...(stats?.dailyCounts?.map((d) => d.count) || [1]));
return (
@@ -186,7 +83,7 @@ export default function DashboardPage() {
{/* Stats Cards: cada tarjeta filtra la lista de tickets según lo presionado */}
<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" href="/tickets" />
<StatsCard icon="bi-ticket-detailed" value={stats?.totalTickets || 0} label="Total Tickets" color="#00285d" href="/tickets?archived=all" />
</div>
<div className="col-6 col-lg">
<StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" href="/tickets?status=Open" />
@@ -195,7 +92,7 @@ export default function DashboardPage() {
<StatsCard icon="bi-clock-history" value={stats?.inProgressTickets || 0} label="En Progreso" color="#ffc107" href="/tickets?status=InProgress" />
</div>
<div className="col-6 col-lg">
<StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" href="/tickets?status=Resolved" />
<StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" href="/tickets?status=Resolved&archived=all" />
</div>
<div className="col-6 col-lg">
<StatsCard icon="bi-exclamation-triangle" value={stats?.criticalTickets || 0} label="Críticos" color="#dc3545" href="/tickets?priority=Critical" />
+451 -92
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import {
getTicket,
@@ -9,18 +9,26 @@ import {
getTicketEmails,
addComment,
updateTicketStatus,
mergeTickets,
assignTicket,
getGroups,
getTeam,
uploadTicketAttachments,
getTicketAttachments,
downloadAttachment,
formatFileSize,
type Ticket,
type Comment,
type Group,
type TeamMember,
type TicketAttachment,
type TicketSentEmail,
type TicketReceivedEmail,
} from "@/lib/api";
import BadgeStatus from "@/components/BadgeStatus";
import BadgePriority from "@/components/BadgePriority";
import Modal from "@/components/Modal";
import { notify } from "@/lib/notify";
import { formatDateTime } from "@/lib/format";
import {
extractLatestMessage,
@@ -45,7 +53,7 @@ const STATUS_OPTIONS = [
// Lado del chat: equipo (agentes/admin/supervisor) a la derecha,
// solicitante a la izquierda, como en un chat.
type ThreadItem =
| { kind: "comment"; key: string; createdAt: string; userId: string; userName?: string; content: string; isInternal: boolean; side: "team" | "customer" }
| { kind: "comment"; key: string; id: number; createdAt: string; userId: string; userName?: string; content: string; isInternal: boolean; side: "team" | "customer" }
| { kind: "sent"; key: string; createdAt: string; who: string; subject: string; body?: string; fallback: string; side: "team" }
| { kind: "received"; key: string; createdAt: string; who: string; subject: string; body?: string; status: string; error?: string; side: "customer" };
@@ -69,6 +77,7 @@ function buildThread(
.map((c): ThreadItem => ({
kind: "comment",
key: `comment-${c.id}`,
id: c.id,
createdAt: c.createdAt,
userId: c.userId,
userName: c.userName,
@@ -186,6 +195,7 @@ function EmailBody({ body, isHtml }: { body: string; isHtml: boolean }) {
export default function TicketDetailPage() {
const params = useParams();
const router = useRouter();
const id = Number(params.id);
const { user } = useAuth();
const isTeam =
@@ -200,11 +210,41 @@ export default function TicketDetailPage() {
const [loading, setLoading] = useState(true);
const [comment, setComment] = useState("");
const [isInternal, setIsInternal] = useState(false);
const [cc, setCc] = useState("");
const [files, setFiles] = useState<File[]>([]);
// Ids ya subidos pendientes de vincular (si el envío del comentario falló).
const [pendingIds, setPendingIds] = useState<number[]>([]);
const [ticketFiles, setTicketFiles] = useState<TicketAttachment[]>([]);
const [submitting, setSubmitting] = useState(false);
const [acting, setActing] = useState(false);
const [error, setError] = useState("");
const [statusOpen, setStatusOpen] = useState(false);
const [assignOpen, setAssignOpen] = useState(false);
const [tab, setTab] = useState<"mensajes" | "correos">("mensajes");
const [mergeOpen, setMergeOpen] = useState(false);
const [mergeTarget, setMergeTarget] = useState("");
const [mergeError, setMergeError] = useState("");
const [merging, setMerging] = useState(false);
const [sendMenuOpen, setSendMenuOpen] = useState(false);
const [sendAction, setSendAction] = useState<"send" | "resolved" | "closed">("send");
const SEND_ACTIONS = {
send: { label: "Enviar", icon: "bi-send", hint: "Solo publica el mensaje" },
resolved: { label: "Enviar y resuelto", icon: "bi-check-circle", hint: "Publica y marca resuelto" },
closed: { label: "Enviar y cerrado", icon: "bi-archive", hint: "Publica y archiva el ticket" },
} as const;
const handleAddComment = (e: React.FormEvent) => {
e.preventDefault();
setSendMenuOpen(false);
void submitComment(sendAction === "send" ? undefined : sendAction);
};
const chooseSendAction = (action: "send" | "resolved" | "closed") => {
// Solo marca la opción; se ejecuta al presionar el botón.
setSendAction(action);
setSendMenuOpen(false);
};
useEffect(() => {
Promise.all([
@@ -213,14 +253,16 @@ export default function TicketDetailPage() {
getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
getGroups().catch(() => []),
getTeam().catch(() => []),
getTicketAttachments(id).catch(() => []),
])
.then(([ticketData, commentsData, emailsData, groupsData, teamData]) => {
.then(([ticketData, commentsData, emailsData, groupsData, teamData, filesData]) => {
setTicket(ticketData);
setComments(commentsData);
setSentEmails(emailsData.sent ?? []);
setReceivedEmails(emailsData.received ?? []);
setGroups(groupsData);
setTeam(teamData);
setTicketFiles(filesData ?? []);
})
.finally(() => setLoading(false));
}, [id]);
@@ -230,28 +272,79 @@ export default function TicketDetailPage() {
if (updated) setTicket(updated);
};
const handleAddComment = async (e: React.FormEvent) => {
e.preventDefault();
// Aviso al llegar desde una fusión (?merged=N): toast una vez y limpia la URL.
useEffect(() => {
const q = new URLSearchParams(window.location.search);
if (q.get("merged") !== null) {
const n = Number(q.get("merged") || 0);
notify.success(
"Tickets fusionados",
n > 0 ? `Se movieron ${n} elemento(s) a este ticket.` : undefined
);
window.history.replaceState(null, "", window.location.pathname);
}
}, []);
const MAX_FILE_BYTES = 10 * 1024 * 1024;
// Envía el comentario y, opcionalmente, resuelve o cierra el ticket.
const submitComment = async (after?: "resolved" | "closed") => {
if (!comment.trim()) return;
// Solo se envía/guarda el mensaje escrito, sin historial pegado o
// citado (se usa para revisar casos en el futuro).
const cleaned = cleanReplyForSend(comment);
if (!cleaned) return;
if (files.length > 5) {
setError("Máximo 5 archivos por respuesta");
return;
}
const tooBig = files.find((f) => f.size > MAX_FILE_BYTES);
if (tooBig) {
setError(`${tooBig.name}: supera los 10 MB`);
return;
}
setSubmitting(true);
setError("");
let newIds: number[] = [];
try {
await addComment(id, cleaned, isInternal);
const [updated, emails] = await Promise.all([
// 1) Subir adjuntos (si hay) y 2) publicar el comentario con CC e ids.
if (files.length > 0) {
const up = await uploadTicketAttachments(id, files);
newIds = up.files.map((f) => f.id);
}
const ids = [...pendingIds, ...newIds];
await addComment(id, cleaned, isInternal, isInternal ? undefined : cc.trim() || undefined, ids.length > 0 ? ids : undefined);
const [updated, emails, atts] = await Promise.all([
getComments(id).catch(() => []),
getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
getTicketAttachments(id).catch(() => []),
]);
setComments(updated);
setSentEmails(emails.sent ?? []);
setReceivedEmails(emails.received ?? []);
setTicketFiles(atts ?? []);
setComment("");
setIsInternal(false);
} catch {
setError("No se pudo enviar el mensaje");
setCc("");
setFiles([]);
setPendingIds([]);
if (after) {
try {
await updateTicketStatus(id, after);
await refreshTicket();
notify.success(after === "resolved" ? "Ticket resuelto" : "Ticket cerrado");
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "El mensaje se envió, pero no se pudo cambiar el estado");
}
}
} catch (err: unknown) {
// Si la subida salió pero el comentario falló, los ids quedan
// pendientes y se vinculan en el próximo envío.
if (newIds.length > 0) {
setPendingIds((prev) => [...new Set([...prev, ...newIds])]);
setFiles([]);
}
setError(err instanceof Error ? err.message : "No se pudo enviar el mensaje");
} finally {
setSubmitting(false);
}
@@ -286,6 +379,38 @@ export default function TicketDetailPage() {
}
};
const openMerge = () => {
setMergeTarget("");
setMergeError("");
setStatusOpen(false);
setAssignOpen(false);
setMergeOpen(true);
};
const handleMerge = async () => {
// Acepta "HD-000123" o "123".
const targetId = Number(String(mergeTarget).replace(/\D/g, ""));
if (!targetId) {
setMergeError("Ingresa el número del ticket destino (ej: HD-000123)");
return;
}
if (targetId === id) {
setMergeError("Origen y destino deben ser distintos");
return;
}
setMerging(true);
setMergeError("");
try {
const res = await mergeTickets(targetId, id);
setMergeOpen(false);
router.push(`/tickets/${targetId}?merged=${res.merged.comments + res.merged.attachments}`);
} catch (err: unknown) {
setMergeError(err instanceof Error ? err.message : "No se pudo fusionar");
} finally {
setMerging(false);
}
};
const handleAssignAgent = async (idUsuario: string | null) => {
if (!ticket) return;
setAssignOpen(false);
@@ -338,6 +463,11 @@ export default function TicketDetailPage() {
<div className="d-flex gap-2 mt-2">
<BadgeStatus status={ticket.status} />
<BadgePriority priority={ticket.priority} />
{ticket.archived && (
<span className="badge bg-secondary" title="Ticket archivado">
<i className="bi bi-archive me-1"></i>Archivado
</span>
)}
</div>
</div>
{isTeam && (
@@ -437,6 +567,15 @@ export default function TicketDetailPage() {
</>
)}
</div>
{/* Fusionar este ticket en otro (mueve historial y cierra este) */}
<button
className="btn btn-outline-secondary btn-sm"
disabled={acting}
title="Fusionar en otro ticket"
onClick={openMerge}
>
<i className="bi bi-intersect me-1"></i>Fusionar
</button>
</div>
)}
</div>
@@ -452,90 +591,168 @@ export default function TicketDetailPage() {
{/* Conversación estilo chat: equipo a la derecha, solicitante a la izquierda */}
<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>
Conversación ({buildThread(ticket, comments, sentEmails, receivedEmails).length})
<small className="text-muted fw-normal ms-auto d-none d-md-inline">
<span className="chat-legend chat-legend-left me-2">Solicitante</span>
<span className="chat-legend chat-legend-right">Equipo</span>
</small>
<div className="card-header d-flex align-items-center gap-2 flex-wrap">
<ul className="nav nav-tabs card-header-tabs border-0" role="tablist">
<li className="nav-item" role="presentation">
<button
type="button"
role="tab"
aria-selected={tab === "mensajes"}
className={`nav-link ${tab === "mensajes" ? "active" : ""}`}
onClick={() => setTab("mensajes")}
>
<i className="bi bi-chat-dots me-1"></i>
Mensajes ({buildThread(ticket, comments, sentEmails, receivedEmails).filter((m) => m.kind === "comment").length + 1})
</button>
</li>
<li className="nav-item" role="presentation">
<button
type="button"
role="tab"
aria-selected={tab === "correos"}
className={`nav-link ${tab === "correos" ? "active" : ""}`}
onClick={() => setTab("correos")}
>
<i className="bi bi-envelope me-1"></i>
Correos ({buildThread(ticket, comments, sentEmails, receivedEmails).filter((m) => m.kind !== "comment").length})
</button>
</li>
</ul>
{tab === "mensajes" && (
<small className="text-muted fw-normal ms-auto d-none d-md-inline">
<span className="chat-legend chat-legend-left me-2">Solicitante</span>
<span className="chat-legend chat-legend-right">Equipo</span>
</small>
)}
</div>
<div className="card-body chat-thread" style={{ maxHeight: "600px", overflowY: "auto" }}>
{(() => {
const thread = buildThread(ticket, comments, sentEmails, receivedEmails);
if (thread.length === 0) {
const messageItems = thread.filter(
(m): m is Extract<ThreadItem, { kind: "comment" }> => m.kind === "comment"
);
const emailItems = thread.filter(
(m): m is Exclude<ThreadItem, { kind: "comment" }> => m.kind !== "comment"
);
if (tab === "correos") {
return (
<div className="text-center text-muted py-4">
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
Sin conversación aún
</div>
<>
{emailItems.length === 0 ? (
<div className="text-center text-muted py-4">
<i className="bi bi-envelope fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
Sin correos en este ticket
</div>
) : (
emailItems.map((m) => {
const isSent = m.kind === "sent";
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
return (
<div key={m.key} className={`comment ${alignClass}`}>
<div className="comment-bubble">
<div className="comment-author">
<span
className={`badge ${isSent ? "bg-primary" : "bg-success"} me-2`}
title={isSent ? "Enviado desde soporte" : "Respuesta recibida por correo"}
>
<i className={`bi ${isSent ? "bi-send" : "bi-inbox"}`}></i>
</span>
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
</div>
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
{isSent ? (
m.body ? (
<EmailBody body={m.body} isHtml={/<[a-z][\s\S]*>/i.test(m.body)} />
) : (
<div className="comment-text text-muted">Plantilla: {m.fallback}</div>
)
) : m.body ? (
<EmailBody body={m.body} isHtml={false} />
) : (
<div className="comment-text text-muted">
{m.status === "inserted"
? "Agregado como comentario"
: `Omitido: ${m.error || m.status}`}
</div>
)}
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
</div>
</div>
);
})
)}
<small className="text-muted d-block mt-2">
Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto
llegan a este hilo (ingesta cada 5 min). Los correos muestran solo el
último mensaje; usa “Ver mensaje completo” para el historial.
</small>
</>
);
}
return thread.map((m) => {
if (m.kind === "comment") {
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
return (
<div key={m.key} className={`comment ${alignClass} ${m.isInternal ? "comment-internal" : ""}`}>
<div className="comment-bubble">
<div className="comment-author">
{m.side === "team" ? (
<><i className="bi bi-headset me-1"></i>{m.userName || "Soporte"}</>
) : (
<><i className="bi bi-person me-1"></i>{m.userName || "Solicitante"}</>
)}
{m.isInternal && (
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
Nota Interna
</span>
)}
</div>
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
</div>
</div>
);
}
const isSent = m.kind === "sent";
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
return (
<div key={m.key} className={`comment ${alignClass}`}>
return (
<>
{/* Lo pedido en el ticket como primer mensaje de la conversación */}
<div className="comment comment-customer">
<div className="comment-bubble">
<div className="comment-author">
<span
className={`badge ${isSent ? "bg-primary" : "bg-success"} me-2`}
title={isSent ? "Enviado desde soporte" : "Respuesta recibida por correo"}
>
<i className={`bi ${isSent ? "bi-send" : "bi-inbox"}`}></i>
<><i className="bi bi-person me-1"></i>{ticket.requesterName || "Solicitante"}</>
<span className="badge bg-primary ms-2" style={{ fontSize: "0.65rem" }}>
Solicitud inicial
</span>
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
</div>
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
{isSent ? (
m.body ? (
<EmailBody body={m.body} isHtml={/<[a-z][\s\S]*>/i.test(m.body)} />
) : (
<div className="comment-text text-muted">Plantilla: {m.fallback}</div>
)
) : m.body ? (
<EmailBody body={m.body} isHtml={false} />
) : (
<div className="comment-text text-muted">
{m.status === "inserted"
? "Agregado como comentario"
: `Omitido: ${m.error || m.status}`}
</div>
)}
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{ticket.title}</div>
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{ticket.description || "Sin descripción"}</div>
<div className="comment-time">{formatDateTime(ticket.createdAt)}</div>
</div>
</div>
);
});
{messageItems.length === 0 ? (
<div className="text-center text-muted py-4">
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
Sin respuestas aún
</div>
) : (
messageItems.map((m) => {
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
return (
<div key={m.key} className={`comment ${alignClass} ${m.isInternal ? "comment-internal" : ""}`}>
<div className="comment-bubble">
<div className="comment-author">
{m.side === "team" ? (
<><i className="bi bi-headset me-1"></i>{m.userName || "Soporte"}</>
) : (
<><i className="bi bi-person me-1"></i>{m.userName || "Solicitante"}</>
)}
{m.isInternal && (
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
Nota Interna
</span>
)}
</div>
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
{ticketFiles.filter((a) => a.commentId === m.id).map((a) => (
<button
key={a.id}
type="button"
className="badge bg-light text-dark border d-flex align-items-center gap-1 mt-1"
style={{ fontSize: "0.72rem", cursor: "pointer" }}
title={`Descargar ${a.filename} (${formatFileSize(a.size)})`}
onClick={() => {
downloadAttachment(a).catch(() => setError("No se pudo descargar el adjunto"));
}}
>
<i className="bi bi-paperclip"></i>
{a.filename} ({formatFileSize(a.size)})
<i className="bi bi-download"></i>
</button>
))}
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
</div>
</div>
);
})
)}
</>
);
})()}
<small className="text-muted d-block mt-2">
Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto
llegan a este hilo (ingesta cada 5 min). Los correos muestran solo el
último mensaje; usa “Ver mensaje completo” para el historial.
</small>
</div>
</div>
@@ -559,6 +776,60 @@ export default function TicketDetailPage() {
</small>
)}
</div>
{!isInternal && (
<div className="mb-3">
<label className="form-label" style={{ fontSize: "0.85rem" }}>
<i className="bi bi-people me-1"></i>
CC (copia por correo, separados por coma)
</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="jefe@norteamericano.cl, otro@norteamericano.cl"
value={cc}
onChange={(e) => setCc(e.target.value)}
/>
</div>
)}
<div className="mb-3">
<label className="btn btn-outline-secondary btn-sm" style={{ cursor: "pointer" }}>
<i className="bi bi-paperclip me-1"></i>
Adjuntar archivos (máx. 10 MB c/u)
<input
type="file"
multiple
hidden
onChange={(e) => {
const picked = Array.from(e.target.files ?? []);
setFiles((prev) => [...prev, ...picked].slice(0, 5));
e.target.value = "";
}}
/>
</label>
{files.length > 0 && (
<div className="d-flex flex-wrap gap-2 mt-2">
{files.map((f, i) => (
<span key={`${f.name}-${i}`} className="badge bg-light text-dark border d-flex align-items-center gap-1" style={{ fontSize: "0.75rem" }}>
<i className="bi bi-file-earmark"></i>
{f.name} ({formatFileSize(f.size)})
<button
type="button"
className="btn-close btn-close-sm"
style={{ fontSize: "0.6rem" }}
aria-label="Quitar archivo"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
/>
</span>
))}
</div>
)}
{pendingIds.length > 0 && (
<small className="text-warning d-block mt-1">
<i className="bi bi-exclamation-triangle me-1"></i>
{pendingIds.length} archivo(s) ya subido(s): se vincularán al enviar.
</small>
)}
</div>
<div className="d-flex align-items-center justify-content-between">
<div className="form-check">
<input
@@ -573,19 +844,78 @@ export default function TicketDetailPage() {
Nota interna (solo 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>
{isTeam ? (
<div className="btn-group" role="group" aria-label="Opciones de envío">
<button
type="button"
className="btn btn-primary"
disabled={!comment.trim() || submitting}
title={`Enviar con: ${SEND_ACTIONS[sendAction].label}`}
onClick={() => void submitComment(sendAction === "send" ? undefined : sendAction)}
>
{submitting ? (
<span className="spinner-border spinner-border-sm" role="status"></span>
) : (
<>
<i className={`bi ${SEND_ACTIONS[sendAction].icon} me-2`}></i>
{SEND_ACTIONS[sendAction].label}
</>
)}
</button>
<button
type="button"
className="btn btn-primary dropdown-toggle dropdown-toggle-split"
disabled={!comment.trim() || submitting}
aria-label="Elegir modo de envío"
aria-expanded={sendMenuOpen}
onClick={() => setSendMenuOpen((o) => !o)}
>
<span className="visually-hidden">Elegir</span>
</button>
{sendMenuOpen && (
<>
<div
className="position-fixed top-0 start-0 w-100 h-100"
style={{ zIndex: 1000 }}
onClick={() => setSendMenuOpen(false)}
/>
<ul className="dropdown-menu show position-absolute end-0 bottom-100 mb-1" style={{ zIndex: 1001, minWidth: "230px" }}>
{(Object.keys(SEND_ACTIONS) as Array<keyof typeof SEND_ACTIONS>).map((key) => (
<li key={key}>
<button
type="button"
className={`dropdown-item ${sendAction === key ? "active" : ""}`}
disabled={submitting}
onClick={() => chooseSendAction(key)}
>
<i className={`bi ${SEND_ACTIONS[key].icon} me-2`}></i>
{SEND_ACTIONS[key].label}
{sendAction === key && <i className="bi bi-check-lg ms-2"></i>}
<small className="d-block text-muted">
{SEND_ACTIONS[key].hint}{key === "send" && !ticket?.assigneeId ? " (te lo asigna si está sin asignar)" : ""}
</small>
</button>
</li>
))}
</ul>
</>
)}
</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>
@@ -630,6 +960,35 @@ export default function TicketDetailPage() {
</div>
</div>
</div>
<Modal
show={mergeOpen}
title={`Fusionar ${ticket.ticketNumber} en otro ticket`}
onClose={() => setMergeOpen(false)}
onSubmit={handleMerge}
submitLabel="Fusionar"
submitting={merging}
>
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
Los mensajes, adjuntos y correos de este ticket se moverán al destino
y este ticket se cerrará. Esta acción no se puede deshacer.
</p>
<label className="form-label">Ticket destino</label>
<input
type="text"
className="form-control"
placeholder="HD-000123"
value={mergeTarget}
onChange={(e) => setMergeTarget(e.target.value)}
autoFocus
/>
{mergeError && (
<div className="alert alert-danger py-2 mt-3 mb-0" role="alert">
<i className="bi bi-exclamation-circle me-2"></i>
{mergeError}
</div>
)}
</Modal>
</div>
);
}
+227 -28
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useEffect, useRef, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { getTickets, type Ticket } from "@/lib/api";
import { useAuth } from "@/lib/auth";
@@ -12,6 +12,61 @@ import Link from "next/link";
type View = "all" | "mine" | "assigned" | "unassigned";
// Alcance de archivado: por defecto solo activos. "all" = todos,
// "active" = no archivados, "archived" = solo archivados.
type ArchiveScope = "all" | "active" | "archived";
function parseArchiveScope(v: string | null): ArchiveScope | null {
if (v === null) return null;
const k = v.trim().toLowerCase();
if (k === "all" || k === "todos") return "all";
if (k === "active" || k === "activos" || k === "false" || k === "0") return "active";
if (k === "archived" || k === "archivados" || k === "true" || k === "1") return "archived";
return null;
}
const STATUS_OPTIONS = [
{ value: "Open", label: "Abierto" },
{ value: "Pending", label: "Pendiente" },
{ value: "InProgress", label: "En Progreso" },
{ value: "Resolved", label: "Resuelto" },
{ value: "Closed", label: "Cerrado" },
] as const;
// Normaliza un valor de estado (URL, dashboard, backend) al canónico del filtro.
function canonStatus(v: string): string | null {
const k = v.trim().toLowerCase().replace(/_/g, "").replace(/\s+/g, "");
switch (k) {
case "open":
case "abierto":
case "nuevo":
case "1":
return "Open";
case "pending":
case "pendiente":
case "2":
return "Pending";
case "inprogress":
case "enprogreso":
case "3":
return "InProgress";
case "resolved":
case "resuelto":
case "4":
return "Resolved";
case "closed":
case "cerrado":
case "5":
return "Closed";
default:
return null;
}
}
function statusLabel(v: string): string {
return STATUS_OPTIONS.find((o) => o.value === v)?.label ?? v;
}
function TicketsContent() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -20,25 +75,53 @@ function TicketsContent() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [view, setView] = useState<View>("all");
const [statusFilter, setStatusFilter] = useState("");
const [archiveScope, setArchiveScope] = useState<ArchiveScope>("active");
// Vacío = Todos los estados. Con 1 o más = filtra por esos estados.
const [statusFilters, setStatusFilters] = useState<string[]>([]);
const [statusOpen, setStatusOpen] = useState(false);
const statusRef = useRef<HTMLDivElement>(null);
const [priorityFilter, setPriorityFilter] = useState("");
const [search, setSearch] = useState("");
const pageSize = 10;
// Cierra el desplegable de estados al hacer clic fuera.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (statusRef.current && !statusRef.current.contains(e.target as Node)) {
setStatusOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, []);
// Filtros iniciales desde la URL (los usa el Dashboard al presionar tarjetas).
// Acepta ?status=Open, ?status=Open,Resolved y ?status=Open&status=Resolved.
// Diferido (no sincrónico en el effect) para no disparar cascading renders.
useEffect(() => {
const status = searchParams.get("status") ?? "";
const raw = searchParams
.getAll("status")
.flatMap((v) => v.split(","))
.map((v) => canonStatus(v))
.filter((v): v is string => v !== null);
const unique = [...new Set(raw)];
// Si vienen todos marcados equivale a Todos (sin filtro).
const normalized =
unique.length >= STATUS_OPTIONS.length ? [] : unique;
const priority = searchParams.get("priority") ?? "";
const viewParam = searchParams.get("view") as View | null;
const archiveParam = parseArchiveScope(searchParams.get("archived"));
const q = searchParams.get("search") ?? "";
queueMicrotask(() => {
setStatusFilter(status);
setStatusFilters(normalized);
setPriorityFilter(priority);
if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") {
setView(viewParam);
}
// Sin ?archived en la URL el default es solo activos.
setArchiveScope(archiveParam ?? "active");
setSearch(q);
setPage(1);
});
@@ -50,8 +133,16 @@ function TicketsContent() {
user?.role === "admin" || user?.role === "agent" || user?.role === "supervisor";
useEffect(() => {
// El loading vive aquí (no en los manejadores): así siempre hay un
// fetch en curso que lo apaga en .finally(). Poner setLoading(true)
// en un onClick sin cambio real de estado dejaba spinner eterno.
// Diferido como el efecto de URL para no disparar cascading renders.
queueMicrotask(() => {
setLoading(true);
setLoadError("");
});
getTickets({
status: statusFilter || undefined,
status: statusFilters.length > 0 ? statusFilters : undefined,
priority: priorityFilter || undefined,
requesterId: view === "mine" || !isTeam ? (hasNumericId ? numericId : undefined) : undefined,
assigneeId:
@@ -60,6 +151,7 @@ function TicketsContent() {
: view === "unassigned"
? 0
: undefined,
archived: archiveScope === "all" ? undefined : archiveScope === "archived",
search: search || undefined,
page,
pageSize,
@@ -68,23 +160,53 @@ function TicketsContent() {
setTickets(data.items || []);
setTotal(data.total || 0);
})
.catch(() => {
.catch((err) => {
setTickets([]);
setTotal(0);
setLoadError(err instanceof Error ? err.message : "Error al cargar tickets");
})
.finally(() => setLoading(false));
}, [statusFilter, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]);
}, [statusFilters, priorityFilter, search, page, view, archiveScope, numericId, hasNumericId, isTeam]);
const totalPages = Math.ceil(total / pageSize);
const hasActiveFilters = statusFilter !== "" || priorityFilter !== "" || view !== "all" || search !== "";
const hasActiveFilters = statusFilters.length > 0 || priorityFilter !== "" || view !== "all" || search !== "" || archiveScope !== "active";
function archiveScopeLabel(s: ArchiveScope): string {
return s === "all" ? "Todos" : s === "archived" ? "Archivados" : "Activos";
}
const toggleStatus = (value: string) => {
setStatusFilters((prev) => {
const next = prev.includes(value)
? prev.filter((v) => v !== value)
: [...prev, value];
// Todos marcados equivale a Todos (sin filtro).
if (next.length >= STATUS_OPTIONS.length) return [];
return next;
});
setPage(1);
};
const selectAllStatuses = () => {
setStatusFilters([]);
setPage(1);
};
const statusButtonLabel =
statusFilters.length === 0
? "Todos los estados"
: statusFilters.length === 1
? statusLabel(statusFilters[0])
: `${statusFilters.length} estados`;
const clearFilters = () => {
setStatusFilter("");
setStatusFilters([]);
setStatusOpen(false);
setPriorityFilter("");
setView("all");
setArchiveScope("active");
setSearch("");
setPage(1);
setLoading(true);
router.replace("/tickets");
};
@@ -112,14 +234,22 @@ function TicketsContent() {
</div>
)}
{loadError && (
<div className="alert alert-danger py-2" role="alert">
<i className="bi bi-exclamation-triangle me-2"></i>
{loadError}
</div>
)}
{hasActiveFilters && (
<div className="alert alert-info py-2 d-flex align-items-center justify-content-between" role="alert">
<span>
<i className="bi bi-funnel-fill me-2"></i>
Vista filtrada
{statusFilter && <> · Estado: <strong>{statusFilter}</strong></>}
{statusFilters.length > 0 && <> · Estado: <strong>{statusFilters.map(statusLabel).join(", ")}</strong></>}
{priorityFilter && <> · Prioridad: <strong>{priorityFilter}</strong></>}
{view !== "all" && <> · Vista: <strong>{view === "unassigned" ? "Sin asignar" : view}</strong></>}
{archiveScope !== "active" && <> · Archivo: <strong>{archiveScopeLabel(archiveScope)}</strong></>}
{search && <> · Búsqueda: <strong>{search}</strong></>}
{" "}(desde el Dashboard)
</span>
@@ -132,6 +262,7 @@ function TicketsContent() {
{/* Filters */}
{isTeam ? (
<div className="filter-bar mb-3">
<span className="text-muted" style={{ fontSize: "0.8rem" }}>Vista:</span>
<div className="btn-group" role="group" aria-label="Vista">
{(
[
@@ -148,7 +279,6 @@ function TicketsContent() {
onClick={() => {
setView(v);
setPage(1);
setLoading(true);
}}
>
{label}
@@ -161,31 +291,93 @@ function TicketsContent() {
<span className="badge bg-primary">Mis tickets</span>
</div>
)}
{/* Archivo: visible para todos (solicitantes también ven sus cerrados) */}
<div className="filter-bar mb-3">
<span className="text-muted" style={{ fontSize: "0.8rem" }}>Archivo:</span>
<div className="btn-group" role="group" aria-label="Archivo">
{(
[
["all", "Todos"],
["active", "Activos"],
["archived", "Archivados"],
] as const
).map(([v, label]) => (
<button
key={v}
type="button"
className={`btn btn-sm ${archiveScope === v ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => {
setArchiveScope(v);
setPage(1);
}}
>
{label}
</button>
))}
</div>
</div>
<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); setLoading(true); }}
>
<option value="">Todos los estados</option>
<option value="Open">Abierto</option>
<option value="Pending">Pendiente</option>
<option value="InProgress">En Progreso</option>
<option value="Resolved">Resuelto</option>
<option value="Closed">Cerrado</option>
</select>
<div className="position-relative" ref={statusRef}>
<button
type="button"
className="form-select d-flex align-items-center justify-content-between gap-2"
style={{ width: "auto", minWidth: "190px", textAlign: "left" }}
aria-haspopup="listbox"
aria-expanded={statusOpen}
onClick={() => setStatusOpen((o) => !o)}
>
<span className="text-truncate">{statusButtonLabel}</span>
<i className={`bi ${statusOpen ? "bi-chevron-up" : "bi-chevron-down"} text-muted`}></i>
</button>
{statusOpen && (
<div
className="card shadow-sm position-absolute mt-1 p-2"
role="listbox"
aria-label="Filtrar por estado"
style={{ zIndex: 30, minWidth: "220px" }}
>
<label
className="d-flex align-items-center gap-2 px-2 py-1 rounded fw-semibold"
style={{ cursor: "pointer", fontSize: "0.85rem" }}
>
<input
type="checkbox"
className="form-check-input m-0"
checked={statusFilters.length === 0}
onChange={selectAllStatuses}
/>
Todos
</label>
<hr className="my-1" />
{STATUS_OPTIONS.map((opt) => (
<label
key={opt.value}
className="d-flex align-items-center gap-2 px-2 py-1 rounded"
style={{ cursor: "pointer", fontSize: "0.85rem" }}
>
<input
type="checkbox"
className="form-check-input m-0"
checked={statusFilters.includes(opt.value)}
onChange={() => toggleStatus(opt.value)}
/>
{opt.label}
</label>
))}
</div>
)}
</div>
<select
className="form-select"
style={{ width: "auto" }}
value={priorityFilter}
onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); setLoading(true); }}
onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); }}
>
<option value="">Todas las prioridades</option>
<option value="Low">Baja</option>
@@ -202,7 +394,7 @@ function TicketsContent() {
style={{ paddingLeft: "2rem" }}
placeholder="Buscar tickets..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); setLoading(true); }}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
</div>
@@ -250,7 +442,14 @@ function TicketsContent() {
<div className="fw-medium">{ticket.title}</div>
</td>
<td>{ticket.requesterName || "N/A"}</td>
<td><BadgeStatus status={ticket.status} /></td>
<td>
<BadgeStatus status={ticket.status} />
{ticket.archived && (
<span className="badge bg-secondary ms-1" title="Ticket archivado">
<i className="bi bi-archive me-1"></i>Archivado
</span>
)}
</td>
<td><BadgePriority priority={ticket.priority} /></td>
<td>{ticket.assigneeName || <span className="text-muted">Sin asignar</span>}</td>
<td className="text-muted" style={{ fontSize: "0.85rem" }}>
+6 -1
View File
@@ -17,7 +17,9 @@ export default function LoginPage() {
setError("");
setLoading(true);
try {
await login(idUsuario, password);
// El usuario es el RUT sin verificador. Se tolera que escriban
// el "-X" final y se quita al ingresar. La contraseña va tal cual.
await login(idUsuario.trim().replace(/-[0-9kK]$/, ""), password);
router.push("/");
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Credenciales inválidas");
@@ -84,6 +86,9 @@ export default function LoginPage() {
required
autoFocus
/>
<div className="form-text">
Tu RUT sin dígito verificador (ej: 14399848). Si escribes el -X final, se ignora.
</div>
</div>
<div className="mb-4">
+15
View File
@@ -451,6 +451,7 @@ body {
border: none;
box-shadow: var(--card-shadow);
transition: all 0.2s ease;
height: 100%;
}
.stats-card:hover {
@@ -693,6 +694,19 @@ body {
padding: 0.5rem 0.75rem;
}
/* El select nativo necesita espacio a la derecha para su flecha;
sin esto el texto largo ("Todas las prioridades") queda tapado. */
.filter-bar select.form-select {
padding-right: 2.25rem;
}
/* Botones de la barra de filtros al mismo alto que selects e inputs */
.filter-bar .btn {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
line-height: 1.5;
}
/* Ticket Detail */
.ticket-header {
background: #fff;
@@ -855,6 +869,7 @@ body {
/* Dashboard: tarjetas clicables que filtran tickets */
.stats-card-anchor {
display: block;
height: 100%;
color: inherit;
}
+6 -1
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next";
import { Inter, Fira_Sans } from "next/font/google";
import "bootstrap/dist/css/bootstrap.min.css";
import "sileo/styles.css";
import "./globals.css";
import { AuthProvider } from "@/lib/auth";
import SileoToaster from "@/components/SileoToaster";
const inter = Inter({
variable: "--font-inter",
@@ -34,7 +36,10 @@ export default function RootLayout({
/>
</head>
<body style={{ fontFamily: "var(--font-fira-sans), sans-serif" }}>
<AuthProvider>{children}</AuthProvider>
<AuthProvider>
{children}
<SileoToaster />
</AuthProvider>
</body>
</html>
);
+64 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
getNotifications,
@@ -9,6 +9,7 @@ import {
markAllNotificationsRead,
type AppNotification,
} from "@/lib/api";
import { notify } from "@/lib/notify";
import { formatDate } from "@/lib/format";
const POLL_MS = 60000;
@@ -48,18 +49,59 @@ export default function NotificationBell() {
const [unread, setUnread] = useState(0);
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const [showRead, setShowRead] = useState(false);
const [markingAll, setMarkingAll] = useState(false);
const seenIds = useRef<Set<number>>(new Set());
const initialized = useRef(false);
const toastNew = useCallback(
(list: AppNotification[]) => {
const fresh = list.filter((n) => !n.isRead && !seenIds.current.has(n.id));
// En la primera carga solo registra; evita una ráfaga de toasts históricos.
if (!initialized.current) {
initialized.current = true;
} else if (fresh.length > 0) {
// Un toast por notificación nueva (máx 3) o uno agrupado.
if (fresh.length <= 3) {
for (const n of fresh) {
if (n.ticketId) {
const ticketId = n.ticketId;
notify.action(n.title, "Ver", () => router.push(`/tickets/${ticketId}`), n.body ?? undefined);
} else {
notify.info(n.title, n.body ?? undefined);
}
}
} else {
notify.info(
`${fresh.length} notificaciones nuevas`,
fresh
.slice(0, 3)
.map((n) => n.title)
.join(" · ")
);
}
}
for (const n of list) seenIds.current.add(n.id);
// Acota memoria del set.
if (seenIds.current.size > 300) {
const keep = new Set(list.map((n) => n.id));
seenIds.current = keep;
}
},
[router]
);
const fetchAll = useCallback(() => {
Promise.all([getNotifications(50), getUnreadCount()])
.then(([list, { count }]) => {
setItems(list);
setUnread(count);
toastNew(list);
})
.catch(() => {
// Sin sesión o backend caído: se reintenta en el próximo ciclo.
});
}, []);
}, [toastNew]);
useEffect(() => {
fetchAll();
@@ -73,7 +115,10 @@ export default function NotificationBell() {
}, [fetchAll]);
const modules = [...new Set(items.map((n) => n.module).filter(Boolean))];
const visible = filter ? items.filter((n) => n.module === filter) : items;
// Las leídas ya no se muestran (solo con el interruptor "Ver leídas").
const visible = items.filter(
(n) => (!filter || n.module === filter) && (showRead || !n.isRead)
);
const handleOpenItem = (n: AppNotification) => {
if (!n.isRead) {
@@ -103,7 +148,9 @@ export default function NotificationBell() {
.catch(() => {});
}
})
.catch(() => {})
.catch(() => {
notify.error("No se pudieron marcar como leídas");
})
.finally(() => setMarkingAll(false));
};
@@ -178,11 +225,23 @@ export default function NotificationBell() {
})}
</div>
)}
<div className="form-check d-flex align-items-center gap-2 px-3 py-2 border-top">
<input
className="form-check-input m-0"
type="checkbox"
id="notif-show-read"
checked={showRead}
onChange={(e) => setShowRead(e.target.checked)}
/>
<label className="form-check-label text-muted" htmlFor="notif-show-read" style={{ fontSize: "0.8rem" }}>
Ver leídas
</label>
</div>
<div className="notification-list">
{visible.length === 0 ? (
<div className="text-center text-muted py-4" style={{ fontSize: "0.85rem" }}>
<i className="bi bi-bell-slash fs-4 d-block mb-2" style={{ opacity: 0.4 }}></i>
Sin notificaciones
{showRead ? "Sin notificaciones" : "Sin notificaciones sin leer"}
</div>
) : (
visible.map((n) => {
+8 -3
View File
@@ -8,10 +8,15 @@ export default function Sidebar() {
const pathname = usePathname();
const { user, logout } = useAuth();
const isTeam =
user?.role === "admin" || user?.role === "agent" || user?.role === "supervisor";
const navItems = [
// Dashboard para todos: el equipo ve el global (o su área) y los
// usuarios normales ven solo sus tickets (sin mensaje de permisos).
{ href: "/", icon: "bi-speedometer2", label: "Dashboard" },
// Dashboard solo para el equipo. Los usuarios normales solo ven
// Tickets y Base de Conocimientos.
...(isTeam
? [{ href: "/", icon: "bi-speedometer2", label: "Dashboard" }]
: []),
{ href: "/tickets", icon: "bi-ticket-detailed", label: "Tickets" },
{ href: "/knowledge-base", icon: "bi-journal-bookmark", label: "Knowledge Base" },
];
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { Toaster } from "sileo";
export default function SileoToaster() {
return <Toaster position="bottom-right" />;
}
+102 -5
View File
@@ -1,5 +1,16 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000";
// Sesión expirada o inválida: limpia el token y lleva al login.
// Se omite en el propio login (ahí el 401 es "credenciales inválidas")
// y si ya estamos en /login para no recargar en bucle.
function handleUnauthorized(path: string) {
if (typeof window === "undefined") return;
if (path.includes("/login")) return;
if (window.location.pathname === "/login") return;
localStorage.removeItem("helpdesk_token");
window.location.href = "/login";
}
async function fetchApi<T>(
path: string,
options: RequestInit = {}
@@ -18,6 +29,8 @@ async function fetchApi<T>(
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) handleUnauthorized(path);
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || `API Error: ${res.status}`);
@@ -41,11 +54,12 @@ export async function getMe() {
// ==================== Tickets ====================
export async function getTickets(params?: {
status?: string;
status?: string | string[];
priority?: string;
assigneeId?: number;
requesterId?: number;
groupId?: number;
archived?: boolean;
search?: string;
page?: number;
pageSize?: number;
@@ -53,8 +67,15 @@ export async function getTickets(params?: {
const query = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "" && value !== null)
query.set(key, String(value));
if (value === undefined || value === "" || value === null) return;
// El filtro de estados acepta uno o varios: se envía como lista
// separada por comas (ej: status=Open,Resolved). Vacío = Todos.
if (Array.isArray(value)) {
if (value.length === 0) return;
query.set(key, value.join(","));
return;
}
query.set(key, String(value));
});
}
return fetchApi<TicketListResult>(`/api/helpdesk/tickets?${query.toString()}`);
@@ -102,6 +123,20 @@ export async function updateTicketStatus(id: number, status: string) {
});
}
export async function mergeTickets(targetId: number, sourceId: number) {
return fetchApi<{ ticket: Ticket; merged: { comments: number; attachments: number; inbound: number; sourceId: number } }>(
`/api/helpdesk/ticket/${targetId}/merge`,
{ method: "POST", body: JSON.stringify({ sourceId }) }
);
}
export async function setTicketArchived(id: number, archived: boolean) {
return fetchApi<void>(`/api/helpdesk/ticket/${id}/archive`, {
method: "PUT",
body: JSON.stringify({ archived }),
});
}
export async function assignTicket(id: number, assigneeId: number, groupId?: number) {
return fetchApi<void>(`/api/helpdesk/ticket/${id}/assign`, {
method: "PUT",
@@ -121,14 +156,74 @@ export async function getComments(ticketId: number) {
export async function addComment(
ticketId: number,
content: string,
isInternal: boolean = false
isInternal: boolean = false,
cc?: string,
attachmentIds?: number[]
) {
return fetchApi<{ id: number }>(`/api/helpdesk/comments/${ticketId}`, {
method: "POST",
body: JSON.stringify({ content, isInternal }),
body: JSON.stringify({ content, isInternal, cc, attachmentIds }),
});
}
// ==================== Adjuntos del ticket ====================
export interface TicketAttachment {
id: number;
commentId?: number | null;
filename: string;
contentType: string;
size: number;
uploadedBy: string;
createdAt: string;
}
export async function uploadTicketAttachments(ticketId: number, files: File[]) {
const token =
typeof window !== "undefined" ? localStorage.getItem("helpdesk_token") : null;
const form = new FormData();
files.forEach((f) => form.append("files", f));
const res = await fetch(`${API_BASE}/api/helpdesk/ticket/${ticketId}/attachments`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (res.status === 401) handleUnauthorized("attachments");
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || `API Error: ${res.status}`);
}
return (await res.json()) as { files: { id: number; filename: string; size: number }[] };
}
export async function getTicketAttachments(ticketId: number) {
return fetchApi<TicketAttachment[]>(`/api/helpdesk/ticket/${ticketId}/attachments`);
}
export async function downloadAttachment(attachment: TicketAttachment) {
const token =
typeof window !== "undefined" ? localStorage.getItem("helpdesk_token") : null;
const res = await fetch(`${API_BASE}/api/helpdesk/attachments/${attachment.id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.status === 401) handleUnauthorized("attachments");
if (!res.ok) throw new Error(`No se pudo descargar (${res.status})`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = attachment.filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ==================== Historial de correos del ticket ====================
// Integración email <-> ticket: enviados desde soporte (mail.log) +
// respuestas recibidas (helpdesk.email_inbound). Visible para quien
@@ -219,6 +314,8 @@ export interface Ticket {
firstResponseAt?: string;
resolvedAt?: string;
closedAt?: string;
archived: boolean;
archivedAt?: string | null;
}
export interface TicketListResult {
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { sileo, type SileoOptions } from "sileo";
// Wrapper fino sobre sileo para unificar copy en español y defaults.
// Uso: notify.success("Ticket creado"), notify.error("No se pudo guardar", err)
const DEFAULT_DURATION = 5000;
function base(title: string, description?: string): SileoOptions {
return { title, description, duration: DEFAULT_DURATION };
}
export const notify = {
success(title: string, description?: string, opts?: Partial<SileoOptions>) {
return sileo.success({ ...base(title, description), ...opts });
},
error(title: string, description?: string, opts?: Partial<SileoOptions>) {
return sileo.error({ ...base(title, description), ...opts });
},
warning(title: string, description?: string, opts?: Partial<SileoOptions>) {
return sileo.warning({ ...base(title, description), ...opts });
},
info(title: string, description?: string, opts?: Partial<SileoOptions>) {
return sileo.info({ ...base(title, description), ...opts });
},
action(
title: string,
buttonTitle: string,
onClick: () => void,
description?: string,
opts?: Partial<SileoOptions>
) {
return sileo.action({
...base(title, description),
button: { title: buttonTitle, onClick },
...opts,
});
},
promise<T>(
promise: Promise<T> | (() => Promise<T>),
messages: {
loading: string;
success: string | ((data: T) => string);
error: string | ((err: unknown) => string);
}
) {
const { loading, success, error } = messages;
return sileo.promise(promise, {
loading: { title: loading },
success:
typeof success === "function"
? (data: T) => ({ title: (success as (d: T) => string)(data) })
: { title: success },
error:
typeof error === "function"
? (err: unknown) => ({ title: (error as (e: unknown) => string)(err) })
: { title: error },
});
},
dismiss: sileo.dismiss,
clear: sileo.clear,
};