Compare commits
19
Commits
dedab95cc5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ceb8c619 | ||
|
|
a2bcab9282 | ||
|
|
318ac083e9 | ||
|
|
b2112eb390 | ||
|
|
7a3d146892 | ||
|
|
b1710da477 | ||
|
|
ce3fa29b69 | ||
|
|
f88d79e2e8 | ||
|
|
dd1e1a231a | ||
|
|
eace5fbcd7 | ||
|
|
4a84271f44 | ||
|
|
24dcb6d770 | ||
|
|
f536f11dfd | ||
|
|
580ecff258 | ||
|
|
148d8b2550 | ||
|
|
0a08fa5192 | ||
|
|
73e6e96d73 | ||
|
|
9e3c7b8059 | ||
|
|
c702a19c1e |
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+178
-100
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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" },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Toaster } from "sileo";
|
||||
|
||||
export default function SileoToaster() {
|
||||
return <Toaster position="bottom-right" />;
|
||||
}
|
||||
+102
-5
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user