Compare commits
79 Commits
0f376a8b75
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf09346f29 | ||
|
|
9461ee1e2e | ||
|
|
3bf92f015c | ||
|
|
67042b0941 | ||
|
|
7efab390da | ||
|
|
4611fb2363 | ||
|
|
d211382e4d | ||
|
|
e29752644a | ||
|
|
704d2407e8 | ||
|
|
5472043a33 | ||
|
|
0fdee073a6 | ||
|
|
6a09ce9ee5 | ||
|
|
af751d771d | ||
|
|
cd87ddb18e | ||
|
|
6d9cf918f9 | ||
|
|
a0db888f2e | ||
|
|
a0253b4f90 | ||
|
|
a660928085 | ||
|
|
30cd83ffc1 | ||
|
|
ca3af1a750 | ||
|
|
b3f039fbd5 | ||
|
|
430bf7a5da | ||
|
|
10bb7299a2 | ||
|
|
dda4b5320a | ||
|
|
44d1f4b9d9 | ||
|
|
a484ba7849 | ||
|
|
ddaba6866a | ||
|
|
fcf93ea3ea | ||
|
|
3e7109a0f1 | ||
|
|
6e9ad89f7b | ||
|
|
3ec5da0e54 | ||
|
|
dbcc60ad95 | ||
|
|
474b501fff | ||
|
|
c847ef3ef5 | ||
|
|
8dea04cae3 | ||
|
|
56443a739f | ||
|
|
df9b168577 | ||
|
|
83782ab6f8 | ||
|
|
b0cf273bc8 | ||
|
|
eaf71567a7 | ||
|
|
2e6fbd7801 | ||
|
|
61676e1423 | ||
|
|
ce679ec1c3 | ||
|
|
7c8b034756 | ||
|
|
c30688810a | ||
|
|
f2666c7d3c | ||
|
|
424071b97c | ||
|
|
eba93db452 | ||
|
|
ed524af16b | ||
|
|
5987502664 | ||
|
|
aa893368cd | ||
|
|
7e4991f960 | ||
|
|
a4287a122e | ||
|
|
6db211b9aa | ||
|
|
d50c31f8f4 | ||
|
|
d6be2b5c6b | ||
|
|
5c17a9846a | ||
|
|
b9a3118c84 | ||
|
|
cfd2eb0624 | ||
|
|
edf82bf7fd | ||
|
|
60c9c81224 | ||
|
|
1bab6874a9 | ||
|
|
933d00ad58 | ||
|
|
059dfa071e | ||
|
|
2d13982d92 | ||
|
|
92a7923a26 | ||
|
|
af0ec65be5 | ||
|
|
157e108322 | ||
|
|
bf963e8894 | ||
|
|
2f39ce555c | ||
|
|
52295a78d0 | ||
|
|
307a54dabc | ||
|
|
a17e810ea8 | ||
|
|
b813622e30 | ||
|
|
b1ca0e0795 | ||
|
|
d91ea6b2ff | ||
|
|
10c665d25c | ||
|
|
df2c33f2d9 | ||
|
|
ad35c46a7a |
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.md
|
||||||
|
sprints/
|
||||||
|
supabase/
|
||||||
|
.claude/
|
||||||
@@ -6,3 +6,9 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key-publica>
|
|||||||
|
|
||||||
# NUNCA agregar SUPABASE_SERVICE_ROLE_KEY como variable NEXT_PUBLIC_ ni en el cliente
|
# NUNCA agregar SUPABASE_SERVICE_ROLE_KEY como variable NEXT_PUBLIC_ ni en el cliente
|
||||||
# La service_role bypasa RLS — solo para Edge Functions / backend server-side
|
# La service_role bypasa RLS — solo para Edge Functions / backend server-side
|
||||||
|
# Usada por app/admin/responses (Server Component) para listar respuestas sin RLS
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
|
# IP hash — variable server-only (NUNCA NEXT_PUBLIC_)
|
||||||
|
# Usada para deduplicación anónima. Generá un string aleatorio largo.
|
||||||
|
IP_HASH_SALT=
|
||||||
|
|||||||
8
.gitignore
vendored
@@ -14,6 +14,14 @@ supabase/.temp/
|
|||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Playwright — reportes/snapshots, pueden contener credenciales de .env.test en el DOM
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next/
|
||||||
|
|
||||||
# Supabase — claves y credenciales
|
# Supabase — claves y credenciales
|
||||||
*.key
|
*.key
|
||||||
service_role*
|
service_role*
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"pages": {}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"polyfillFiles": [
|
|
||||||
"static/chunks/polyfills.js"
|
|
||||||
],
|
|
||||||
"devFiles": [],
|
|
||||||
"ampDevFiles": [],
|
|
||||||
"lowPriorityFiles": [
|
|
||||||
"static/development/_buildManifest.js",
|
|
||||||
"static/development/_ssgManifest.js"
|
|
||||||
],
|
|
||||||
"rootMainFiles": [],
|
|
||||||
"rootMainFilesTree": {},
|
|
||||||
"pages": {
|
|
||||||
"/_app": []
|
|
||||||
},
|
|
||||||
"ampFirstPages": []
|
|
||||||
}
|
|
||||||
1
.next/cache/.rscinfo
vendored
@@ -1 +0,0 @@
|
|||||||
{"encryption.key":"rCrwV8YThn7yWBHzp4+yH4ABfW8goSONtUiZDNhogWc=","encryption.expire_at":1781610552095}
|
|
||||||
1
.next/cache/.tsbuildinfo
vendored
BIN
.next/cache/webpack/client-development/0.pack.gz
vendored
BIN
.next/cache/webpack/client-development/1.pack.gz
vendored
BIN
.next/cache/webpack/client-development/2.pack.gz
vendored
BIN
.next/cache/webpack/client-development/3.pack.gz
vendored
BIN
.next/cache/webpack/client-development/4.pack.gz
vendored
BIN
.next/cache/webpack/client-development/5.pack.gz
vendored
BIN
.next/cache/webpack/client-development/index.pack.gz
vendored
BIN
.next/cache/webpack/client-production/0.pack
vendored
BIN
.next/cache/webpack/client-production/1.pack
vendored
BIN
.next/cache/webpack/client-production/2.pack
vendored
BIN
.next/cache/webpack/client-production/3.pack
vendored
BIN
.next/cache/webpack/client-production/4.pack
vendored
BIN
.next/cache/webpack/client-production/index.pack
vendored
BIN
.next/cache/webpack/client-production/index.pack.old
vendored
BIN
.next/cache/webpack/edge-server-production/0.pack
vendored
BIN
.next/cache/webpack/server-development/0.pack.gz
vendored
BIN
.next/cache/webpack/server-development/1.pack.gz
vendored
BIN
.next/cache/webpack/server-development/2.pack.gz
vendored
BIN
.next/cache/webpack/server-development/3.pack.gz
vendored
BIN
.next/cache/webpack/server-development/index.pack.gz
vendored
BIN
.next/cache/webpack/server-production/0.pack
vendored
BIN
.next/cache/webpack/server-production/1.pack
vendored
BIN
.next/cache/webpack/server-production/2.pack
vendored
BIN
.next/cache/webpack/server-production/3.pack
vendored
BIN
.next/cache/webpack/server-production/index.pack
vendored
BIN
.next/cache/webpack/server-production/index.pack.old
vendored
@@ -1 +0,0 @@
|
|||||||
{"type": "commonjs"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
globalThis.__BUILD_MANIFEST = {
|
|
||||||
"polyfillFiles": [
|
|
||||||
"static/chunks/polyfills.js"
|
|
||||||
],
|
|
||||||
"devFiles": [],
|
|
||||||
"ampDevFiles": [],
|
|
||||||
"lowPriorityFiles": [],
|
|
||||||
"rootMainFiles": [],
|
|
||||||
"rootMainFilesTree": {},
|
|
||||||
"pages": {
|
|
||||||
"/_app": []
|
|
||||||
},
|
|
||||||
"ampFirstPages": []
|
|
||||||
};
|
|
||||||
globalThis.__BUILD_MANIFEST.lowPriorityFiles = [
|
|
||||||
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
|
||||||
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"middleware": {},
|
|
||||||
"functions": {},
|
|
||||||
"sortedMiddleware": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"\n}"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"node": {},
|
|
||||||
"edge": {},
|
|
||||||
"encryptionKey": "rCrwV8YThn7yWBHzp4+yH4ABfW8goSONtUiZDNhogWc="
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__BUILD_MANIFEST = (function(a){return {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},__routerFilterStatic:a,__routerFilterDynamic:a,sortedPages:["\u002F_app"]}}(void 0));self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
|
||||||
141
.next/types/cache-life.d.ts
vendored
@@ -1,141 +0,0 @@
|
|||||||
// Type definitions for Next.js cacheLife configs
|
|
||||||
|
|
||||||
declare module 'next/cache' {
|
|
||||||
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
|
|
||||||
export {
|
|
||||||
revalidateTag,
|
|
||||||
revalidatePath,
|
|
||||||
unstable_expireTag,
|
|
||||||
unstable_expirePath,
|
|
||||||
} from 'next/dist/server/web/spec-extension/revalidate'
|
|
||||||
export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store'
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache this `"use cache"` for a timespan defined by the `"default"` profile.
|
|
||||||
* ```
|
|
||||||
* stale: 300 seconds (5 minutes)
|
|
||||||
* revalidate: 900 seconds (15 minutes)
|
|
||||||
* expire: never
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
|
||||||
* If the server receives a new request after 15 minutes, start revalidating new values in the background.
|
|
||||||
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
|
|
||||||
*/
|
|
||||||
export function unstable_cacheLife(profile: "default"): void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache this `"use cache"` for a timespan defined by the `"seconds"` profile.
|
|
||||||
* ```
|
|
||||||
* stale: 0 seconds
|
|
||||||
* revalidate: 1 seconds
|
|
||||||
* expire: 60 seconds (1 minute)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This cache may be stale on clients for 0 seconds before checking with the server.
|
|
||||||
* If the server receives a new request after 1 seconds, start revalidating new values in the background.
|
|
||||||
* If this entry has no traffic for 1 minute it will expire. The next request will recompute it.
|
|
||||||
*/
|
|
||||||
export function unstable_cacheLife(profile: "seconds"): void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache this `"use cache"` for a timespan defined by the `"minutes"` profile.
|
|
||||||
* ```
|
|
||||||
* stale: 300 seconds (5 minutes)
|
|
||||||
* revalidate: 60 seconds (1 minute)
|
|
||||||
* expire: 3600 seconds (1 hour)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
|
||||||
* If the server receives a new request after 1 minute, start revalidating new values in the background.
|
|
||||||
* If this entry has no traffic for 1 hour it will expire. The next request will recompute it.
|
|
||||||
*/
|
|
||||||
export function unstable_cacheLife(profile: "minutes"): void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache this `"use cache"` for a timespan defined by the `"hours"` profile.
|
|
||||||
* ```
|
|
||||||
* stale: 300 seconds (5 minutes)
|
|
||||||
* revalidate: 3600 seconds (1 hour)
|
|
||||||
* expire: 86400 seconds (1 day)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
|
||||||
* If the server receives a new request after 1 hour, start revalidating new values in the background.
|
|
||||||
* If this entry has no traffic for 1 day it will expire. The next request will recompute it.
|
|
||||||
*/
|
|
||||||
export function unstable_cacheLife(profile: "hours"): void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache this `"use cache"` for a timespan defined by the `"days"` profile.
|
|
||||||
* ```
|
|
||||||
* stale: 300 seconds (5 minutes)
|
|
||||||
* revalidate: 86400 seconds (1 day)
|
|
||||||
* expire: 604800 seconds (1 week)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
|
||||||
* If the server receives a new request after 1 day, start revalidating new values in the background.
|
|
||||||
* If this entry has no traffic for 1 week it will expire. The next request will recompute it.
|
|
||||||
*/
|
|
||||||
export function unstable_cacheLife(profile: "days"): void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache this `"use cache"` for a timespan defined by the `"weeks"` profile.
|
|
||||||
* ```
|
|
||||||
* stale: 300 seconds (5 minutes)
|
|
||||||
* revalidate: 604800 seconds (1 week)
|
|
||||||
* expire: 2592000 seconds (30 days)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
|
||||||
* If the server receives a new request after 1 week, start revalidating new values in the background.
|
|
||||||
* If this entry has no traffic for 30 days it will expire. The next request will recompute it.
|
|
||||||
*/
|
|
||||||
export function unstable_cacheLife(profile: "weeks"): void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache this `"use cache"` for a timespan defined by the `"max"` profile.
|
|
||||||
* ```
|
|
||||||
* stale: 300 seconds (5 minutes)
|
|
||||||
* revalidate: 2592000 seconds (30 days)
|
|
||||||
* expire: never
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This cache may be stale on clients for 5 minutes before checking with the server.
|
|
||||||
* If the server receives a new request after 30 days, start revalidating new values in the background.
|
|
||||||
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
|
|
||||||
*/
|
|
||||||
export function unstable_cacheLife(profile: "max"): void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache this `"use cache"` using a custom timespan.
|
|
||||||
* ```
|
|
||||||
* stale: ... // seconds
|
|
||||||
* revalidate: ... // seconds
|
|
||||||
* expire: ... // seconds
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This is similar to Cache-Control: max-age=`stale`,s-max-age=`revalidate`,stale-while-revalidate=`expire-revalidate`
|
|
||||||
*
|
|
||||||
* If a value is left out, the lowest of other cacheLife() calls or the default, is used instead.
|
|
||||||
*/
|
|
||||||
export function unstable_cacheLife(profile: {
|
|
||||||
/**
|
|
||||||
* This cache may be stale on clients for ... seconds before checking with the server.
|
|
||||||
*/
|
|
||||||
stale?: number,
|
|
||||||
/**
|
|
||||||
* If the server receives a new request after ... seconds, start revalidating new values in the background.
|
|
||||||
*/
|
|
||||||
revalidate?: number,
|
|
||||||
/**
|
|
||||||
* If this entry has no traffic for ... seconds it will expire. The next request will recompute it.
|
|
||||||
*/
|
|
||||||
expire?: number
|
|
||||||
}): void
|
|
||||||
|
|
||||||
|
|
||||||
export { cacheTag as unstable_cacheTag } from 'next/dist/server/use-cache/cache-tag'
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"type": "module"}
|
|
||||||
@@ -77,11 +77,15 @@ Principios estructurales (de ADR-001):
|
|||||||
- **Umbral de k-anonimato** obligatorio en toda visualización compartida (nunca mostrar celda con
|
- **Umbral de k-anonimato** obligatorio en toda visualización compartida (nunca mostrar celda con
|
||||||
n < umbral).
|
n < umbral).
|
||||||
|
|
||||||
## 6. ADRs iniciales
|
## 6. ADRs
|
||||||
|
|
||||||
|
Ver `docs/adr/` para los archivos completos.
|
||||||
|
|
||||||
- **ADR-001** — Modelo de privacidad y gobierno de datos *(el corazón del producto)*.
|
- **ADR-001** — Modelo de privacidad y gobierno de datos *(el corazón del producto)*.
|
||||||
- **ADR-002** — Stack tecnológico y residencia de datos.
|
- **ADR-002** — Stack tecnológico y residencia de datos.
|
||||||
- **ADR-003** — Consentimiento granular versionado e inmutable.
|
- **ADR-003** — Consentimiento granular versionado e inmutable.
|
||||||
|
- **ADR-004** — Capa de identidad y autenticación (SSO Keycloak para staff; sin login para respondentes).
|
||||||
|
- **ADR-005** — Esquema declarativo de formularios y estrategia de dos fases (Sprint 4). La lógica del flujo vive en datos (JSONB), interpretada por un motor backend; el frontend wizard es un renderer sin reglas de negocio. Motor genérico diferido a Fase 2.
|
||||||
|
|
||||||
## 7. Riesgos
|
## 7. Riesgos
|
||||||
|
|
||||||
|
|||||||
113
BACKLOG.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# BACKLOG.md
|
||||||
|
|
||||||
|
> Ítems pendientes que no entran en el sprint actual. El agente NO los ejecuta
|
||||||
|
> sin aprobación del director (Nivel 3 salvo indicación contraria).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
### [SEC-004] RPC de submit es bypasseable vía REST directo (coherencia de flujo)
|
||||||
|
- **Estado:** Riesgo residual documentado — no resuelto en Etapa 4G
|
||||||
|
- **Contexto:** `rpc_submit_response` (migración `20260624000001_submit_rpc.sql`) es el único
|
||||||
|
camino de escritura para `consent_records`/`responses`/`answers` (las policies `WITH CHECK(true)`
|
||||||
|
que permitían INSERT directo a `anon` se eliminaron). La RPC valida lo ESTRUCTURAL en SQL
|
||||||
|
(el `question_id` pertenece a la encuesta, la encuesta está viva, el consentimiento llegó).
|
||||||
|
La validación de COHERENCIA DE FLUJO (visibilidad según `conditional_rules`, instancias
|
||||||
|
legítimas de `4.1`) vive en TypeScript (`lib/submit-validation.ts`, reusa `lib/form-engine.ts`)
|
||||||
|
porque reimplementarla en SQL violaría la regla de "no duplicar la lógica de flujo" (BRIEF 4G).
|
||||||
|
- **Riesgo:** quien llame a `rpc_submit_response` directo por la REST API de Supabase (sin pasar
|
||||||
|
por el Server Action de Next.js) puede mandar un payload con instancias/visibilidad incoherentes
|
||||||
|
que igual pase las validaciones estructurales de la RPC.
|
||||||
|
- **Por qué no es un incidente de privacidad:** el dato resultante sigue siendo anónimo
|
||||||
|
(`respondent_id=NULL`), `is_sensitive` se sigue respetando, y queda sujeto al umbral k-anonimato
|
||||||
|
en `agg_segment`/RPCs de dashboard igual que cualquier otra respuesta. Es un problema de calidad
|
||||||
|
de datos de investigación (una respuesta lógicamente imposible podría colarse), no de exposición
|
||||||
|
de datos.
|
||||||
|
- **Cierre posible (no implementado):** firmar el payload en el Server Action (HMAC con un secreto
|
||||||
|
server-only) y verificar la firma dentro de la RPC antes de insertar — así un caller directo sin
|
||||||
|
el secreto no puede producir un payload válido. Evaluar costo/beneficio antes de priorizar.
|
||||||
|
- **Dependencia:** no bloquea Sprint 4; evaluar junto con SEC-002 (sprint de compliance).
|
||||||
|
|
||||||
|
### [SEC-002] Sprint de compliance & seguridad
|
||||||
|
- **Estado:** Diferido — pendiente de priorizar tras Sprint 4
|
||||||
|
- **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap
|
||||||
|
de hardening. Antes de escalar a más empresas o datos reales, se requiere un sprint dedicado.
|
||||||
|
- **Ítems principales (ver COMPLIANCE.md secciones 5 y 6):**
|
||||||
|
- Registro de auditoría append-only (log inmutable de operaciones sensibles)
|
||||||
|
- Hashing SHA-256 de integridad de respuestas (detección de tampering sin blockchain)
|
||||||
|
- Cifrado en reposo a nivel de volumen/disco de la base de datos
|
||||||
|
- Roadmap de hardening por capas: aplicación, autenticación, datos, infraestructura,
|
||||||
|
observabilidad y proceso (revisión periódica de CVEs, pen testing)
|
||||||
|
- **Dependencia:** no bloquea Sprint 4; sí bloquea escalar a producción masiva.
|
||||||
|
- **Ver:** `COMPLIANCE.md` secciones 5 y 6.
|
||||||
|
|
||||||
|
### [SEC-001] Actualizar Next.js — CVE pendiente en 15.3.3
|
||||||
|
- **Estado:** Pendiente
|
||||||
|
- **Prioridad:** Alta
|
||||||
|
- **Contexto:** Next.js 15.3.3 tiene un CVE conocido. La versión no se actualizó
|
||||||
|
en la tarea del 2026-06-02 para no mezclar cambios con el fix de producción de
|
||||||
|
chromedriver. Actualizar en el próximo ciclo de mantenimiento.
|
||||||
|
- **Acción:** `npm install next@latest` + verificar que el build y la app sigan
|
||||||
|
funcionando antes de deployar.
|
||||||
|
- **Nota:** revisar changelog de Next.js por breaking changes antes de actualizar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Técnico / Deuda
|
||||||
|
|
||||||
|
### [TECH-001] `npm audit` reporta 2 vulnerabilidades en deps de producción
|
||||||
|
- **Estado:** Pendiente de revisión
|
||||||
|
- **Contexto:** `npm audit` señala 1 moderate + 1 critical. Evaluar con
|
||||||
|
`npm audit --json` si afectan el runtime de la app o solo herramientas de build.
|
||||||
|
- **Acción:** `npm audit fix` (o fix manual) y re-verificar build.
|
||||||
|
|
||||||
|
### [TECH-002] Mockup `formulario_mapeo.html` no encontrado en el árbol de trabajo
|
||||||
|
- **Estado:** Pendiente
|
||||||
|
- **Contexto:** El BRIEF Sprint 2 lo cita como "contrato visual". El agente diseñó
|
||||||
|
la UI basándose en SEED_SPEC.md. Requiere validación visual del director contra
|
||||||
|
el mockup original.
|
||||||
|
|
||||||
|
### [TECH-003] Auditoría a11y interactiva pendiente (foco, aria-expanded, widgets)
|
||||||
|
- **Estado:** Pendiente
|
||||||
|
- **Contexto:** La auditoría de Etapa 2A se corrió sobre el HTML SSR estático
|
||||||
|
(jsdom). La verificación de componentes interactivos (foco al abrir bloque
|
||||||
|
condicional, aria-expanded en triggers, operación solo con teclado) requiere
|
||||||
|
auditoría en browser real.
|
||||||
|
- **Acción:** Correr axe-cli con Chrome 149+ (o actualizar Chrome a 149) y
|
||||||
|
hacer test manual de navegación por teclado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
*(Sprint 3 y posteriores — ver ARCHITECTURE.md y BRIEF por sprint)*
|
||||||
|
|
||||||
|
### [BACKLOG-001] Consentimiento inicial editable desde admin
|
||||||
|
- **Estado:** hoy el texto del consentimiento está hardcoded en `consent-screen.tsx`.
|
||||||
|
- **Necesidad:** cuando se construya el motor de formularios (builder), el texto del
|
||||||
|
consentimiento debe ser editable por encuesta desde la administración, para reflejar
|
||||||
|
cambios en política de privacidad o ajustes específicos por tipo de relevamiento.
|
||||||
|
- **Bloquea:** implementación del builder visual.
|
||||||
|
- **Decisión cerrada:** la edición es desde admin, no desde el código.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [BACKLOG-002] Fase 2 — Motor genérico de formularios
|
||||||
|
- **Estado:** Diferido post-deadline
|
||||||
|
- **Contexto:** En Sprint 4 se construye un intérprete del esquema declarativo específico
|
||||||
|
para el formulario de cuidadores (ADR-005, Fase 1). Una vez entregado ese caso, el
|
||||||
|
intérprete se extrae a un motor universal que interprete cualquier esquema definido en
|
||||||
|
`questions.conditional_rules` + campos de instanciación, sin hardcodear ningún formulario.
|
||||||
|
El formulario de cuidadores se vuelve el primer caso de prueba del motor sin reescribir
|
||||||
|
el contrato de datos.
|
||||||
|
- **Dependencia:** Fase 1 completada (Sprint 4 — wizard funcional).
|
||||||
|
- **Ver:** ADR-005 (`docs/adr/ADR-005-esquema-declarativo-formularios.md`).
|
||||||
|
|
||||||
|
- Dashboard / reportes (Sprint 3)
|
||||||
|
- Formulario de consentimiento + persistencia de respuestas (Etapa 2B)
|
||||||
|
- Builder visual de encuestas (post-validación del caso fijo)
|
||||||
|
- Integración LLM para análisis de texto libre (diferenciador)
|
||||||
|
- Modo B / controller_role dual (reservado en esquema)
|
||||||
|
- Columnas `qi_gender` / `qi_modality` en `responses` — decisión Nivel 3 abierta
|
||||||
|
(ver hallazgo en SEED_SPEC.md)
|
||||||
98
CHANGELOG.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Changelog — Datos País sobre el Cuidado
|
||||||
|
|
||||||
|
Todas las versiones notables de este proyecto se documentan en este archivo.
|
||||||
|
|
||||||
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es/1.1.0/)
|
||||||
|
y el proyecto adhiere a [Versionado Semántico](https://semver.org/lang/es/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.0.0] — 2026-06-27
|
||||||
|
|
||||||
|
Reescritura del formulario a wizard declarativo pregunta-por-pantalla.
|
||||||
|
Motor de flujo puro, instancias familiares, submit transaccional server-side
|
||||||
|
y dashboard Tres Miradas con Mirada Empresa funcional.
|
||||||
|
|
||||||
|
### Agregado
|
||||||
|
- **Motor de flujo declarativo** (`lib/form-engine.ts`): `resolveVisibility`,
|
||||||
|
`resolveScreens`, `expandInstances` — lógica de show/hide/instancias en TypeScript
|
||||||
|
puro, sin estado UI. Operadores `contains`, `eq_sole`, `contains_any`.
|
||||||
|
- **Wizard renderer** (`components/wizard-form.tsx`): pregunta-por-pantalla,
|
||||||
|
progreso lineal, layout estable sin saltos de scroll.
|
||||||
|
- **Instancias familiares**: columna `instance_id` en `answers`; preguntas
|
||||||
|
4.2/4.3 se expanden por cada vínculo seleccionado en 4.1 con IDs `familiar_N`.
|
||||||
|
- **Submit v3 transaccional**: Server Action + RPC `rpc_submit_response`
|
||||||
|
(SECURITY DEFINER) — persiste consent_record + response + answers en una
|
||||||
|
transacción atómica; revoca INSERT directo de `anon`.
|
||||||
|
- **Validación server-side** (`lib/submit-validation.ts`): 7 checks antes del
|
||||||
|
RPC (consent, códigos legítimos, forma de valores, visibilidad coherente,
|
||||||
|
instancias válidas, opciones en rango, requeridas presentes).
|
||||||
|
- **Dashboard Tres Miradas**: Mirada Empresa (`/dashboard`) con 13 widgets,
|
||||||
|
RPCs STABLE `rpc_me_*` con filtros de segmento y k-anonimato; Mirada País y
|
||||||
|
Mirada Perfil (base implementada).
|
||||||
|
- **Autocomplete de barrios** (`BarrioAutocompleteField`): filtra `geo_paraguay_full.json`
|
||||||
|
por departamento + ciudad, dropdown `position: fixed` con `getBoundingClientRect`.
|
||||||
|
- **Ciudad select** (`CiudadSelectField`): filtra ciudades por departamento,
|
||||||
|
limpia el valor al cambiar 2.1.
|
||||||
|
- **Instrumento v3**: 10 secciones, 40 preguntas, 3 encuestas (encuesta Demo
|
||||||
|
`30000000-…`, InQuality, Fundación).
|
||||||
|
- Tema claro marca Re — fondos, cards y paleta de texto revisada.
|
||||||
|
|
||||||
|
### Cambiado
|
||||||
|
- Esquema de DB: tabla `questions` con `instance_generator`, `instance_of`,
|
||||||
|
`conditional_rules` (jsonb), `code` único por encuesta.
|
||||||
|
- RLS submit: DROP de policies `*_insert` directas; único canal de escritura
|
||||||
|
es `rpc_submit_response`.
|
||||||
|
- Dashboard helper `getDashboardWidgetData` unificado con códigos v3 de pregunta.
|
||||||
|
|
||||||
|
### Seguridad
|
||||||
|
- RPC SECURITY DEFINER para submit: cierra el `WITH CHECK(true)` que dejaba
|
||||||
|
el store analítico abierto a escritura directa desde el cliente (BRIEF dec. 15).
|
||||||
|
- Validación de integridad del payload en el Server Action antes del RPC:
|
||||||
|
el servidor es la única fuente de verdad sobre lo que se persiste.
|
||||||
|
|
||||||
|
### Migraciones aplicadas (Sprint 4)
|
||||||
|
- `20260620000001` — `instance_id` en `answers` [4A]
|
||||||
|
- `20260620000002` — esquema declarativo v3 [4B]
|
||||||
|
- `20260624000001` — RPC submit transaccional [4G]
|
||||||
|
- `20260625000001` — RPCs dashboard Mirada Empresa [4H]
|
||||||
|
- `20260625000002` — RPCs prevalencia y segmentos [4H]
|
||||||
|
- `20260626000001` — fix `cnt` en RPCs `me_carrera` / `me_apoyos` [4H]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.0] — 2026-06-16
|
||||||
|
|
||||||
|
Versión estable preservada en la rama `v2.1.0-stable`. Última versión del
|
||||||
|
formulario lineal antes del rediseño a wizard.
|
||||||
|
|
||||||
|
### Agregado
|
||||||
|
- Dashboard multitenante con selector de empresa (Todos / InQuality / Fundación / Demo)
|
||||||
|
- Flag `p_all` (UUID cero) para vista agregada de todos los surveys
|
||||||
|
- Vista de control `/admin/responses` con tabla plana, filtro por empresa,
|
||||||
|
colapsar/expandir por submission
|
||||||
|
- Dos encuestas piloto: InQuality y Fundación Solidaridad (organizaciones separadas)
|
||||||
|
- Método formalizado: `method/PATTERNS.md` y `method/ANTI_PATTERNS.md`
|
||||||
|
(patrones C.8, AP.10, AP.11)
|
||||||
|
|
||||||
|
### Cambiado
|
||||||
|
- Instrumento actualizado a v2.1 (feedback equipo médico): salto triple,
|
||||||
|
nueva pregunta 6.6, eliminación de "Prefiero no decir", 2.4 eliminada,
|
||||||
|
2.1 a multiple_choice
|
||||||
|
- Next.js actualizado a 15.3.8 (pin exacto)
|
||||||
|
|
||||||
|
### Seguridad
|
||||||
|
- Resuelto CVE-2025-66478 (+ advisory posterior CVE-2025-55183/55184/67779)
|
||||||
|
|
||||||
|
### Infraestructura
|
||||||
|
- Migración completa a Supabase self-hosted en Dokploy
|
||||||
|
- Backups GFS locales con retención de 3 días + domingos 14 días
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.0.0] — 2026-06 (anterior)
|
||||||
|
|
||||||
|
Primera versión en producción con Supabase self-hosted.
|
||||||
|
- Formulario lineal de 44 preguntas
|
||||||
|
- Dashboard con 11 widgets y k-anonimato (k≥5)
|
||||||
|
- Instrumento v2 (44 preguntas, 8 secciones)
|
||||||
148
COMPLIANCE.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# COMPLIANCE.md — Cumplimiento, seguridad y auditabilidad
|
||||||
|
|
||||||
|
> Documento vivo. Área: Compliance & Seguridad del producto "Datos País sobre el Cuidado".
|
||||||
|
> Audiencia: dirección técnica, auditores externos, equipo legal, clientes (resumen).
|
||||||
|
> Última actualización: 2026-06-24 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Principio rector de seguridad
|
||||||
|
|
||||||
|
**No existe un sistema "imposible de hackear". El objetivo es defensa en profundidad:**
|
||||||
|
encarecer progresivamente el ataque, minimizar la superficie expuesta, contener el daño
|
||||||
|
si una capa cae, y garantizar trazabilidad para detectar, auditar y reconstruir cualquier
|
||||||
|
incidente. Toda afirmación de inviolabilidad absoluta se considera, en sí misma, una
|
||||||
|
señal de inmadurez en seguridad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Naturaleza de los datos y marco normativo
|
||||||
|
|
||||||
|
### 2.1 Clasificación de datos
|
||||||
|
- **Datos sensibles** (salud, discapacidad): preguntas marcadas `is_sensitive=true`
|
||||||
|
(Sección de prevalencia, condición propia y de familiares).
|
||||||
|
- **Cuasi-identificadores**: departamento, ciudad, sector, rango etario, género.
|
||||||
|
- **Dato confidencial restringido**: franja salarial (analyst-only, nunca en vistas de empresa).
|
||||||
|
- **NO se recogen identificadores directos**: sin nombre, documento, email ni teléfono
|
||||||
|
en la encuesta anónima. La encuesta es anónima por diseño.
|
||||||
|
|
||||||
|
### 2.2 Marco normativo aplicable (Paraguay)
|
||||||
|
- Ley N° 6534/2020 de Protección de Datos Personales Crediticios y normativa de datos
|
||||||
|
personales aplicable en Paraguay.
|
||||||
|
- Principios alineados a estándares internacionales (minimización, finalidad, consentimiento).
|
||||||
|
- **Pendiente de revisión legal formal** con el equipo de Fundación Solidaridad / asesoría legal.
|
||||||
|
|
||||||
|
### 2.3 Consentimiento
|
||||||
|
- Registrado en tablas `consent_records` + `consent_versions` con versión y timestamp.
|
||||||
|
- Doble propósito separado y autorizable independientemente: diagnóstico interno de empresa
|
||||||
|
(requerido) y estudio país (opcional).
|
||||||
|
- Por ser anónima, la encuesta no permite revocación post-envío (no hay forma de localizar
|
||||||
|
una respuesta individual) — comunicado explícitamente al participante en el consentimiento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Anonimato y protección contra reidentificación
|
||||||
|
|
||||||
|
### 3.1 k-anonimato (k ≥ 5)
|
||||||
|
- Regla no negociable del sistema: ningún corte de datos con menos de 5 respuestas se muestra.
|
||||||
|
- Implementado en las RPCs del dashboard (`SECURITY DEFINER`), no en el frontend.
|
||||||
|
- Estados de supresión: `suppressed` con `n` real solo a nivel agregado.
|
||||||
|
|
||||||
|
### 3.2 Separación de datos sensibles
|
||||||
|
- Franja salarial (3.4 / 8.4 según versión): nunca expuesta en vistas de empresa
|
||||||
|
ni en `/admin/responses`.
|
||||||
|
- Política RLS `deny_sensitive` a nivel de base de datos.
|
||||||
|
- Vista de control `/admin/responses` excluye `is_sensitive=true` y `text_long` a nivel
|
||||||
|
de query, no de presentación (los valores no llegan al servidor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Seguridad — estado actual (v2.1.0)
|
||||||
|
|
||||||
|
### 4.1 Implementado
|
||||||
|
- **Cifrado en tránsito**: TLS/SSL vía Kong + Traefik (Let's Encrypt) en `db.inqualityhq.com`
|
||||||
|
y `economia-cuidado.inqualityhq.com`.
|
||||||
|
- **Soberanía del dato**: Supabase self-hosted en infraestructura propia (VPS controlado),
|
||||||
|
no en nube de terceros. Decisión arquitectónica fundacional.
|
||||||
|
- **RLS (Row Level Security)**: políticas activas en tablas sensibles; el rol `anon` solo
|
||||||
|
puede insertar respuestas, no leer datos de otros.
|
||||||
|
- **Service role aislado**: la clave de servicio nunca llega al bundle del cliente
|
||||||
|
(verificado: vistas server-side, no Client Components con la key).
|
||||||
|
- **Gestión de vulnerabilidades**: CVE-2025-66478 resuelto (Next.js 15.3.8, pin exacto).
|
||||||
|
- **Backups**: GFS local (3 días diarios + domingos 14 días), restore probado.
|
||||||
|
- **Aislamiento de red**: Studio de administración solo accesible vía SSH tunnel, no expuesto.
|
||||||
|
|
||||||
|
### 4.2 Deuda de seguridad registrada (ver TECH_DEBT.md)
|
||||||
|
- **TECH-DEBT-006**: backup remoto S3 — sin réplica off-site, riesgo ante pérdida del VPS.
|
||||||
|
Bloqueante antes del primer dato real de empresa.
|
||||||
|
- **TECH-DEBT-008**: `/admin/responses` sin autenticación — mitigado por URL no publicada,
|
||||||
|
pero requiere gate de auth antes de exponer.
|
||||||
|
- **TECH-DEBT-009**: rotación de secrets Supabase post-CVE — pendiente.
|
||||||
|
- **SEC-004** (ver `BACKLOG.md`): el submit v3 (`rpc_submit_response`, Etapa 4G) cerró el INSERT
|
||||||
|
directo de `anon` sobre `consent_records`/`responses`/`answers` (antes `WITH CHECK(true)`).
|
||||||
|
La RPC valida estructura en SQL; la coherencia de flujo (visibilidad, instancias legítimas) se
|
||||||
|
valida en TypeScript antes de llamarla — quien la invoque directo por REST puede evadir esa
|
||||||
|
segunda capa. No es un riesgo de privacidad (el dato sigue anónimo y sujeto a k-anonimato),
|
||||||
|
es de calidad de datos de investigación.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Auditabilidad y trazabilidad (roadmap)
|
||||||
|
|
||||||
|
> Objetivo: ante una auditoría o un incidente, poder responder "quién accedió a qué,
|
||||||
|
> cuándo, y demostrar que un registro no fue alterado".
|
||||||
|
|
||||||
|
### 5.1 Pendiente de diseño e implementación (BACKLOG — sprint de compliance)
|
||||||
|
- **Registro de auditoría append-only**: log inmutable de operaciones sensibles
|
||||||
|
(accesos a datos sensibles, cambios de configuración, ejecución de migraciones).
|
||||||
|
- **Hashing de integridad (SHA-256)**: hash de los registros de respuesta para demostrar
|
||||||
|
no-alteración, encadenable para detección de tampering. Provee el beneficio probatorio
|
||||||
|
de inmutabilidad SIN los costos de blockchain.
|
||||||
|
- **Cifrado en reposo**: cifrado a nivel de volumen/disco de la base de datos.
|
||||||
|
- **Trazabilidad de cambios de datos**: tablas de historial con triggers, o aprovechamiento
|
||||||
|
del WAL de PostgreSQL para reconstrucción temporal.
|
||||||
|
|
||||||
|
### 5.2 Por qué NO blockchain (decisión registrada)
|
||||||
|
Blockchain aporta valor cuando se requiere registro inmutable verificable entre partes
|
||||||
|
que NO confían entre sí, sin autoridad central legítima. En este sistema la confianza
|
||||||
|
está legítimamente centralizada (InQuality es responsable y operador soberano del dato),
|
||||||
|
por lo que blockchain no resuelve ningún problema real que no esté mejor resuelto por
|
||||||
|
audit logs + hashing. Además, su inmutabilidad absoluta entra en CONFLICTO con el derecho
|
||||||
|
de rectificación y supresión exigido por la normativa de protección de datos. Se descarta
|
||||||
|
por inadecuación técnica y normativa, no por desconocimiento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Roadmap de hardening de seguridad (a evaluar en sprint de compliance)
|
||||||
|
|
||||||
|
> Defensa en profundidad — capas a evaluar e implementar gradualmente.
|
||||||
|
|
||||||
|
- **Capa de aplicación**: validación y sanitización de inputs, protección contra inyección
|
||||||
|
(parametrización ya usada vía PostgREST/Supabase), rate limiting en endpoints públicos,
|
||||||
|
headers de seguridad (CSP, HSTS, X-Frame-Options).
|
||||||
|
- **Capa de autenticación**: gate de auth para vistas administrativas, rotación de secretos,
|
||||||
|
gestión de sesiones robusta cuando se introduzcan roles autenticados.
|
||||||
|
- **Capa de datos**: cifrado en reposo, RLS exhaustivo, principio de mínimo privilegio en roles.
|
||||||
|
- **Capa de infraestructura**: firewall, fail2ban, actualizaciones de SO, monitoreo de
|
||||||
|
intrusiones, segmentación de red.
|
||||||
|
- **Capa de observabilidad**: logs centralizados, alertas ante patrones anómalos,
|
||||||
|
registro de auditoría (sección 5).
|
||||||
|
- **Proceso**: revisión periódica de dependencias (`npm audit`), gestión de CVEs,
|
||||||
|
pruebas de penetración antes de escalar a producción masiva.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Responsabilidades
|
||||||
|
|
||||||
|
- **Responsable del dato**: InQuality (operador de infraestructura y autoridad del dato).
|
||||||
|
- **Aliado institucional**: Fundación Solidaridad.
|
||||||
|
- **Revisión legal**: pendiente de formalizar con asesoría legal especializada en
|
||||||
|
protección de datos en Paraguay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Historial de revisiones de este documento
|
||||||
|
| Fecha | Versión producto | Cambio |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-06-20 | 2.1.0 / 3.0.0-dev | Creación inicial. Estado de seguridad v2.1.0, roadmap de auditabilidad, decisión sobre blockchain. |
|
||||||
|
| 2026-06-24 | 2.1.0 / 3.0.0-dev | Etapa 4G: submit v3 vía RPC SECURITY DEFINER, cierra INSERT directo de anon. Registra SEC-004 (riesgo residual de bypass vía REST, ver BACKLOG.md). |
|
||||||
@@ -97,6 +97,11 @@ qi_zone text
|
|||||||
```
|
```
|
||||||
> Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar
|
> Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar
|
||||||
> (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`.
|
> (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`.
|
||||||
|
|
||||||
|
> **Mapeo v3 (Etapa 4G, confirmado):** `qi_zone` ← `2.1` (departamento), `qi_age_bucket` ← `2.4`
|
||||||
|
> (rango de edad). `qi_sector` ← `2.6` (nivel organizacional) — v3 no tiene una pregunta
|
||||||
|
> equivalente a "área/gerencia" como v2 (`A4`); `2.6` es el cuasi-identificador organizacional
|
||||||
|
> más cercano disponible en el instrumento actual, no un mapeo 1:1 con el campo de v2.
|
||||||
> Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para
|
> Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para
|
||||||
> minimizar re-identificación.
|
> minimizar re-identificación.
|
||||||
|
|
||||||
@@ -106,9 +111,20 @@ id uuid pk
|
|||||||
response_id uuid fk -> responses(id)
|
response_id uuid fk -> responses(id)
|
||||||
question_id uuid fk -> questions(id)
|
question_id uuid fk -> questions(id)
|
||||||
value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc.
|
value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc.
|
||||||
|
instance_id text not null default 'default'
|
||||||
|
-- 'default' → preguntas normales (la gran mayoría)
|
||||||
|
-- 'familiar_1', 'familiar_2', ... → instancias del mini-wizard de vínculos (4A, Sprint 4)
|
||||||
|
UNIQUE (response_id, question_id, instance_id) -- constraint: answers_response_question_instance_key
|
||||||
```
|
```
|
||||||
> El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee
|
> El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee
|
||||||
> categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4).
|
> categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4).
|
||||||
|
>
|
||||||
|
> **Patrón `instance_id` (v3.0.0):** la pregunta 4.1 ("¿Tenés familiar con discapacidad/adulto
|
||||||
|
> mayor a cargo?") dispara el mini-wizard. Por cada familiar declarado se genera un conjunto de
|
||||||
|
> respuestas para las preguntas 4.2, 4.3, etc., cada una con su propio `instance_id`. Las RPCs
|
||||||
|
> de dashboard que agregan datos de vínculo operan sobre filas donde `instance_id != 'default'`.
|
||||||
|
> Las RPCs existentes de v2 no se modifican: siguen usando `instance_id = 'default'` implícitamente
|
||||||
|
> (las preguntas de vínculo no estaban en el instrumento v2).
|
||||||
|
|
||||||
### `consent_versions` — textos de consentimiento versionados
|
### `consent_versions` — textos de consentimiento versionados
|
||||||
```sql
|
```sql
|
||||||
|
|||||||
43
Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# ── Dependencias ──────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Las variables NEXT_PUBLIC_* se hornean en el bundle en build time.
|
||||||
|
# Pasarlas como build args desde Dokploy (Environment → Build Args).
|
||||||
|
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runner ────────────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
384
INSTALL_SUPABASE.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# INSTALL_SUPABASE.md — Supabase self-hosted en Dokploy v0.29.4
|
||||||
|
|
||||||
|
> Guía operativa basada en la instalación real del proyecto "Datos País sobre el Cuidado" (junio 2026).
|
||||||
|
> Registra los pasos exactos, errores conocidos y sus soluciones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisiones de arquitectura
|
||||||
|
|
||||||
|
- **Supabase self-hosted completo** (Postgres + Auth + Storage + Realtime + Studio + Kong).
|
||||||
|
- **Partida limpia** — no se migran datos desde Cloud, se aplican las migraciones del repo.
|
||||||
|
- **Gestión vía Git** — el compose y los volumes viven en un repo dedicado; Dokploy clona ese repo.
|
||||||
|
Las variables de entorno viven en Dokploy (no en el repo).
|
||||||
|
- **Backups locales** diarios, retención 30 días. Remoto: TECH-DEBT-006.
|
||||||
|
- **Studio** no expuesto a internet. Acceso solo vía SSH tunnel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos previos
|
||||||
|
|
||||||
|
- VPS Ubuntu 22.04+ con Docker 29+ instalado.
|
||||||
|
- Dokploy v0.29.4 instalado y accesible.
|
||||||
|
- Repo Git disponible (Gitea o GitHub) con acceso desde el VPS y desde Dokploy.
|
||||||
|
- Acceso SSH al VPS.
|
||||||
|
- Supabase CLI en la máquina de desarrollo local.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTE 1 — Preparar el repo de Supabase
|
||||||
|
|
||||||
|
### 1.1 Descargar el stack oficial en el VPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://supabase.link/setup.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Crea `~/supabase-project/` con esta estructura:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/supabase-project/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── volumes/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── kong.yml <- API gateway (EDITAR: ver 1.2)
|
||||||
|
│ │ └── kong-entrypoint.sh
|
||||||
|
│ ├── db/
|
||||||
|
│ │ ├── _supabase.sql <- CRITICO: crea la base _supabase
|
||||||
|
│ │ ├── roles.sql
|
||||||
|
│ │ ├── jwt.sql
|
||||||
|
│ │ ├── realtime.sql
|
||||||
|
│ │ ├── pooler.sql
|
||||||
|
│ │ ├── webhooks.sql
|
||||||
|
│ │ ├── logs.sql
|
||||||
|
│ │ └── init/data.sql
|
||||||
|
│ └── pooler/
|
||||||
|
│ └── pooler.exs
|
||||||
|
```
|
||||||
|
|
||||||
|
> Estos archivos son los scripts de inicializacion que Postgres ejecuta la primera vez.
|
||||||
|
> Sin ellos la base _supabase no se crea y el stack no funciona.
|
||||||
|
> Por eso NO se puede usar "docker raw" en Dokploy — ese modo no tiene contexto del directorio.
|
||||||
|
|
||||||
|
### 1.2 Ajustar kong.yml
|
||||||
|
|
||||||
|
El kong.yml define las credenciales del API gateway. Verificar que use los mismos
|
||||||
|
nombres de variables que se definen en Dokploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "keyauth" ~/supabase-project/volumes/api/kong.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Deben quedar dos bloques con UNA sola entrada cada uno:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keyauth_credentials:
|
||||||
|
- key: $ANON_KEY # nombre exacto de la variable en Dokploy
|
||||||
|
|
||||||
|
keyauth_credentials:
|
||||||
|
- key: $SERVICE_ROLE_KEY # nombre exacto de la variable en Dokploy
|
||||||
|
```
|
||||||
|
|
||||||
|
ERROR comun: si los dos bloques resuelven al mismo valor JWT, Kong falla con
|
||||||
|
"uniqueness violation". Verificar que ANON_KEY y SERVICE_ROLE_KEY son distintos.
|
||||||
|
|
||||||
|
### 1.3 Crear .gitignore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > ~/supabase-project/.gitignore << 'EOF'
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
volumes/db/data/
|
||||||
|
volumes/storage/
|
||||||
|
volumes/logs/
|
||||||
|
*.log
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Push al repo Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/supabase-project
|
||||||
|
git init
|
||||||
|
git add docker-compose.yml volumes/api/ volumes/db/ volumes/pooler/ .gitignore
|
||||||
|
git commit -m "chore: supabase self-hosted stack inicial"
|
||||||
|
git remote add origin https://TU_GIT/usuario/supabase-repo.git
|
||||||
|
git push -u origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
El .env con los secrets NO se sube (esta en .gitignore).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTE 2 — Generar los secrets
|
||||||
|
|
||||||
|
Usar siempre openssl rand -hex (solo hex, sin caracteres especiales que rompan el escaping de Docker).
|
||||||
|
|
||||||
|
### 2.1 Valores base (en el VPS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
|
||||||
|
echo "JWT_SECRET=$(openssl rand -hex 40)"
|
||||||
|
echo "DASHBOARD_PASSWORD=$(openssl rand -hex 16)"
|
||||||
|
echo "SECRET_KEY_BASE=$(openssl rand -hex 40)"
|
||||||
|
echo "VAULT_ENC_KEY=$(openssl rand -hex 20)"
|
||||||
|
echo "PG_META_CRYPTO_KEY=$(openssl rand -hex 24)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Guardar todos en un gestor de contrasenas antes de continuar.
|
||||||
|
|
||||||
|
### 2.2 Generar ANON_KEY y SERVICE_ROLE_KEY
|
||||||
|
|
||||||
|
Con el JWT_SECRET generado, usar Node.js en el VPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const secret = 'PEGAR_AQUI_JWT_SECRET';
|
||||||
|
|
||||||
|
function b64url(obj) {
|
||||||
|
return Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeJWT(role) {
|
||||||
|
const h = b64url({alg:'HS256',typ:'JWT'});
|
||||||
|
const p = b64url({role,iss:'supabase',iat:1700000000,exp:2000000000});
|
||||||
|
const sig = crypto.createHmac('sha256',secret).update(h+'.'+p).digest('base64url');
|
||||||
|
return h+'.'+p+'.'+sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ANON_KEY='+makeJWT('anon'));
|
||||||
|
console.log('SERVICE_ROLE_KEY='+makeJWT('service_role'));
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTE 3 — Configurar el servicio en Dokploy
|
||||||
|
|
||||||
|
### 3.1 Crear el servicio
|
||||||
|
|
||||||
|
1. Projects -> seleccionar proyecto -> Create Service -> Docker Compose.
|
||||||
|
2. Provider: Gitea (o GitHub) -> seleccionar el repo del paso 1.4.
|
||||||
|
3. Branch: master | Compose Path: ./docker-compose.yml
|
||||||
|
4. IMPORTANTE: desactivar Autodeploy (toggle OFF).
|
||||||
|
|
||||||
|
### 3.2 Variables de entorno (pestaña Environment)
|
||||||
|
|
||||||
|
Pegar el bloque completo reemplazando los valores:
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=<openssl rand -hex 24>
|
||||||
|
POSTGRES_HOST=db
|
||||||
|
POSTGRES_DB=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
JWT_SECRET=<openssl rand -hex 40>
|
||||||
|
JWT_EXPIRY=3600
|
||||||
|
ANON_KEY=<JWT anon>
|
||||||
|
SERVICE_ROLE_KEY=<JWT service_role>
|
||||||
|
SUPABASE_ANON_KEY=<igual a ANON_KEY>
|
||||||
|
SUPABASE_SERVICE_KEY=<igual a SERVICE_ROLE_KEY>
|
||||||
|
DASHBOARD_USERNAME=admin
|
||||||
|
DASHBOARD_PASSWORD=<openssl rand -hex 16>
|
||||||
|
KONG_HTTP_PORT=8000
|
||||||
|
KONG_HTTPS_PORT=8443
|
||||||
|
SECRET_KEY_BASE=<openssl rand -hex 40>
|
||||||
|
VAULT_ENC_KEY=<openssl rand -hex 20>
|
||||||
|
PG_META_CRYPTO_KEY=<openssl rand -hex 24>
|
||||||
|
SITE_URL=http://localhost:3000
|
||||||
|
API_EXTERNAL_URL=http://localhost:8000
|
||||||
|
SUPABASE_PUBLIC_URL=http://localhost:8000
|
||||||
|
STUDIO_DEFAULT_ORGANIZATION=NombreOrg
|
||||||
|
STUDIO_DEFAULT_PROJECT=NombreProyecto
|
||||||
|
SMTP_ADMIN_EMAIL=admin@example.com
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_SENDER_NAME=
|
||||||
|
ENABLE_EMAIL_SIGNUP=false
|
||||||
|
ENABLE_EMAIL_AUTOCONFIRM=false
|
||||||
|
ENABLE_PHONE_SIGNUP=false
|
||||||
|
ENABLE_PHONE_AUTOCONFIRM=false
|
||||||
|
DISABLE_SIGNUP=true
|
||||||
|
ENABLE_ANONYMOUS_USERS=false
|
||||||
|
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false
|
||||||
|
MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify
|
||||||
|
MAILER_URLPATHS_INVITE=/auth/v1/verify
|
||||||
|
MAILER_URLPATHS_RECOVERY=/auth/v1/verify
|
||||||
|
MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify
|
||||||
|
ADDITIONAL_REDIRECT_URLS=
|
||||||
|
POOLER_TENANT_ID=pooler
|
||||||
|
POOLER_DEFAULT_POOL_SIZE=20
|
||||||
|
POOLER_MAX_CLIENT_CONN=100
|
||||||
|
POOLER_PROXY_PORT_TRANSACTION=6543
|
||||||
|
POOLER_DB_POOL_SIZE=10
|
||||||
|
STORAGE_TENANT_ID=default
|
||||||
|
GLOBAL_S3_BUCKET=
|
||||||
|
REGION=
|
||||||
|
S3_PROTOCOL_ACCESS_KEY_ID=
|
||||||
|
S3_PROTOCOL_ACCESS_KEY_SECRET=
|
||||||
|
LOGFLARE_LOGGER_BACKEND_API_KEY=logflare-local-key
|
||||||
|
LOGFLARE_API_KEY=logflare-local-key
|
||||||
|
FUNCTIONS_VERIFY_JWT=true
|
||||||
|
IMGPROXY_ENABLE_WEBP_DETECTION=true
|
||||||
|
IMGPROXY_AUTO_WEBP=true
|
||||||
|
PGRST_DB_SCHEMAS=public,storage,graphql_public
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Deploy y validacion
|
||||||
|
|
||||||
|
Click en Deploy. Cuando termine verificar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format "table {{.Names}}\t{{.Status}}" | grep supabase
|
||||||
|
|
||||||
|
docker exec supabase-db psql -U postgres -c 'SELECT version();'
|
||||||
|
|
||||||
|
curl -s http://localhost:8000/rest/v1/ -H "apikey: <ANON_KEY>" | head -c 200
|
||||||
|
|
||||||
|
docker exec supabase-db psql -U postgres -c '\l' | grep supabase
|
||||||
|
```
|
||||||
|
|
||||||
|
Contenedores esperados healthy: supabase-db, supabase-kong, supabase-studio,
|
||||||
|
supabase-auth, supabase-rest, supabase-storage, supabase-meta, supabase-pooler,
|
||||||
|
supabase-imgproxy, realtime-dev.supabase-realtime.
|
||||||
|
supabase-edge-functions puede quedar en Restarting si no se usa Edge Functions (no critico).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTE 4 — Aplicar las migraciones del proyecto
|
||||||
|
|
||||||
|
El puerto 5432 no esta expuesto externamente. Las migraciones se aplican desde el VPS.
|
||||||
|
|
||||||
|
### 4.1 Copiar migraciones al VPS (desde la maquina de desarrollo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh administrator@<VPS_IP> "mkdir -p ~/migrations"
|
||||||
|
scp -r supabase/migrations/ administrator@<VPS_IP>:~/migrations/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Aplicar en orden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in $(ls ~/migrations/*.sql | sort); do
|
||||||
|
echo "Aplicando: $f"
|
||||||
|
docker exec -i supabase-db psql -U postgres -d postgres < "$f"
|
||||||
|
echo "---"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Verificar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec supabase-db psql -U postgres -d postgres -c \
|
||||||
|
"SELECT tablename, policyname FROM pg_policies WHERE schemaname='public' ORDER BY tablename;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nota: auth.jwt() en GoTrue v2.189+
|
||||||
|
|
||||||
|
GoTrue v2.189+ elimino auth.jwt(). Las funciones disponibles son auth.uid(), auth.role().
|
||||||
|
Las politicas que usen custom claims JWT (app_role, org_id) fallaran. Ver TECH-DEBT-007.
|
||||||
|
No es bloqueante para el piloto porque el dashboard usa service_role que bypasa RLS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTE 5 — Backups locales
|
||||||
|
|
||||||
|
### 5.1 Crear el script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/backups/supabase-prod
|
||||||
|
sudo chmod 700 /var/backups/supabase-prod
|
||||||
|
|
||||||
|
sudo tee /usr/local/bin/supabase-backup.sh > /dev/null << 'SCRIPT'
|
||||||
|
#!/usr/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
TS=$(date +%Y%m%d_%H%M%S)
|
||||||
|
OUT=/var/backups/supabase-prod/supabase_$TS.sql.gz
|
||||||
|
docker exec supabase-db pg_dump -U postgres postgres | gzip > "$OUT"
|
||||||
|
chmod 600 "$OUT"
|
||||||
|
find /var/backups/supabase-prod -name "supabase_*.sql.gz" -mtime +30 -delete
|
||||||
|
echo "Backup OK: $OUT ($(du -h "$OUT" | cut -f1))"
|
||||||
|
SCRIPT
|
||||||
|
|
||||||
|
sudo chmod +x /usr/local/bin/supabase-backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Probar y configurar cron
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /usr/local/bin/supabase-backup.sh
|
||||||
|
|
||||||
|
sudo crontab -e
|
||||||
|
# Agregar: 0 3 * * * /usr/bin/bash /usr/local/bin/supabase-backup.sh >> /var/log/supabase-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Probar restore (obligatorio)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec supabase-db createdb -U postgres restore_test
|
||||||
|
gunzip < $(ls /var/backups/supabase-prod/*.sql.gz | tail -1) | \
|
||||||
|
docker exec -i supabase-db psql -U postgres restore_test
|
||||||
|
docker exec supabase-db psql -U postgres -d restore_test -c \
|
||||||
|
"SELECT count(*) FROM pg_tables WHERE schemaname='public';"
|
||||||
|
docker exec supabase-db dropdb -U postgres restore_test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTE 6 — Dominio y SSL (pendiente)
|
||||||
|
|
||||||
|
En Dokploy, pestaña Domains del servicio supabase:
|
||||||
|
- Agregar dominio db.dominio.com -> apuntar a supabase-kong:8000 -> activar SSL.
|
||||||
|
- Studio NO tiene dominio publico. Acceso solo via SSH tunnel:
|
||||||
|
ssh -L 3001:localhost:3001 administrator@VPS_IP -> abrir http://localhost:3001
|
||||||
|
|
||||||
|
Actualizar variables de entorno:
|
||||||
|
```
|
||||||
|
API_EXTERNAL_URL=https://db.dominio.com
|
||||||
|
SUPABASE_PUBLIC_URL=https://db.dominio.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PARTE 7 — Conectar la app Next.js
|
||||||
|
|
||||||
|
Actualizar variables de entorno de la app Next.js en Dokploy:
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://db.dominio.com
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=<ANON_KEY>
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Errores conocidos y soluciones
|
||||||
|
|
||||||
|
| Error | Causa | Solucion |
|
||||||
|
|---|---|---|
|
||||||
|
| password authentication failed for supabase_storage_admin | POSTGRES_PASSWORD con caracteres especiales | Usar openssl rand -hex 24 |
|
||||||
|
| database "_supabase" does not exist | volumes/db/ no accesible | Usar modo Git en Dokploy, no docker raw |
|
||||||
|
| keyauth_credentials uniqueness violation | ANON_KEY = SERVICE_ROLE_KEY o duplicados en kong.yml | Una sola entrada por bloque en kong.yml |
|
||||||
|
| exec: kong-entrypoint.sh is a directory | Volumen corrupto | docker volume rm del volumen afectado |
|
||||||
|
| GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: converting to bool | Variable vacia | Agregar GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false |
|
||||||
|
| GOTRUE_SMTP_PORT: converting to int | SMTP_PORT vacio | Agregar SMTP_PORT=587 |
|
||||||
|
| auth.jwt() does not exist | GoTrue v2.189+ lo elimino | Ver TECH-DEBT-007 |
|
||||||
|
| Conflicto de nombres de contenedores | Stack manual y Dokploy corriendo en paralelo | docker compose down desde ~/supabase-project |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deuda tecnica
|
||||||
|
|
||||||
|
- TECH-DEBT-006: Backup remoto (S3) — bloqueante antes del primer dato real de empresa.
|
||||||
|
- TECH-DEBT-007: Migrar auth.jwt() a alternativa compatible con GoTrue v2.189+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datos operativos de esta instalacion
|
||||||
|
|
||||||
|
- VPS: inq-mart-2 - IP VPN: 172.20.5.11
|
||||||
|
- Dokploy: https://172.20.5.11 (acceso via VPN)
|
||||||
|
- Repo Supabase: https://git.inquality.com.py/marcos/supabase-motor-fomularios
|
||||||
|
- Backups: /var/backups/supabase-prod/ (diarios, retencion 30 dias)
|
||||||
|
- Studio: ssh -L 3001:localhost:3001 administrator@172.20.5.11 -> http://localhost:3001
|
||||||
89
OBSERVATIONS.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# OBSERVATIONS.md — Observaciones y breaking changes
|
||||||
|
|
||||||
|
> Registro de cambios al instrumento que rompen compatibilidad con datos existentes
|
||||||
|
> o que requieren atención del director antes/después de re-seedear. No bloquean el
|
||||||
|
> trabajo del agente — se documentan para que el director decida qué hacer con los
|
||||||
|
> datos ya capturados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etapa 3I2 — Revisión del instrumento v2.1 (feedback equipo médico)
|
||||||
|
|
||||||
|
### 1. Eliminación de "Prefiero no decir" (Cambio 1)
|
||||||
|
Afecta: A5, A6, 1.1, 1.5, 1.6, 1.13, 2.5, 3.1, 3.4.
|
||||||
|
|
||||||
|
Si existen respuestas guardadas con el valor `"Prefiero no decir"` en alguna de estas
|
||||||
|
preguntas, esos valores quedan **huérfanos**: ya no aparecen como opción en el
|
||||||
|
formulario ni en `seed.sql`, pero permanecen en `responses.answers` tal cual fueron
|
||||||
|
guardados. No se borran ni se migran automáticamente.
|
||||||
|
|
||||||
|
**Impacto en triggers (1.1, 1.6, 1.13):** estas preguntas ahora tienen solo 2 opciones
|
||||||
|
(Sí/No). La lógica condicional (incluyendo el nuevo salto-triple, Cambio 4a) solo
|
||||||
|
reacciona a `"Sí"`. Una respuesta histórica `"Prefiero no decir"` en un trigger se
|
||||||
|
trataría como "no Sí" a efectos de la lógica condicional si se re-evaluara, pero no
|
||||||
|
hay re-evaluación retroactiva de respuestas ya enviadas.
|
||||||
|
|
||||||
|
### 2. 2.1 cambia de `single_choice` a `multiple_choice` (Cambio 5)
|
||||||
|
Respuestas existentes para 2.1 están guardadas como un string único (`"Yo soy el/la
|
||||||
|
principal"`, etc.). El formulario nuevo espera un array de strings para preguntas
|
||||||
|
`multiple_choice`. Las respuestas históricas de 2.1 quedan en formato de string,
|
||||||
|
inconsistente con el nuevo esquema esperado para esa pregunta.
|
||||||
|
|
||||||
|
Ver `TECH_DEBT.md` → TECH-DEBT-006 (deuda de ranking pendiente sobre esta misma
|
||||||
|
pregunta).
|
||||||
|
|
||||||
|
### 3. Eliminación de la pregunta 2.4 (Cambio 6)
|
||||||
|
"¿Contás con apoyo de otras personas para el cuidado?" se elimina del seed por ser
|
||||||
|
similar/redundante con 2.1 (feedback del equipo médico).
|
||||||
|
|
||||||
|
El `DELETE FROM public.questions WHERE survey_id = ...` al inicio de `seed.sql` borra
|
||||||
|
la fila de la pregunta 2.4 (id `40000000-0000-0000-0000-000000000027`). Si existen
|
||||||
|
filas en `responses` cuyo `answers` referencian `question_id` = 2.4 (por
|
||||||
|
`question_id`, no por código embebido en JSON — depende del esquema de `responses`),
|
||||||
|
esas referencias quedan apuntando a un `question_id` que ya no existe en `questions`
|
||||||
|
tras el re-seed.
|
||||||
|
|
||||||
|
**No re-seedear la base de producción sin que el director confirme que no hay
|
||||||
|
respuestas reales que dependan de 2.4**, o decidir conservar la fila de 2.4 marcada
|
||||||
|
como `archived`/inactiva en lugar de borrarla (cambio de diseño — Nivel 3, no
|
||||||
|
ejecutado en esta etapa).
|
||||||
|
|
||||||
|
### 4. Cambio de texto en opción de 3.3 (Cambio 8)
|
||||||
|
"Es difícil sostenerlo con mis ingresos" → "No puedo sostenerlo con mis ingresos".
|
||||||
|
|
||||||
|
Respuestas existentes que eligieron la opción anterior quedan guardadas con el texto
|
||||||
|
literal `"Es difícil sostenerlo con mis ingresos"`, que ya no coincide con ninguna
|
||||||
|
opción del seed actual. Cualquier agregación por valor exacto de opción debe
|
||||||
|
contemplar ambos strings como equivalentes para no perder esos datos en reportes.
|
||||||
|
|
||||||
|
### 5. División de la primera franja salarial en 3.4 (Cambio 9)
|
||||||
|
"Sueldo mínimo – Gs. 5.000.000" se reemplaza por dos opciones nuevas: "Sueldo mínimo"
|
||||||
|
y "Gs. 3.000.000 a 5.000.000".
|
||||||
|
|
||||||
|
Respuestas existentes con el valor `"Sueldo mínimo – Gs. 5.000.000"` quedan con ese
|
||||||
|
valor, que ya no existe como opción. No hay forma automática de reasignar esas
|
||||||
|
respuestas a una de las dos franjas nuevas (la franja exacta del respondente no se
|
||||||
|
puede inferir del valor agregado anterior).
|
||||||
|
|
||||||
|
### 6. Cambio de texto en última opción de 6.1 (Cambio 10)
|
||||||
|
"No, nunca se ha hablado del tema" → "No, no se puede hablar del tema".
|
||||||
|
|
||||||
|
Respuestas existentes con el valor anterior quedan con ese texto literal, que ya no
|
||||||
|
coincide con ninguna opción del seed actual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumen para el director
|
||||||
|
|
||||||
|
- Ningún cambio de esta etapa borra datos directamente, salvo el `DELETE` estándar de
|
||||||
|
`questions` por `survey_id` (mecanismo de idempotencia ya existente en `seed.sql`,
|
||||||
|
no nuevo de esta etapa) — pero ese `DELETE` ahora también elimina la fila de 2.4.
|
||||||
|
- Los cambios 1, 8, 9 y 10 dejan **valores de texto huérfanos** en respuestas
|
||||||
|
históricas (ya no matchean ninguna opción vigente). No afecta el funcionamiento del
|
||||||
|
formulario hacia adelante, pero sí cualquier reporte/agregación que dependa de
|
||||||
|
matchear el string exacto de la opción.
|
||||||
|
- El cambio 5 (2.1) deja respuestas históricas en un formato (string) distinto al que
|
||||||
|
el formulario nuevo produce (array). Si se construye un reporte sobre 2.1, debe
|
||||||
|
manejar ambos formatos o normalizarse en una migración de datos aparte.
|
||||||
|
- **No se ejecutó ningún re-seed sobre la base real** — esto aplica solo cuando el
|
||||||
|
director apruebe re-seedear con `seed.sql` v2.1.
|
||||||
264
SEED_SPEC.md
@@ -1,105 +1,209 @@
|
|||||||
# SEED_SPEC.md — Contrato del instrumento "Mapeo de cuidadores"
|
# SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v3.0.0
|
||||||
|
|
||||||
> Inventario completo y autoritativo de la encuesta, extraído del mockup real
|
> Versión 3.0.0 — Sprint 4, Etapa 4B (Jun 2026). Reemplaza la v2.1. Reescritura completa del
|
||||||
> (`formulario_mapeo.html`). Reemplaza el seed inventado de la Etapa 1 (que tenía 9 de 34 preguntas y
|
> instrumento: 10 secciones, 40 preguntas (2 consentimiento + 38 de relevamiento), esquema
|
||||||
> el supuesto incorrecto C1→G4). Claude Code reescribe `seed.sql` a partir de ESTE contrato.
|
> declarativo de flujo (ADR-005). Seed LIMPIO — no migra datos de v2.1.
|
||||||
|
>
|
||||||
|
> Fuente de contenido AUTORITATIVA: `Formulario_Mapeo_Corregido.xlsx` (versión corregida,
|
||||||
|
> en rojo = cambios aplicados). El mockup `wizard_formulario_mapeo_v3.html` define estructura
|
||||||
|
> de secciones y mecánica del mini-wizard (4.1→4.2/4.3); donde el Excel difiere en el flujo
|
||||||
|
> condicional de "solo niño sin discapacidad", gana el Excel (decisión de la directora, ver
|
||||||
|
> abajo "Flujo condicional — recorrido soloNino").
|
||||||
|
>
|
||||||
|
> Claude Code reescribe `seed.sql` a partir de ESTE contrato.
|
||||||
|
|
||||||
## Convenciones (decisiones cerradas)
|
## Convenciones
|
||||||
- **Mapeo de tipos al esquema:** radio→`single_choice`, checkbox→`multiple_choice`, escala 1-5→
|
|
||||||
`rating`, textarea→`text_long`.
|
|
||||||
- **`conditional_rules` viven en la pregunta DEPENDIENTE** (cada pregunta condicional declara su
|
|
||||||
propia condición de visibilidad: `{if_question, op:'eq', value, action:'show'}`). El motor, al
|
|
||||||
decidir si renderiza X, mira las reglas de X. (Resuelve la ambigüedad que el agente había dejado.)
|
|
||||||
- **B1/C1/D1 son `single_choice` de 3 opciones** (Sí / No / Prefiero no decir), **NO `yes_no`**.
|
|
||||||
Solo el valor **"Sí"** dispara el bloque condicional; "No" y "Prefiero no decir" lo ocultan.
|
|
||||||
- **`is_sensitive = true`** en toda pregunta que elicita salud/discapacidad/diagnóstico (marcadas
|
|
||||||
abajo). La encuesta entera es `data_sensitivity = 'sensible'`.
|
|
||||||
|
|
||||||
## Estructura condicional (3 bloques, el resto siempre visible)
|
- Tipos: radio→`single_choice`, checkbox→`multiple_choice`, texto libre corto→`text_short`,
|
||||||
- **cond-B** — visible si `B1 = 'Sí'` → contiene B2, B3, B4, B5
|
texto libre largo→`text_long`, sí/no→`yes_no`.
|
||||||
- **cond-C** — visible si `C1 = 'Sí'` → contiene C2, C3, C4, C5
|
- Secciones persisten como filas `questions.type='section'`: `prompt`=nombre de sección,
|
||||||
- **cond-D** — visible si `D1 = 'Sí'` → contiene D2, D3, D4, D5
|
`hint`=descripción/"porqué" (Opción C, ver ADR-005 §4.2).
|
||||||
- Secciones A (datos generales), E (rol cuidador), F (económico), G (bienestar), H (necesidades):
|
- `conditional_rules` vive en la pregunta/sección DEPENDIENTE (auto-target implícito, sin
|
||||||
**top-level, siempre visibles.**
|
campo `target`). Vocabulario completo en `supabase/migrations/20260620000002_form_schema_v3.sql`:
|
||||||
|
operadores `eq`, `eq_any`, `not_in`, `contains`, `eq_sole`; acciones `show`, `hide`.
|
||||||
|
- `is_sensitive=true` en preguntas de salud/discapacidad/diagnóstico y en 8.4 (franja salarial,
|
||||||
|
CONFIDENCIAL — analyst-only, nunca en vistas de empresa, igual que 3.4 en v2.1).
|
||||||
|
- En preguntas `multiple_choice` el `hint` suele ser "Podés marcar más de una opción".
|
||||||
|
- Mini-wizard de instanciación (ADR-005): 4.1 es `instance_generator=true`; 4.2 y 4.3 tienen
|
||||||
|
`instance_of='4.1'` y se repiten una vez por cada vínculo familiar (opciones 2–7) marcado en 4.1.
|
||||||
|
Las respuestas de cada instancia se guardan en `answers.instance_id` (`'familiar_1'`, `'familiar_2'`...).
|
||||||
|
- Geo: 2.1 (departamento, 18 opciones) y 2.2 (ciudad, 219 opciones filtradas por departamento en
|
||||||
|
el renderer) son cuasi-identificadores. 2.3 (barrio) es `text_short` de texto libre, opcional —
|
||||||
|
NO entra al store analítico crudo sin categorizar (regla 4 CLAUDE.md); se usa solo como apoyo
|
||||||
|
cualitativo, no agregable.
|
||||||
|
|
||||||
|
## Flujo condicional — las 3 reglas principales
|
||||||
|
|
||||||
|
1. **`r_show_si_s3_1`** — 3.1='Sí' → muestra 3.2, 3.3, 3.4, 3.5 (discapacidad propia).
|
||||||
|
Si 3.1='No', toda la sub-rama queda oculta.
|
||||||
|
2. **`r_hide_ning`** — 4.1 contiene "Ninguna de las anteriores / No tengo vinculación de cuidado"
|
||||||
|
→ oculta S5, S6, S7, S8(header), 8.4, S9(header), 9.1, 9.4. El formulario salta directo a S10.
|
||||||
|
3. **`r_hide_ning_sn`** (Ninguna O soloNino) — además de la condición anterior, también oculta
|
||||||
|
cuando 4.1 tiene como ÚNICO elemento seleccionado "Hijo menor sin discapacidad" (`eq_sole`).
|
||||||
|
Oculta: 5.1, 5.2, 5.4, S6 completa, S7 completa, 8.1, 8.2, 8.3, 9.2, 9.3.
|
||||||
|
|
||||||
|
### Flujo condicional — recorrido "solo niño sin discapacidad" (soloNino)
|
||||||
|
|
||||||
|
Decisión de la directora (Etapa 4B): cuando 4.1 tiene como única respuesta "Hijo menor sin
|
||||||
|
discapacidad", el recorrido visible es **5.3 → 8.4 → 9.1 → 9.4 → fin**, tal como lo especifica
|
||||||
|
el Excel corregido (no el mockup, que marcaba toda la Sección 5 como oculta). Esto es más
|
||||||
|
granular que un simple "salto de sección": 5.3, 8.4, 9.1 y 9.4 quedan visibles porque no
|
||||||
|
dependen de que haya responsabilidad económica o de cuidado activo — preguntan por quién
|
||||||
|
cuida durante el horario laboral (5.3), franja salarial (8.4) y percepción/voluntariado
|
||||||
|
organizacional (9.1, 9.4), que aplican igual a quien solo tiene un hijo menor sin discapacidad.
|
||||||
|
|
||||||
|
- **4.1 = sole "Hijo menor sin discapacidad"**: 4.2 y 4.3 se ocultan (no hay tipo de
|
||||||
|
discapacidad ni dispositivos de apoyo que relevar), 5.1/5.2/5.4 ocultas, 5.3 VISIBLE,
|
||||||
|
S6 y S7 completas ocultas, 8.1–8.3 ocultas, 8.4 VISIBLE, 9.1 VISIBLE, 9.2/9.3 ocultas,
|
||||||
|
9.4 VISIBLE.
|
||||||
|
- **4.1 contiene "Ninguna de las anteriores..."**: salta directo a S10 (todo S5–S9 oculto).
|
||||||
|
- **4.1 = cualquier combinación de opciones 2–7** (con o sin la opción 1): recorrido completo,
|
||||||
|
4.2/4.3 generan una instancia por cada vínculo 2–7 declarado.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sección 0 — Datos Generales (top-level, cuasi-identificadores)
|
## Sección 1 — Consentimiento (`S1`, pos. 10)
|
||||||
| Code | Tipo | Pregunta | Opciones |
|
|
||||||
|---|---|---|---|
|
|
||||||
| A1 | single_choice | ¿En qué área o gerencia trabajás? | Operaciones/Producción · Comercial/Ventas · Administración/Finanzas · RRHH/Cultura · Tecnología/Sistemas · Logística/Distribución · Otra área |
|
|
||||||
| A2 | single_choice | ¿Cuántos años tenés? | Menos de 25 · 25-34 · 35-44 · 45-54 · 55+ · Prefiero no decir |
|
|
||||||
| A3 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro · Prefiero no decir |
|
|
||||||
| A4 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto · Híbrido |
|
|
||||||
|
|
||||||
## Sección 1 — ¿Vivís con discapacidad? *(sensible)*
|
| Code | Tipo | Pregunta | Requerida | Notas |
|
||||||
| Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive |
|
|---|---|---|---|---|
|
||||||
|
| 1.1 | yes_no | Declaro que fui informado/a sobre el propósito de esta encuesta y el tratamiento de mis respuestas, y acepto participar de forma voluntaria en el diagnóstico interno de [Empresa]. | Sí | Gate de participación — ver `consent_versions` v2 |
|
||||||
|
| 1.2 | yes_no | Autorizo que mis respuestas anonimizadas se utilicen para el estudio país sobre la economía del cuidado en Paraguay. | No | Scope `macro_n1` — Modo A (CLAUDE.md regla 7) |
|
||||||
|
|
||||||
|
## Sección 2 — Datos generales (`S2`, pos. 20)
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Opciones | is_sensitive | Notas |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| B1 | single_choice | trigger | ¿Vos mismo/a tenés alguna discapacidad o condición de salud…? | Sí · No · Prefiero no decir | ✅ |
|
| 2.1 | single_choice | Departamento | 18 departamentos de Paraguay + Capital | no | qi (cuasi-identificador) |
|
||||||
| B2 | multiple_choice | B1=Sí | ¿Qué tipo de discapacidad tenés? (más de una) | Física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial · Condición crónica · Otra | ✅ |
|
| 2.2 | single_choice | Ciudad | 219 ciudades (filtradas por 2.1 en el renderer) | no | qi |
|
||||||
| B3 | single_choice | B1=Sí | ¿Contás con certificado oficial de discapacidad? | Sí · En trámite · No | ✅ |
|
| 2.3 | text_short | ¿En qué barrio vivís? | — (texto libre) | no | `hint`='Opcional.' — no entra al store analítico crudo |
|
||||||
| B4 | single_choice | B1=Sí | ¿Tu discapacidad afecta tu desempeño o comodidad en el trabajo? | Sí, significativamente · Sí, aspectos puntuales · No, bien gestionada · Prefiero no decir | ✅ |
|
| 2.4 | single_choice | ¿Cuál es tu rango de edad? | Menos de 25 · 25-34 · 35-44 · 45-54 · 55+ | no | qi |
|
||||||
| B5 | single_choice | B1=Sí | ¿La empresa conoce tu situación? | Sí, con apoyo · Sí, sin apoyo · No, privado | ✅ |
|
| 2.5 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro | no | qi |
|
||||||
|
| 2.6 | single_choice | ¿En qué nivel organizacional estás? | Directivo · Gerencial · Jefatura · Operativo · Auxiliar · Otro | no | qi |
|
||||||
|
| 2.7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto · Híbrido | no | qi |
|
||||||
|
|
||||||
## Sección 2 — Familiares con Discapacidad *(sensible)*
|
## Sección 3 — Prevalencia de discapacidad (`S3`, pos. 30) *(sensible)*
|
||||||
| Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| C1 | single_choice | trigger | ¿Tenés algún familiar cercano que viva con discapacidad? | Sí · No · Prefiero no decir | ✅ |
|
|
||||||
| C2 | multiple_choice | C1=Sí | ¿Cuál es el vínculo? (más de una) | Hijo/a · Cónyuge/pareja · Padre/madre · Hermano/a · Otro familiar | ✅ |
|
|
||||||
| C3 | multiple_choice | C1=Sí | ¿Qué tipo de discapacidad tiene? (más de una) | Física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial · Condición crónica | ✅ |
|
|
||||||
| C4 | single_choice | C1=Sí | ¿Esta persona requiere apoyo o cuidado cotidiano? | Alta dependencia · Apoyo parcial · Autónomo/a | ✅ |
|
|
||||||
| C5 | single_choice | C1=Sí | ¿Quién es el responsable principal del cuidado? | Yo principal · Compartido · Cuidador pagado · Institución | |
|
|
||||||
|
|
||||||
## Sección 3 — Adultos Mayores a Cargo *(sensible)*
|
Cond.: 3.2–3.5 visibles solo si 3.1='Sí' (`r_show_si_s3_1`).
|
||||||
| Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive |
|
|
||||||
|---|---|---|---|---|---|
|
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|
||||||
| D1 | single_choice | trigger | ¿Tenés algún adulto mayor (60+) a tu cuidado? | Sí · No · Prefiero no decir | |
|
|---|---|---|---|---|
|
||||||
| D2 | single_choice | D1=Sí | ¿Cuántos adultos mayores están bajo tu cuidado? | 1 · 2 · 3+ | |
|
| 3.1 | single_choice | ¿Vos mismo/a tenés alguna discapacidad o condición de salud que pudiera afectar tu independencia o desempeño laboral? | No · Sí | ✅ trigger |
|
||||||
| D3 | single_choice | D1=Sí | ¿Qué nivel de dependencia tiene/n? | Supervisión · Dependencia leve · Dependencia moderada · Dependencia severa | ✅ |
|
| 3.2 | multiple_choice | ¿Qué tipo de discapacidad o condición tenés? | Física/motora · Visual · Auditiva · Intelectual · Mental/psicosocial · Del habla y la comunicación · Múltiple · Vejez/osteoporosis/artrosis/otras · Amputación · Otra | ✅ |
|
||||||
| D4 | multiple_choice | D1=Sí | ¿Tiene condición diagnosticada? (más de una) | Demencia/Alzheimer · ACV/Parálisis · Enf. crónica · Movilidad reducida · Deterioro cognitivo · Ninguna específica | ✅ |
|
| 3.3 | multiple_choice | ¿Usás algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón de apoyo · Bastón blanco · Andador · Muletas · Prótesis · Tecnología asistiva · Audífonos · No utilizo | ✅ |
|
||||||
| D5 | single_choice | D1=Sí | ¿La persona vive con vos o en otra vivienda? | Vive conmigo · Otra vivienda · Institución | |
|
| 3.4 | single_choice | ¿Contás con certificado oficial de discapacidad? | Sí · No, pero estoy en trámite · No | ✅ |
|
||||||
|
| 3.5 | multiple_choice | ¿Cómo afecta tu discapacidad y/o condición limitante en tu trabajo? | No afecta mi rendimiento · Falta de adecuaciones afecta más que la discapacidad · Transporte · Accesibilidad para participar | ✅ |
|
||||||
|
|
||||||
|
## Sección 4 — Vinculación familiar (`S4`, pos. 40) *(sensible)*
|
||||||
|
|
||||||
|
Mini-wizard: 4.1 `instance_generator=true`; 4.2/4.3 `instance_of='4.1'`, una instancia por
|
||||||
|
vínculo 2–7 declarado en 4.1. Ocultas (4.2/4.3) si Ninguna o soloNino (`r_hide_ning_sn`).
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 4.1 | multiple_choice | ¿Cuál de las siguientes vinculaciones familiares tenés? | 1. Hijo menor sin discapacidad · 2. Hijo menor con discapacidad · 3. Hijo adulto con discapacidad · 4. Familiar menor con discapacidad · 5. Familiar mayor con discapacidad · 6. Padre/madre con movilidad reducida o 100% dependiente · 7. Otro/Especificar · 8. Ninguna de las anteriores / No tengo vinculación de cuidado | ✅ generador |
|
||||||
|
| 4.2 | multiple_choice (instancia) | ¿Qué tipo de discapacidades o limitaciones tienen tus familiares vinculados? | (mismo set que 3.2) | ✅ |
|
||||||
|
| 4.3 | multiple_choice (instancia) | ¿Qué tipo de dispositivos de apoyo utilizan tus familiares vinculados? | (mismo set que 3.3, "No utiliza" en 3ra persona) | ✅ |
|
||||||
|
|
||||||
|
## Sección 5 — Responsabilidad y cuidado (`S5`, pos. 50)
|
||||||
|
|
||||||
|
Cond.: oculta completa si Ninguna (`r_hide_ning`). 5.1/5.2/5.4 además ocultas si soloNino;
|
||||||
|
**5.3 permanece visible en soloNino** (ver "recorrido soloNino" arriba).
|
||||||
|
|
||||||
## Sección 4 — Tu Rol como Cuidador/a (top-level)
|
|
||||||
| Code | Tipo | Pregunta | Opciones |
|
| Code | Tipo | Pregunta | Opciones |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| E1 | single_choice | ¿Cuántas horas por día dedicás, en promedio, al cuidado directo? | Menos de 1h · 1-3h · 3-6h · Más de 6h · Variable |
|
| 5.1 | multiple_choice | ¿Cuál es tu rol en el cuidado de los niños y/o personas con discapacidad o dependientes? | Proveo financieramente · Comparto gastos con familiares · Administro recursos/organizo servicios · Cuido unas horas al salir del trabajo · Me ocupo de todas las gestiones |
|
||||||
| E2 | multiple_choice | ¿En qué momentos realizás principalmente el cuidado? (más de una) | Antes del trabajo · Durante horario laboral · Al llegar · Durante la noche · Fines de semana · Continuo |
|
| 5.2 | multiple_choice | ¿En qué momento del día te ocupás de gestionar/organizar el cuidado? | Antes del trabajo · Después del trabajo · Durante el trabajo (llamadas/emergencias) · En cualquier momento · Fines de semana |
|
||||||
| E3 | single_choice | ¿Contás con apoyo de otras personas para el cuidado? | Apoyo familiar suficiente · Apoyo insuficiente · Único responsable · Apoyo contratado |
|
| 5.3 | multiple_choice | Durante el tiempo que estás en el trabajo, ¿quién se ocupa del cuidado de las personas a tu cargo? | Mi cónyuge · Otro familiar · Cuidador/a contratado/a · Centro de estudios o cuidado · Un/a vecino/a · Otro |
|
||||||
| E4 | single_choice | ¿Tuviste que faltar al trabajo por el cuidado en el último año? | Nunca · Pocas veces (1-3) · Varias veces (4-10) · Con frecuencia (10+) |
|
| 5.4 | single_choice | ¿Desde hace cuánto cumplís este rol de responsable o cuidador/a? | Menos de 1 año · 1-3 años · 3-5 años · Más de 5 años |
|
||||||
|
|
||||||
|
## Sección 6 — Intensidad del cuidado (`S6`, pos. 60)
|
||||||
|
|
||||||
|
Cond.: oculta completa si Ninguna o soloNino (`r_hide_ning_sn`).
|
||||||
|
|
||||||
## Sección 5 — Impacto Económico (top-level)
|
|
||||||
| Code | Tipo | Pregunta | Opciones |
|
| Code | Tipo | Pregunta | Opciones |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| F1 | single_choice | ¿Cuánto estimás que gastás mensualmente en el cuidado? | Sin gastos significativos · Menos de 500k · 500k-1.5M · 1.5M-3M · 3M-6M · Más de 6M · Prefiero no decir |
|
| 6.1 | single_choice | ¿Cuántos días por semana estás a cargo del cuidado directo, en promedio? | 1–2 · 3–4 · 5–6 · Todos los días |
|
||||||
| F2 | multiple_choice | ¿Qué servicios o apoyos utilizás actualmente? (más de una) | Terapias especializadas · Cuidador particular · Centro de día · Transporte adaptado · Medicación alto costo · Tecnología asistiva · Apoyo ONG · Sin servicios formales |
|
| 6.2 | single_choice | Cuando estás a cargo del cuidado, ¿cuántas horas por día dedicás, en promedio? | 1–2 · 3–4 · 5–6 · Más de 6 horas |
|
||||||
| F3 | rating | ¿Los gastos de cuidado impactan en tu situación económica personal? | Escala 1 (Sin impacto) → 5 (Muy alto) |
|
| 6.3 | multiple_choice | ¿En qué momentos del día cuidás principalmente? | Antes del trabajo · Al llegar del trabajo · Durante la noche · Fines de semana |
|
||||||
|
| 6.4 | single_choice | Además del cuidado directo, ¿cuántas horas al día necesitás estar disponible o de guardia? | 1–2 · 3–4 · 5–6 · Más de 6 horas · Permanente/sin horario |
|
||||||
|
|
||||||
|
## Sección 7 — Impacto en el trabajo (`S7`, pos. 70)
|
||||||
|
|
||||||
|
Cond.: oculta completa si Ninguna o soloNino (`r_hide_ning_sn`).
|
||||||
|
|
||||||
## Sección 6 — Bienestar y Descanso (top-level)
|
|
||||||
| Code | Tipo | Pregunta | Opciones |
|
| Code | Tipo | Pregunta | Opciones |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| G1 | single_choice | ¿Cómo describirías tu nivel de descanso habitual? | Descanso bien · Descanso irregular · Me cuesta descansar · Muy poco descanso |
|
| 7.1 | single_choice | ¿Cómo describirías tu nivel de descanso cuando te ocupás del cuidado y cómo impacta en tu desempeño? | Descanso bien · Irregular pero lo manejo · Me cuesta, afecta con frecuencia · Descanso muy poco, agotamiento afecta desempeño |
|
||||||
| G2 | single_choice | En los últimos 3 meses, ¿con qué frecuencia el cuidado afectó tu concentración o rendimiento laboral? | Nunca/casi nunca · Algunas veces al mes · Varias veces por semana · Casi todos los días |
|
| 7.2 | single_choice | En los últimos 3 meses, ¿con qué frecuencia sentiste que el cuidado afectó tu concentración o rendimiento? | Nunca/casi nunca · Algunas veces al mes · Varias veces por semana · Casi todos los días |
|
||||||
| G3 | multiple_choice | ¿Qué aspectos de tu vida se ven más afectados? (más de una) | Tiempo libre · Sueño y descanso · Vida social · Vida en pareja/familia · Salud física · Salud emocional · Desarrollo profesional · Situación económica |
|
| 7.3 | single_choice | En el último año, ¿faltaste al trabajo por situaciones relacionadas al cuidado? | Nunca · Pocas veces (1-3/año) · Varias veces (4-10/año) · Con frecuencia (+10/año) |
|
||||||
| G4 | rating | ¿Cómo evaluás tu bienestar general en este momento? | Escala 1 (Muy mal) → 5 (Muy bien) |
|
| 7.4 | multiple_choice | ¿Qué aspectos de tu vida se ven más afectados por tu rol de cuidador/a? | Tiempo libre · Sueño · Vida social · Vida en pareja/familiar · Salud física · Salud emocional/mental · Desarrollo profesional · Situación económica |
|
||||||
| G5 | single_choice | ¿Sentís que tu empresa comprende la realidad de ser cuidador/a? | Sí, acompañado/a · Parcialmente · No, invisible · Nunca lo supo |
|
| 7.5 | multiple_choice | Por tu rol de cuidado, ¿alguna vez tuviste que…? | Reducir jornada · Rechazar ascenso · Declinar viajes/extras/capacitaciones · Cambiar a puesto menos exigente · Faltar/llegar tarde reiteradamente · Pensar en dejar el trabajo · Dejar un trabajo anterior · Ninguna de las anteriores |
|
||||||
|
| 7.6 | single_choice | Pensando en los próximos 12 meses, ¿qué tan probable es que dejes o reduzcas tu trabajo por razones de cuidado? | Nada probable · Poco probable · Algo probable · Muy probable |
|
||||||
|
|
||||||
## Sección 7 — Necesidades (top-level)
|
## Sección 8 — Impacto económico (`S8`, pos. 80)
|
||||||
| Code | Tipo | Pregunta | Opciones / nota |
|
|
||||||
|
Cond.: header y 8.4 ocultos si Ninguna (`r_hide_ning`). 8.1–8.3 además ocultas si soloNino.
|
||||||
|
**8.4 permanece visible en soloNino.**
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 8.1 | single_choice | ¿Cuánto estimás que gastás por mes en el cuidado? | 7 franjas, < 1M a > 15M Gs. | no |
|
||||||
|
| 8.2 | multiple_choice | ¿En qué conceptos gastás principalmente el monto expresado? | Cuidador/a particular · Centro de día/institución · Traslados · Equipamiento médico/tecnología asistiva · Servicios terapéuticos/rehabilitación · Medicamentos · Insumos personales · Otros | no |
|
||||||
|
| 8.3 | multiple_choice | ¿Cómo sostenés los gastos y responsabilidades del cuidado? | Solo con mi empleo · Ingresos extraordinarios · Emprendimiento · Me ayudan familiares · Me ayudan organizaciones sociales · Me ayudan vecinos/amigos · Eventos de recaudación · Subsidios estatales | no |
|
||||||
|
| 8.4 | single_choice | ¿En qué franja salarial mensual te ubicás con tu trabajo en esta empresa? *(CONFIDENCIAL)* | 9 franjas, hasta 3M a 50M+ Gs. | ✅ analyst-only, nunca en vistas de empresa (igual criterio que 3.4 en v2.1) |
|
||||||
|
|
||||||
|
## Sección 9 — Conciencia organizacional (`S9`, pos. 90)
|
||||||
|
|
||||||
|
Cond.: header, 9.1 y 9.4 ocultos si Ninguna (`r_hide_ning`). 9.2/9.3 además ocultas si soloNino.
|
||||||
|
**9.1 y 9.4 permanecen visibles en soloNino.**
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Opciones | Notas |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 9.1 | single_choice | ¿Sentís que tu empresa conoce o comprende la realidad del cuidado y la discapacidad de su gente? | Sí, acompañado/a · Parcialmente · No, invisible · Nunca hemos tocado el tema | |
|
||||||
|
| 9.2 | multiple_choice (**max_select=3**) | ¿Qué apoyos de la empresa te ayudarían más? | Flexibilidad horaria/remoto · Apoyo económico/convenios · Beneficios terapias/salud · Contención emocional/psicológica · Capacitación y autocuidado · Redes de apoyo/grupos de pares · Información sobre recursos | |
|
||||||
|
| 9.3 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores dentro de la empresa? | Sí, definitivamente · Tal vez · No por ahora · Prefiero mantenerlo en privado | |
|
||||||
|
| 9.4 | single_choice | ¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo? | Sí · No | |
|
||||||
|
|
||||||
|
## Sección 10 — Comentarios adicionales (`S10`, pos. 100)
|
||||||
|
|
||||||
|
Siempre visible — es el destino del salto "Ninguna de las anteriores" en 4.1.
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Notas |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| H1 | multiple_choice (**max_select=3**) | ¿Qué tipo de apoyo de tu empresa te ayudaría más? (hasta 3) | Flexibilidad horaria/remoto · Info recursos y servicios · Apoyo psicológico · Espacio de escucha · Convenios descuentos · Grupos de pares · Capacitación autocuidado · Nada, estoy bien |
|
| 10.1 | text_long | ¿Te gustaría dejar un comentario o sugerencia especial? (opcional) | `hint`='Opcional.' — NO entra al store analítico crudo sin categorizar (regla 4 CLAUDE.md) |
|
||||||
| H2 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores? | Sí, definitivamente · Tal vez · No por ahora · Prefiero privacidad |
|
|
||||||
| H3 | text_long | ¿Querés compartir algo más sobre tu experiencia? (opcional) | Texto libre, opcional. **No entra al store analítico crudo** (regla 4 CLAUDE.md). |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mapeo a cuasi-identificadores (`responses.qi_*`)
|
## Totales
|
||||||
- `qi_age_bucket` ← **A2** (ya viene en bucket — bien).
|
|
||||||
- `qi_sector` ← **A1** (área).
|
|
||||||
- `qi_zone` ← **no aplica en este instrumento** (no se pregunta zona geográfica; es intra-empresa). Va NULL.
|
|
||||||
|
|
||||||
## ⚠️ Hallazgo para el director (decisión de modelo de datos, Nivel 3 — NO ejecutar)
|
10 secciones (`type='section'`) + 40 preguntas = **50 filas** en `questions` por encuesta.
|
||||||
El dashboard original quería filtrar por **género (A3)** y **modalidad (A4)**, pero `agg_segment`
|
Preguntas por sección: S1=2, S2=7, S3=5, S4=3, S5=4, S6=4, S7=6, S8=4, S9=4, S10=1 → 40.
|
||||||
solo tiene en su whitelist `qi_age_bucket / qi_sector / qi_zone`. Para segmentar/filtrar por género o
|
|
||||||
modalidad en el dashboard (Sprint 3), hay que decidir: ¿se agregan columnas `qi_gender` / `qi_modality`
|
## Consent versions
|
||||||
(y a la whitelist de `agg_segment`), o se resuelven por otra vía? Es decisión de esquema → consultar
|
|
||||||
antes de Sprint 3, no resolver en el seed.
|
- **v1** (`20000000-0000-0000-0000-000000000001`): texto original v2.1, preservado — no se
|
||||||
|
altera para no invalidar `consent_records` ya firmados contra esa versión (regla 5 CLAUDE.md).
|
||||||
|
- **v2** (`20000000-0000-0000-0000-000000000002`): texto revisado para el instrumento v3.0.0,
|
||||||
|
asignado a las 3 encuestas seed. Scopes: `["operativo","macro_n1"]` (Modo A).
|
||||||
|
|
||||||
|
## Discrepancias mockup vs. Excel resueltas (AP.10)
|
||||||
|
|
||||||
|
Reportadas y resueltas con la directora antes de escribir el seed:
|
||||||
|
|
||||||
|
1. **Wording de preguntas y opciones**: el Excel corregido es la fuente autoritativa en todos
|
||||||
|
los casos donde difiere del mockup (~30 discrepancias menores de texto).
|
||||||
|
2. **Recorrido soloNino**: el mockup marcaba `skipSoloNino:true` en TODA la Sección 5 (incluida
|
||||||
|
5.3). El Excel especifica un recorrido más granular: 5.3 → 8.4 → 9.1 → 9.4 → fin. Resuelto a
|
||||||
|
favor del Excel (ver "Flujo condicional — recorrido soloNino" arriba).
|
||||||
|
3. **8.3 "Eventos de recaudación"**: opción presente en el Excel, ausente en el mockup. Incluida.
|
||||||
|
|
||||||
|
## ⚠️ Decisiones pendientes para el director (NO ejecutar sin aprobación)
|
||||||
|
|
||||||
|
- **Ranking widget (9.2):** actual es `multiple_choice` con `max_select=3`. Si se requiere
|
||||||
|
orden de prioridad, pasa a tipo `ranking` (mismo criterio que 6.2 en v2.1).
|
||||||
|
- **Motor genérico de formularios (Fase 2, BACKLOG-002):** este contrato es el primer caso de
|
||||||
|
prueba; el intérprete declarativo se generaliza después de validar el wizard de cuidadores.
|
||||||
|
- **Migración de datos v2.1 → v3:** NO se ejecuta. v2.1.0-stable permanece intacta; v3 es un
|
||||||
|
instrumento nuevo con seed limpio.
|
||||||
|
- **`qi_city`, RE-INDEX scoring, salary analytics:** decisiones heredadas de v2.1, aún abiertas,
|
||||||
|
no se resuelven en esta etapa.
|
||||||
|
|||||||
171
TECH_DEBT.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# TECH_DEBT.md — Deuda técnica registrada
|
||||||
|
|
||||||
|
> Ítems identificados durante el desarrollo. Cada ítem documenta qué falta, por qué
|
||||||
|
> no se hizo ahora, y qué trigger habilitaría su resolución.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-001] Ranking widget para 6.2
|
||||||
|
|
||||||
|
- **Estado:** 6.2 es actualmente `multiple_choice` max_select=3. El widget muestra
|
||||||
|
frecuencia simple (top 8).
|
||||||
|
- **Deuda:** cuando el tipo pase a `ranking` con JSONB `{rank1, rank2, rank3}`, el
|
||||||
|
widget debe reescribirse para mostrar ponderación por posición.
|
||||||
|
- **Trigger:** decisión del director sobre el tipo de 6.2 en próxima versión del
|
||||||
|
instrumento (anotada en SEED_SPEC.md § Decisiones pendientes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-002] KPI NPS Cuidador
|
||||||
|
|
||||||
|
- **Estado:** el instrumento actual no tiene pregunta NPS. El KPI fue eliminado del
|
||||||
|
dashboard en Etapa 2E.
|
||||||
|
- **Para implementar:** agregar en próxima versión del instrumento:
|
||||||
|
"En una escala de 0 a 10, ¿qué tan probable es que recomiendes esta empresa a otra
|
||||||
|
persona cuidadora?"
|
||||||
|
- **Trigger:** decisión de incluir NPS en el instrumento v2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-003] Brecha de políticas (oferta vs. demanda)
|
||||||
|
|
||||||
|
- **Estado:** el instrumento captura solo demanda (6.2 = apoyos que ayudarían).
|
||||||
|
No captura qué políticas la empresa ya ofrece. W6 muestra solo demanda.
|
||||||
|
- **Para implementar:** agregar en próxima versión del instrumento:
|
||||||
|
"¿Qué apoyos para el cuidado ofrece tu empresa hoy?" (multi-select con las mismas
|
||||||
|
opciones de 6.2 + "Ninguno" + "No lo sé").
|
||||||
|
- **Trigger:** decisión de agregar la pregunta de oferta al instrumento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-004] Intención directa de salida laboral
|
||||||
|
|
||||||
|
- **Estado:** el instrumento no pregunta directamente por intención de dejar el
|
||||||
|
empleo. W8 usa indicador compuesto (4.3 + 5.1 + 5.2) renombrado a "Saturación".
|
||||||
|
- **Para implementar:** agregar en próxima versión:
|
||||||
|
"En los últimos 6 meses, ¿pensaste en dejar este empleo por razones relacionadas
|
||||||
|
al cuidado?" (Sí/Frecuentemente · Sí/A veces · No · Prefiero no decir).
|
||||||
|
- **Trigger:** validación metodológica de que el indicador compuesto no es suficiente
|
||||||
|
para medir intención de salida.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-005] qi_city / qi_country — migración pendiente
|
||||||
|
|
||||||
|
- **Estado:** A1 (Departamento) y A2 (Ciudad) se guardan en `answers`. Las columnas
|
||||||
|
`qi_city` y `qi_country` en `responses` son NULL. Sprint 3 las necesita para
|
||||||
|
segmentación avanzada.
|
||||||
|
- **Para implementar:** migration futura que agregue extracción de A1/A2 en el insert
|
||||||
|
y extienda la whitelist de `agg_segment`.
|
||||||
|
- **Trigger:** inicio de Sprint 3 (dashboard avanzado con filtros de ciudad).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-006] Ranking widget para 2.1
|
||||||
|
|
||||||
|
- **Estado:** Etapa 3I2 (Cambio 5) cambió 2.1 ("¿Quién es el/la responsable principal
|
||||||
|
del cuidado?") de `single_choice` a `multiple_choice`. Permite selección múltiple
|
||||||
|
pero sin orden de prioridad — pierde la noción de "responsable principal" cuando
|
||||||
|
el respondente marca más de una opción.
|
||||||
|
- **Deuda:** implementar widget de ranking para 2.1 con el orden:
|
||||||
|
Principal cuidador soy yo > secundario instituto > principal pagado > secundario
|
||||||
|
familiar (mismo patrón que TECH-DEBT-001 para 6.2).
|
||||||
|
- **Trigger:** decisión del director sobre el tipo de 2.1 en próxima versión del
|
||||||
|
instrumento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-008] /admin/responses sin autenticación
|
||||||
|
|
||||||
|
- **Estado:** RESUELTO (27 jun 2026, ETAPA_AUTH).
|
||||||
|
- **Descripción:** la vista de control `/admin/responses` no tenía auth. Se implementó
|
||||||
|
login con Supabase Auth (`@supabase/ssr`), `middleware.ts` protegiendo
|
||||||
|
`/admin/:path*` y `/dashboard/:path*` con redirect a `/login` sin sesión, y la página
|
||||||
|
migrada de `service_role` a la sesión del usuario (`anon` key + RLS). Sigue
|
||||||
|
excluyendo preguntas `is_sensitive=true` y `text_long` — ese diseño no cambió.
|
||||||
|
- **Nota:** la vista no distingue roles dentro de staff todavía (todo usuario con fila
|
||||||
|
en `user_roles` ve lo mismo). Diferenciar `platform_admin` de `org_admin` ahí sería
|
||||||
|
una etapa nueva, no parte de este cierre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-010] E9 lee modalidad por JOIN a `answers` en vez de `qi_modalidad`
|
||||||
|
|
||||||
|
- **Estado:** ABIERTO — decisión consciente, no un descuido.
|
||||||
|
- **Descripción:** `rpc_me_segmentacion` (E9, Mirada Empresa) segmenta por nivel
|
||||||
|
organizacional y por modalidad de trabajo. El nivel (`2.6`) sí se promovió a
|
||||||
|
`responses.qi_sector` en el submit (Etapa 4G) — E9 lee de ahí directo. La
|
||||||
|
modalidad (`2.7`) NO tiene una columna `qi_modalidad` equivalente — promoverla
|
||||||
|
hubiera significado reabrir `rpc_submit_response`, ya verificada contra
|
||||||
|
Postgres real en 4G. Para no reabrirla, E9 resuelve modalidad con un JOIN a
|
||||||
|
`answers`/`questions` por `code='2.7'` en tiempo de consulta.
|
||||||
|
- **Costo del JOIN vs. la columna:** una pasada extra por `answers`/`questions`
|
||||||
|
en cada llamada a E9 — aceptable para el volumen actual (decenas/cientos de
|
||||||
|
respuestas por encuesta), pero más lento que leer una columna denormalizada
|
||||||
|
si el dataset crece mucho o si la segmentación por modalidad se usa con
|
||||||
|
frecuencia en varios widgets.
|
||||||
|
- **Acción si se decide promover:** agregar `qi_modalidad text` a `responses`,
|
||||||
|
poblarla en `rpc_submit_response` (requiere re-verificar esa RPC contra
|
||||||
|
Postgres real, igual que se hizo en 4G), y migrar E9 a leerla directo —
|
||||||
|
mismo patrón que se usó para `qi_sector`.
|
||||||
|
- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por
|
||||||
|
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
|
||||||
|
promoverla de una vez en lugar de cada RPC reimplementando el JOIN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-011] Sección de pregunta inferida por posición, no modelada como dato
|
||||||
|
|
||||||
|
- **Estado:** ABIERTO — decisión consciente, no un descuido. Surgió en
|
||||||
|
ETAPA_ADMIN (vista detallada `/admin/responses/[id]`, 27 jun 2026): el prompt
|
||||||
|
de etapa asumía columnas `questions.section_number`/`section_title` que NO
|
||||||
|
existen. Verificado contra el esquema real (`supabase/migrations/
|
||||||
|
20260531000001_create_tables.sql` — única definición de `questions`,
|
||||||
|
`20260620000002_form_schema_v3.sql` solo agrega comentarios, ninguna
|
||||||
|
migración agrega esas columnas).
|
||||||
|
- **Descripción:** hoy `questions` no tiene ningún campo que declare "esta
|
||||||
|
pregunta pertenece a la sección X". La sección es una fila más en la misma
|
||||||
|
tabla, con `type='section'`, intercalada por `position` entre las preguntas
|
||||||
|
reales — el código (`lib/form-engine.ts:146-148` en el wizard, y la vista
|
||||||
|
detallada de admin) infiere el agrupamiento recorriendo las filas en orden
|
||||||
|
de `position` y "abriendo" una sección nueva cada vez que encuentra una fila
|
||||||
|
`type='section'`. El título de la sección es el `prompt` de esa fila — no
|
||||||
|
hay un campo `title` separado.
|
||||||
|
- **Por qué es deuda, no solo una curiosidad de modelado:** el motor está
|
||||||
|
pensado para ser un "motor de formularios de encuestas" genérico — no toda
|
||||||
|
encuesta futura va a tener secciones, y las que las tengan no necesariamente
|
||||||
|
van a querer modelarlas como filas-marcador en la misma tabla que las
|
||||||
|
preguntas. Hoy CUALQUIER consumidor que necesite agrupar por sección
|
||||||
|
(wizard, admin, y a futuro cualquier export/reporte) tiene que reimplementar
|
||||||
|
el mismo recorrido secuencial con el mismo supuesto implícito ("la fila
|
||||||
|
`type='section'` más cercana hacia atrás en `position` es la sección de esta
|
||||||
|
pregunta"). Si esa lógica se necesita en un tercer lugar, ya es momento de
|
||||||
|
promoverla a una función compartida — y si el motor crece a encuestas con
|
||||||
|
estructuras más ricas (subsecciones, secciones condicionales, secciones que
|
||||||
|
no son "preguntas" en absoluto), el modelo actual no escala sin reescribir
|
||||||
|
cada consumidor.
|
||||||
|
- **Alternativa más genérica y escalable (no implementada, propuesta para
|
||||||
|
evaluar cuando se priorice):** sacar la sección de `questions.type` y
|
||||||
|
modelarla como entidad propia — opción simple: `questions.section_id uuid
|
||||||
|
NULL REFERENCES sections(id)` + tabla `sections(id, survey_id, title,
|
||||||
|
position)`; opción más liviana sin tabla nueva: `questions.section_title
|
||||||
|
text NULL` + `questions.section_position int NULL` poblados directamente en
|
||||||
|
cada pregunta (denormalizado, sin fila-marcador `type='section'` separada).
|
||||||
|
Cualquiera de las dos deja "pertenece a la sección X" como un hecho
|
||||||
|
consultable con una columna, no como un cálculo derivado de recorrer
|
||||||
|
`position` — y ninguna asume que toda encuesta tiene secciones (el campo es
|
||||||
|
nullable). Migrar implicaría: (1) nueva(s) columna(s)/tabla, (2) backfill
|
||||||
|
desde las filas `type='section'` existentes, (3) actualizar
|
||||||
|
`lib/form-engine.ts` y la vista detallada de admin para leer el dato en vez
|
||||||
|
de inferirlo, (4) decidir si `type='section'` se deprecia o convive.
|
||||||
|
- **Acción tomada mientras tanto:** la vista detallada de admin
|
||||||
|
(`app/admin/responses/[id]/page.tsx`) replica la misma lógica de
|
||||||
|
`lib/form-engine.ts` (recorrido secuencial por `position`, abre sección en
|
||||||
|
cada `type='section'`) — no inventa columnas nuevas, no diverge del modelo
|
||||||
|
actual.
|
||||||
|
- **Trigger:** si una tercera feature necesita el mismo agrupamiento, extraer
|
||||||
|
la lógica a un helper compartido en `lib/` en vez de triplicarla. Si el
|
||||||
|
producto efectivamente evoluciona a soportar surveys con estructuras de
|
||||||
|
sección más complejas que el caso actual, evaluar la migración a columna/
|
||||||
|
tabla explícita antes de seguir apilando consumidores sobre el supuesto
|
||||||
|
implícito.
|
||||||
106
app/actions/invite-user.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use server'
|
||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { getSupabaseAdmin } from '@/lib/supabase'
|
||||||
|
|
||||||
|
export type InviteUserResult = { success: true } | { success: false; error: string }
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
// Cliente ligado a la sesión (cookies) — anon key, respeta RLS. Se usa solo
|
||||||
|
// para identificar a quien llama y leer SU PROPIA fila de user_roles
|
||||||
|
// (policy user_roles_select_own: user_id = auth.uid()). Nunca para las
|
||||||
|
// escrituras de esta acción — esas requieren service_role (ver getSupabaseAdmin).
|
||||||
|
async function getSessionClient() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Server Action no siempre puede mutar cookies — se ignora
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invita a un usuario de empresa y lo asocia a su organización con rol org_admin.
|
||||||
|
// Seguridad: quien llama debe ser platform_admin — se verifica leyendo user_roles
|
||||||
|
// en la DB con la sesión real (auth.uid()), nunca confiando en datos del formData.
|
||||||
|
// El rol del nuevo usuario es siempre 'org_admin', fijo en código — no es un
|
||||||
|
// valor que el formulario pueda controlar para escalar privilegios.
|
||||||
|
export async function inviteUser(formData: FormData): Promise<InviteUserResult> {
|
||||||
|
const sessionClient = await getSessionClient()
|
||||||
|
|
||||||
|
// auth.getUser() valida el token contra el servidor de Auth — no decodifica
|
||||||
|
// la cookie localmente (mismo patrón que middleware.ts).
|
||||||
|
const { data: { user: caller } } = await sessionClient.auth.getUser()
|
||||||
|
if (!caller) return { success: false, error: 'no_autenticado' }
|
||||||
|
|
||||||
|
// Verificación de autorización — lee la fila real de user_roles del llamante.
|
||||||
|
const { data: callerRole, error: roleErr } = await sessionClient
|
||||||
|
.from('user_roles')
|
||||||
|
.select('app_role')
|
||||||
|
.eq('user_id', caller.id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (roleErr || callerRole?.app_role !== 'platform_admin') {
|
||||||
|
return { success: false, error: 'forbidden' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = String(formData.get('email') ?? '').trim().toLowerCase()
|
||||||
|
const orgId = String(formData.get('orgId') ?? '').trim()
|
||||||
|
|
||||||
|
if (!EMAIL_RE.test(email)) return { success: false, error: 'email_invalido' }
|
||||||
|
if (!orgId) return { success: false, error: 'organizacion_requerida' }
|
||||||
|
|
||||||
|
const admin = getSupabaseAdmin()
|
||||||
|
|
||||||
|
// La organización debe existir — admin client porque ya está autorizado el
|
||||||
|
// llamante (paso anterior) y queremos evitar depender del claim app_role del
|
||||||
|
// JWT (puede estar stale si el rol cambió desde la última emisión de token).
|
||||||
|
const { data: org } = await admin
|
||||||
|
.from('organizations')
|
||||||
|
.select('id')
|
||||||
|
.eq('id', orgId)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (!org) return { success: false, error: 'organizacion_no_encontrada' }
|
||||||
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? ''
|
||||||
|
const { data: inviteData, error: inviteErr } = await admin.auth.admin.inviteUserByEmail(
|
||||||
|
email,
|
||||||
|
{ redirectTo: `${siteUrl}/auth/confirm` }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (inviteErr || !inviteData?.user) {
|
||||||
|
return { success: false, error: `invite_failed: ${inviteErr?.message ?? 'sin detalle'}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUserId = inviteData.user.id
|
||||||
|
|
||||||
|
// Insert inmediato — antes de que el usuario confirme nada. user_roles no
|
||||||
|
// tiene policy de INSERT (RLS deniega por defecto), por eso requiere admin.
|
||||||
|
const { error: roleInsertErr } = await admin
|
||||||
|
.from('user_roles')
|
||||||
|
.insert({ user_id: newUserId, app_role: 'org_admin', org_id: orgId })
|
||||||
|
|
||||||
|
if (roleInsertErr) {
|
||||||
|
// Rollback — no dejar un usuario en auth.users sin rol asignado.
|
||||||
|
await admin.auth.admin.deleteUser(newUserId)
|
||||||
|
return { success: false, error: `role_insert_failed: ${roleInsertErr.message}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
94
app/actions/submit-survey.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use server'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
|
||||||
|
import { buildFormSchema } from '@/lib/form-engine'
|
||||||
|
import type { Question as EngineQuestion } from '@/lib/form-engine.types'
|
||||||
|
import { validateSubmission, type SubmitAnswerEntry } from '@/lib/submit-validation'
|
||||||
|
|
||||||
|
export type SubmitResult =
|
||||||
|
| { success: true }
|
||||||
|
| { success: false; error: string }
|
||||||
|
|
||||||
|
export async function submitSurvey(payload: {
|
||||||
|
surveyId: string
|
||||||
|
answers: SubmitAnswerEntry[]
|
||||||
|
consentOperativo: boolean
|
||||||
|
consentMacroN1: boolean
|
||||||
|
}): Promise<SubmitResult> {
|
||||||
|
// ip_hash: server-only, nunca en texto plano (dedup sin identidad)
|
||||||
|
const headersList = await headers()
|
||||||
|
const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||||
|
const salt = process.env.IP_HASH_SALT ?? ''
|
||||||
|
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
|
||||||
|
|
||||||
|
// Cliente con anon key — respeta RLS. NUNCA service_role (regla 9 CLAUDE.md).
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||||
|
const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey)
|
||||||
|
|
||||||
|
// 1. Validación #1: survey existe y está activa
|
||||||
|
const { data: survey, error: sErr } = await supabase
|
||||||
|
.from('surveys')
|
||||||
|
.select('id, org_id, status')
|
||||||
|
.eq('id', payload.surveyId)
|
||||||
|
.eq('status', 'live')
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (sErr) return { success: false, error: 'survey_lookup_failed' }
|
||||||
|
if (!survey) return { success: false, error: 'survey_not_found_or_not_live' }
|
||||||
|
|
||||||
|
// 2. Carga el esquema completo de la encuesta — es contra ESTO que se valida,
|
||||||
|
// nunca contra lo que el cliente afirma que vio.
|
||||||
|
const { data: questionRows, error: qErr } = await supabase
|
||||||
|
.from('questions')
|
||||||
|
.select(
|
||||||
|
'id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||||
|
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||||
|
)
|
||||||
|
.eq('survey_id', payload.surveyId)
|
||||||
|
|
||||||
|
if (qErr || !questionRows) return { success: false, error: 'schema_load_failed' }
|
||||||
|
|
||||||
|
const schema = buildFormSchema(questionRows as unknown as EngineQuestion[])
|
||||||
|
|
||||||
|
// 3. Validaciones #2-#7 — reusa lib/form-engine.ts, no reimplementa flujo.
|
||||||
|
const result = validateSubmission(schema, payload.answers, payload.consentOperativo)
|
||||||
|
if (!result.ok) {
|
||||||
|
return { success: false, error: `validation_failed: ${result.errors.join(', ')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Cuasi-identificadores — códigos v3 (2.1 zona, 2.4 edad).
|
||||||
|
// qi_sector: v3 no tiene una pregunta equivalente a "área/gerencia" de v2.
|
||||||
|
// Se usa 2.6 (nivel organizacional) como proxy más cercano disponible —
|
||||||
|
// decisión a confirmar con el director, no hay un mapeo exacto.
|
||||||
|
const findDefault = (code: string): string | null => {
|
||||||
|
const entry = payload.answers.find((e) => e.code === code && e.instanceId === 'default')
|
||||||
|
return typeof entry?.value === 'string' ? entry.value : null
|
||||||
|
}
|
||||||
|
const qiZone = findDefault('2.1')
|
||||||
|
const qiAgeBucket = findDefault('2.4')
|
||||||
|
const qiSector = findDefault('2.6')
|
||||||
|
|
||||||
|
// 5. Persistencia atómica vía RPC SECURITY DEFINER — ver migración
|
||||||
|
// 20260624000001_submit_rpc.sql para el detalle de por qué RPC y no
|
||||||
|
// INSERTs sueltos desde acá.
|
||||||
|
const { error: rpcErr } = await supabase.rpc('rpc_submit_response', {
|
||||||
|
p_survey_id: payload.surveyId,
|
||||||
|
p_ip_hash: ipHash,
|
||||||
|
p_qi_age_bucket: qiAgeBucket,
|
||||||
|
p_qi_sector: qiSector,
|
||||||
|
p_qi_zone: qiZone,
|
||||||
|
p_consent_operativo: payload.consentOperativo,
|
||||||
|
p_consent_macro_n1: payload.consentMacroN1,
|
||||||
|
p_answers: result.rows.map((r) => ({
|
||||||
|
question_id: r.questionId,
|
||||||
|
instance_id: r.instanceId,
|
||||||
|
value: r.value,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rpcErr) return { success: false, error: 'submit_failed' }
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
274
app/admin/responses/[id]/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { formatDate, renderValue } from '../admin-format'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface QuestionRow {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
prompt: string
|
||||||
|
type: string
|
||||||
|
position: number
|
||||||
|
instance_of: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnswerRow {
|
||||||
|
question_id: string
|
||||||
|
value: unknown
|
||||||
|
instance_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseMeta {
|
||||||
|
id: string
|
||||||
|
submitted_at: string
|
||||||
|
qi_zone: string | null
|
||||||
|
qi_sector: string | null
|
||||||
|
qi_age_bucket: string | null
|
||||||
|
survey_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRow {
|
||||||
|
code: string
|
||||||
|
prompt: string
|
||||||
|
rendered: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstanceGroup {
|
||||||
|
instanceId: string
|
||||||
|
rows: DetailRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionGroup {
|
||||||
|
title: string | null
|
||||||
|
instances: InstanceGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSupabaseSession() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Server Component — no puede mutar cookies, se ignora
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backHref(surveyId: string | undefined, page: string | undefined): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (surveyId) params.set('survey_id', surveyId)
|
||||||
|
if (page) params.set('page', page)
|
||||||
|
const qs = params.toString()
|
||||||
|
return `/admin/responses${qs ? `?${qs}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ResponseDetailPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
searchParams: Promise<{ survey_id?: string; page?: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const { survey_id: backSurveyId, page: backPage } = await searchParams
|
||||||
|
const back = backHref(backSurveyId, backPage)
|
||||||
|
const supabase = await getSupabaseSession()
|
||||||
|
|
||||||
|
const { data: response, error: responseError } = await supabase
|
||||||
|
.from('responses')
|
||||||
|
.select('id, submitted_at, qi_zone, qi_sector, qi_age_bucket, survey_id')
|
||||||
|
.eq('id', id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (responseError || !response) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||||
|
<h1 className="admin-title">Respuesta no encontrada</h1>
|
||||||
|
<p className="admin-empty">
|
||||||
|
No existe esta respuesta o no tenés permiso para verla.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = response as ResponseMeta
|
||||||
|
|
||||||
|
// Mismo filtro de sensibles que la lista (regla no negociable #4 CLAUDE.md) — NO diverge.
|
||||||
|
const { data: questionsData, error: questionsError } = await supabase
|
||||||
|
.from('questions')
|
||||||
|
.select('id, code, prompt, type, position, instance_of')
|
||||||
|
.eq('survey_id', meta.survey_id)
|
||||||
|
.eq('is_sensitive', false)
|
||||||
|
.neq('type', 'text_long')
|
||||||
|
.order('position', { ascending: true })
|
||||||
|
|
||||||
|
if (questionsError) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||||
|
<h1 className="admin-title">Control de respuestas</h1>
|
||||||
|
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||||
|
<pre className="admin-error-detail">{questionsError.message}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = (questionsData ?? []) as QuestionRow[]
|
||||||
|
const answerQuestionIds = questions.filter((q) => q.type !== 'section').map((q) => q.id)
|
||||||
|
|
||||||
|
const { data: answersData, error: answersError } =
|
||||||
|
answerQuestionIds.length > 0
|
||||||
|
? await supabase
|
||||||
|
.from('answers')
|
||||||
|
.select('question_id, value, instance_id')
|
||||||
|
.eq('response_id', id)
|
||||||
|
.in('question_id', answerQuestionIds)
|
||||||
|
: { data: [] as AnswerRow[], error: null }
|
||||||
|
|
||||||
|
if (answersError) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||||
|
<h1 className="admin-title">Control de respuestas</h1>
|
||||||
|
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||||
|
<pre className="admin-error-detail">{answersError.message}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = (answersData ?? []) as AnswerRow[]
|
||||||
|
const answersByQuestion = new Map<string, AnswerRow[]>()
|
||||||
|
answers.forEach((a) => {
|
||||||
|
const list = answersByQuestion.get(a.question_id) ?? []
|
||||||
|
list.push(a)
|
||||||
|
answersByQuestion.set(a.question_id, list)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Agrupar por sección. No existe section_number/section_title en `questions`
|
||||||
|
// (verificado — ver TECH_DEBT.md). Las secciones hoy son filas type='section'
|
||||||
|
// intercaladas por position, mismo modelo que usa el wizard en
|
||||||
|
// lib/form-engine.ts:146-148 — se replica esa lógica acá, no se inventa una
|
||||||
|
// columna nueva.
|
||||||
|
const sections: SectionGroup[] = []
|
||||||
|
let current: SectionGroup = { title: null, instances: [] }
|
||||||
|
let hasContent = false
|
||||||
|
|
||||||
|
function instanceGroupFor(section: SectionGroup, instanceId: string): InstanceGroup {
|
||||||
|
let group = section.instances.find((g) => g.instanceId === instanceId)
|
||||||
|
if (!group) {
|
||||||
|
group = { instanceId, rows: [] }
|
||||||
|
section.instances.push(group)
|
||||||
|
}
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
questions.forEach((q) => {
|
||||||
|
if (q.type === 'section') {
|
||||||
|
if (hasContent || current.title !== null) sections.push(current)
|
||||||
|
current = { title: q.prompt, instances: [] }
|
||||||
|
hasContent = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const qAnswers = answersByQuestion.get(q.id)
|
||||||
|
if (qAnswers && qAnswers.length > 0) {
|
||||||
|
qAnswers.forEach((a) => {
|
||||||
|
const group = instanceGroupFor(current, a.instance_id)
|
||||||
|
group.rows.push({ code: q.code, prompt: q.prompt, rendered: renderValue(a.value, q.type) })
|
||||||
|
})
|
||||||
|
hasContent = true
|
||||||
|
} else if (!q.instance_of) {
|
||||||
|
// Pregunta normal sin respuesta — placeholder "sin respuesta" visible.
|
||||||
|
// Si es instance_of y no hay respuestas, no se generaron instancias
|
||||||
|
// (igual que el wizard no muestra pantallas de instancia sin generador
|
||||||
|
// disparado) — no se fabrica un grupo "Familiar" vacío.
|
||||||
|
const group = instanceGroupFor(current, 'default')
|
||||||
|
group.rows.push({ code: q.code, prompt: q.prompt, rendered: null })
|
||||||
|
hasContent = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (hasContent || current.title !== null) sections.push(current)
|
||||||
|
|
||||||
|
sections.forEach((s) => {
|
||||||
|
s.instances.sort((a, b) => {
|
||||||
|
if (a.instanceId === 'default') return -1
|
||||||
|
if (b.instanceId === 'default') return 1
|
||||||
|
return a.instanceId.localeCompare(b.instanceId, undefined, { numeric: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||||
|
|
||||||
|
<header className="admin-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-title">Respuesta {meta.id.slice(0, 8)}</h1>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
{formatDate(meta.submitted_at)} · Departamento: {meta.qi_zone ?? '—'} · Sector:{' '}
|
||||||
|
{meta.qi_sector ?? '—'} · Edad: {meta.qi_age_bucket ?? '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{sections.map((section, si) => (
|
||||||
|
<section key={si} className="admin-detail-section">
|
||||||
|
{section.title && <h2 className="admin-detail-section-title">{section.title}</h2>}
|
||||||
|
{section.instances.map((group) => (
|
||||||
|
<div key={group.instanceId} className="admin-detail-instance">
|
||||||
|
{group.instanceId !== 'default' && (
|
||||||
|
<span className="admin-instance-badge">
|
||||||
|
Familiar {group.instanceId.replace('familiar_', '')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Código</th>
|
||||||
|
<th>Pregunta</th>
|
||||||
|
<th>Valor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{group.rows.map((row, ri) => (
|
||||||
|
<tr key={ri}>
|
||||||
|
<td>{row.code}</td>
|
||||||
|
<td className="admin-prompt">{row.prompt}</td>
|
||||||
|
<td>
|
||||||
|
{row.rendered === null ? (
|
||||||
|
<span className="admin-nodata">sin respuesta</span>
|
||||||
|
) : (
|
||||||
|
row.rendered
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{sections.length === 0 && (
|
||||||
|
<p className="admin-empty">Esta respuesta no tiene preguntas visibles para mostrar.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
app/admin/responses/admin-format.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Compartido entre la lista (page.tsx) y la vista detallada ([id]/page.tsx) —
|
||||||
|
// deben renderizar fecha y valor exactamente igual; antes vivían duplicados
|
||||||
|
// en los dos archivos y ya habían divergido.
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// null/undefined → null (renderizado como badge "sin dato"/"sin respuesta" por el caller)
|
||||||
|
export function renderValue(value: unknown, type: string): string | null {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'single_choice':
|
||||||
|
case 'text_short':
|
||||||
|
return String(value).replace(/^"|"$/g, '')
|
||||||
|
case 'multiple_choice':
|
||||||
|
if (Array.isArray(value)) return value.join(', ')
|
||||||
|
return String(value)
|
||||||
|
case 'rating':
|
||||||
|
return String(value)
|
||||||
|
default:
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
274
app/admin/responses/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { ResponsesTable, type DisplayRow } from './responses-table'
|
||||||
|
import { SurveySelect } from './survey-select'
|
||||||
|
import { AdminNavBar } from '@/components/admin-nav-bar'
|
||||||
|
import { formatDate, renderValue } from './admin-format'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
const PROMPT_MAX_LEN = 60
|
||||||
|
|
||||||
|
interface AllowedQuestion {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
prompt: string
|
||||||
|
type: string
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnswerRow {
|
||||||
|
value: unknown
|
||||||
|
question_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseRow {
|
||||||
|
id: string
|
||||||
|
submitted_at: string
|
||||||
|
qi_zone: string | null
|
||||||
|
qi_sector: string | null
|
||||||
|
answers: AnswerRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SurveyOption {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSupabaseSession() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Server Component — no puede mutar cookies, se ignora
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max = PROMPT_MAX_LEN): string {
|
||||||
|
return text.length > max ? `${text.slice(0, max)}...` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageHref(surveyId: string | undefined, page: number): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (surveyId) params.set('survey_id', surveyId)
|
||||||
|
params.set('page', String(page))
|
||||||
|
return `/admin/responses?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminResponsesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ survey_id?: string; page?: string }>
|
||||||
|
}) {
|
||||||
|
const { survey_id: surveyId, page: pageParam } = await searchParams
|
||||||
|
const page = Math.max(1, Number(pageParam) || 1)
|
||||||
|
const supabase = await getSupabaseSession()
|
||||||
|
|
||||||
|
// 0 y 1 no dependen entre sí — en paralelo.
|
||||||
|
// 0. Surveys para el filtro — dinámico, no hardcodeado. Solo trae lo que
|
||||||
|
// RLS deja ver al usuario autenticado. Si falla, el filtro se degrada a
|
||||||
|
// "sin opciones" en vez de tirar abajo la página entera — la lista de
|
||||||
|
// respuestas no depende de esta query.
|
||||||
|
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
|
||||||
|
// antes de tocar la tabla answers. Sus valores nunca se consultan.
|
||||||
|
const [
|
||||||
|
{ data: surveysData, error: surveysError },
|
||||||
|
{ data: questionsData, error: questionsError },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('surveys').select('id, title').order('title'),
|
||||||
|
supabase
|
||||||
|
.from('questions')
|
||||||
|
.select('id, code, prompt, type, position')
|
||||||
|
.eq('is_sensitive', false)
|
||||||
|
.neq('type', 'text_long')
|
||||||
|
.order('position', { ascending: true }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const surveys = (surveysData ?? []) as SurveyOption[]
|
||||||
|
const selectedSurvey = surveys.find((s) => s.id === surveyId)
|
||||||
|
|
||||||
|
if (questionsError) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<h1 className="admin-title">Control de respuestas</h1>
|
||||||
|
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||||
|
<pre className="admin-error-detail">{questionsError.message}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedQuestions = (questionsData ?? []) as AllowedQuestion[]
|
||||||
|
const allowedIds = allowedQuestions.map((q) => q.id)
|
||||||
|
const questionMap = new Map(allowedQuestions.map((q) => [q.id, q]))
|
||||||
|
|
||||||
|
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
|
||||||
|
// embebido a solo question_id permitidos (consulta, no post-filtro).
|
||||||
|
// Paginado con .range() — page size 50, no LIMIT fijo sin paginación.
|
||||||
|
let responsesQuery = supabase
|
||||||
|
.from('responses')
|
||||||
|
.select(
|
||||||
|
`
|
||||||
|
id,
|
||||||
|
submitted_at,
|
||||||
|
qi_zone,
|
||||||
|
qi_sector,
|
||||||
|
answers!inner ( value, question_id )
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.in('answers.question_id', allowedIds)
|
||||||
|
|
||||||
|
if (surveyId) {
|
||||||
|
responsesQuery = responsesQuery.eq('survey_id', surveyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Contadores totales (independientes de la página actual). submissionsCount
|
||||||
|
// usa el MISMO answers!inner + .in() que responsesQuery — si contara todas
|
||||||
|
// las responses sin ese filtro, una response sin ningún answer permitido
|
||||||
|
// inflaría el total y totalPages quedaría desalineado con lo que .range()
|
||||||
|
// realmente puede devolver (página "fantasma" al final).
|
||||||
|
let submissionsCountQuery = supabase
|
||||||
|
.from('responses')
|
||||||
|
.select('*, answers!inner(question_id)', { count: 'exact', head: true })
|
||||||
|
.in('answers.question_id', allowedIds)
|
||||||
|
|
||||||
|
if (surveyId) {
|
||||||
|
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
let answersCountQuery = supabase
|
||||||
|
.from('answers')
|
||||||
|
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
|
||||||
|
.in('question_id', allowedIds)
|
||||||
|
|
||||||
|
if (surveyId) {
|
||||||
|
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Las tres son independientes entre sí — en paralelo.
|
||||||
|
const [
|
||||||
|
{ data: responsesData, error: responsesError },
|
||||||
|
{ count: totalSubmissions },
|
||||||
|
{ count: totalAnswers },
|
||||||
|
] =
|
||||||
|
allowedIds.length > 0
|
||||||
|
? await Promise.all([
|
||||||
|
responsesQuery
|
||||||
|
.order('submitted_at', { ascending: false })
|
||||||
|
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1),
|
||||||
|
submissionsCountQuery,
|
||||||
|
answersCountQuery,
|
||||||
|
])
|
||||||
|
: [{ data: [] as ResponseRow[], error: null }, { count: 0 }, { count: 0 }]
|
||||||
|
|
||||||
|
if (responsesError) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<h1 className="admin-title">Control de respuestas</h1>
|
||||||
|
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||||
|
<pre className="admin-error-detail">{responsesError.message}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = (responsesData ?? []) as unknown as ResponseRow[]
|
||||||
|
const totalPages = Math.max(1, Math.ceil((totalSubmissions ?? 0) / PAGE_SIZE))
|
||||||
|
|
||||||
|
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
|
||||||
|
const rows: DisplayRow[] = []
|
||||||
|
responses.forEach((response, idx) => {
|
||||||
|
const answers = response.answers
|
||||||
|
.filter((a) => questionMap.has(a.question_id))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
questionMap.get(a.question_id)!.position - questionMap.get(b.question_id)!.position
|
||||||
|
)
|
||||||
|
|
||||||
|
answers.forEach((a, ai) => {
|
||||||
|
const q = questionMap.get(a.question_id)!
|
||||||
|
const isFirstInGroup = ai === 0
|
||||||
|
rows.push({
|
||||||
|
key: `${response.id}-${a.question_id}`,
|
||||||
|
responseId: response.id,
|
||||||
|
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
|
||||||
|
isFirstInGroup,
|
||||||
|
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
|
||||||
|
code: q.code,
|
||||||
|
prompt: truncate(q.prompt),
|
||||||
|
rendered: renderValue(a.value, q.type),
|
||||||
|
zone: isFirstInGroup ? (response.qi_zone ?? '—') : '',
|
||||||
|
sector: isFirstInGroup ? (response.qi_sector ?? '—') : '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavBar active="responses" />
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<header className="admin-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-title">Control de respuestas</h1>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·{' '}
|
||||||
|
{selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
|
||||||
|
{formatDate(new Date().toISOString())}
|
||||||
|
</p>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
Página {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales
|
||||||
|
</p>
|
||||||
|
<div className="admin-survey-nav">
|
||||||
|
<SurveySelect surveys={surveys} value={surveyId ?? ''} />
|
||||||
|
</div>
|
||||||
|
{surveysError && (
|
||||||
|
<p className="admin-error" role="alert">
|
||||||
|
No se pudo cargar la lista de surveys para el filtro — se muestra "Todos".
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-header-actions">
|
||||||
|
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="admin-table-wrapper">
|
||||||
|
<ResponsesTable rows={rows} surveyId={surveyId} page={page} />
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p className="admin-empty">Sin respuestas registradas todavía.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(totalSubmissions ?? 0) > PAGE_SIZE && (
|
||||||
|
<nav className="admin-pagination" aria-label="Paginación de respuestas">
|
||||||
|
{page > 1 ? (
|
||||||
|
<a href={pageHref(surveyId, page - 1)} className="admin-pagination-link">← Anterior</a>
|
||||||
|
) : (
|
||||||
|
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">← Anterior</span>
|
||||||
|
)}
|
||||||
|
<span className="admin-pagination-current">Página {page} de {totalPages}</span>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<a href={pageHref(surveyId, page + 1)} className="admin-pagination-link">Siguiente →</a>
|
||||||
|
) : (
|
||||||
|
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">Siguiente →</span>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
app/admin/responses/responses-table.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export interface DisplayRow {
|
||||||
|
key: string
|
||||||
|
responseId: string
|
||||||
|
submissionNum: number
|
||||||
|
isFirstInGroup: boolean
|
||||||
|
date: string
|
||||||
|
code: string
|
||||||
|
prompt: string
|
||||||
|
rendered: string | null
|
||||||
|
zone: string
|
||||||
|
sector: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsesTable({
|
||||||
|
rows,
|
||||||
|
surveyId,
|
||||||
|
page,
|
||||||
|
}: {
|
||||||
|
rows: DisplayRow[]
|
||||||
|
surveyId?: string
|
||||||
|
page: number
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
function toggle(submissionNum: number) {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(submissionNum)) next.delete(submissionNum)
|
||||||
|
else next.add(submissionNum)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailHref(responseId: string): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (surveyId) params.set('survey_id', surveyId)
|
||||||
|
params.set('page', String(page))
|
||||||
|
return `/admin/responses/${responseId}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Código</th>
|
||||||
|
<th>Pregunta</th>
|
||||||
|
<th>Valor</th>
|
||||||
|
<th className="admin-col-optional">Departamento</th>
|
||||||
|
<th className="admin-col-optional">Sector</th>
|
||||||
|
<th>Ver</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows
|
||||||
|
.filter((row) => row.isFirstInGroup || expanded.has(row.submissionNum))
|
||||||
|
.map((row) => {
|
||||||
|
const groupClass =
|
||||||
|
row.submissionNum % 2 === 0 ? 'admin-group-even' : 'admin-group-odd'
|
||||||
|
const isExpanded = expanded.has(row.submissionNum)
|
||||||
|
const trClass = row.isFirstInGroup
|
||||||
|
? `${groupClass} admin-group-start admin-row-toggle`
|
||||||
|
: groupClass
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.key}
|
||||||
|
className={trClass}
|
||||||
|
// Conveniencia para mouse — sin semántica ARIA en la fila (evita
|
||||||
|
// anidar un control interactivo real, el Link "Ver", dentro de un
|
||||||
|
// elemento expuesto como role="button" a lectores de pantalla).
|
||||||
|
// El toggle accesible por teclado vive en el <button> de abajo.
|
||||||
|
onClick={row.isFirstInGroup ? () => toggle(row.submissionNum) : undefined}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
{row.isFirstInGroup ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-row-toggle-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggle(row.submissionNum)
|
||||||
|
}}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={isExpanded ? 'Colapsar respuesta' : 'Expandir respuesta'}
|
||||||
|
>
|
||||||
|
<span className="admin-toggle-icon" aria-hidden="true">
|
||||||
|
{isExpanded ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
{row.submissionNum}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
row.submissionNum
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{row.date}</td>
|
||||||
|
<td>{row.code}</td>
|
||||||
|
<td className="admin-prompt">{row.prompt}</td>
|
||||||
|
<td>
|
||||||
|
{row.rendered === null ? (
|
||||||
|
<span className="admin-nodata">sin dato</span>
|
||||||
|
) : (
|
||||||
|
row.rendered
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="admin-col-optional">{row.zone}</td>
|
||||||
|
<td className="admin-col-optional">{row.sector}</td>
|
||||||
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
|
{row.isFirstInGroup && (
|
||||||
|
<Link href={detailHref(row.responseId)} className="admin-action-link">
|
||||||
|
Ver
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
app/admin/responses/survey-select.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
interface SurveyOption {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SurveySelect({ surveys, value }: { surveys: SurveyOption[]; value: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="admin-survey-select"
|
||||||
|
aria-label="Selección de encuesta"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value
|
||||||
|
router.push(id ? `/admin/responses?survey_id=${id}` : '/admin/responses')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Todos los surveys</option>
|
||||||
|
{surveys.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
app/admin/sign-out-button.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export function SignOutButton() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={handleSignOut} className="admin-signout">
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
app/admin/usuarios/invite-form.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client'
|
||||||
|
import { useActionState, useEffect, useState } from 'react'
|
||||||
|
import { inviteUser, type InviteUserResult } from '@/app/actions/invite-user'
|
||||||
|
|
||||||
|
interface OrgOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: InviteUserResult = { success: false, error: '' }
|
||||||
|
const BANNER_VISIBLE_MS = 4000
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
no_autenticado: 'Tu sesión expiró — volvé a iniciar sesión.',
|
||||||
|
forbidden: 'No tenés permisos para invitar usuarios.',
|
||||||
|
email_invalido: 'El email no es válido.',
|
||||||
|
organizacion_requerida: 'Elegí una organización.',
|
||||||
|
organizacion_no_encontrada: 'La organización seleccionada no existe.',
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(code: string): string {
|
||||||
|
if (code.startsWith('invite_failed'))
|
||||||
|
return `No se pudo enviar la invitación: ${code.slice('invite_failed: '.length)}`
|
||||||
|
if (code.startsWith('role_insert_failed'))
|
||||||
|
return `Error interno al asignar el rol: ${code.slice('role_insert_failed: '.length)}`
|
||||||
|
return ERROR_MESSAGES[code] ?? 'Ocurrió un error inesperado.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteForm({ organizations }: { organizations: OrgOption[] }) {
|
||||||
|
const [state, formAction, isPending] = useActionState(
|
||||||
|
async (_prev: InviteUserResult, formData: FormData) => inviteUser(formData),
|
||||||
|
INITIAL_STATE
|
||||||
|
)
|
||||||
|
const [bannerVisible, setBannerVisible] = useState(false)
|
||||||
|
|
||||||
|
// Banner visible al menos BANNER_VISIBLE_MS tras cada resultado nuevo, o
|
||||||
|
// hasta que el usuario vuelva a tocar el formulario (lo que pase primero).
|
||||||
|
useEffect(() => {
|
||||||
|
const hasMessage = state.success || (!state.success && state.error !== '')
|
||||||
|
setBannerVisible(hasMessage)
|
||||||
|
if (!hasMessage) return
|
||||||
|
const timer = setTimeout(() => setBannerVisible(false), BANNER_VISIBLE_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="invite-form"
|
||||||
|
onChange={() => setBannerVisible(false)}
|
||||||
|
>
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="email" className="login-label">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="orgId" className="login-label">Organización</label>
|
||||||
|
<select id="orgId" name="orgId" required disabled={isPending} className="admin-survey-select">
|
||||||
|
<option value="">Seleccioná una organización</option>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={isPending} className="login-submit">
|
||||||
|
{isPending ? 'Enviando…' : 'Enviar invitación'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bannerVisible && !state.success && state.error && (
|
||||||
|
<p role="alert" className="invite-banner invite-banner-error">
|
||||||
|
{describeError(state.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{bannerVisible && state.success && (
|
||||||
|
<p role="status" className="invite-banner invite-banner-success">
|
||||||
|
Invitación enviada correctamente.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
app/admin/usuarios/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { getSupabaseAdmin } from '@/lib/supabase'
|
||||||
|
import { formatDate } from '../responses/admin-format'
|
||||||
|
import { AdminNavBar } from '@/components/admin-nav-bar'
|
||||||
|
import { InviteForm } from './invite-form'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface OrgOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleRow {
|
||||||
|
user_id: string
|
||||||
|
app_role: string
|
||||||
|
org_id: string | null
|
||||||
|
organizations: { name: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserDisplayRow {
|
||||||
|
userId: string
|
||||||
|
email: string
|
||||||
|
orgName: string
|
||||||
|
role: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSupabaseSession() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Server Component — no puede mutar cookies, se ignora
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auth.users no está expuesto vía PostgREST (no es schema público) — no hay
|
||||||
|
// forma de hacer un .from('auth.users') ni un join SQL directo desde
|
||||||
|
// supabase-js. listUsers() es la API admin soportada para resolver el email;
|
||||||
|
// pagina hasta agotar resultados (tope defensivo de 2000 usuarios).
|
||||||
|
async function fetchAllUsersMap(admin: ReturnType<typeof getSupabaseAdmin>) {
|
||||||
|
const map = new Map<string, { email: string; createdAt: string }>()
|
||||||
|
const perPage = 200
|
||||||
|
for (let page = 1; page <= 10; page++) {
|
||||||
|
const { data, error } = await admin.auth.admin.listUsers({ page, perPage })
|
||||||
|
if (error || !data) break
|
||||||
|
data.users.forEach((u) => {
|
||||||
|
map.set(u.id, { email: u.email ?? '—', createdAt: u.created_at })
|
||||||
|
})
|
||||||
|
if (data.users.length < perPage) break
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminUsuariosPage() {
|
||||||
|
const supabase = await getSupabaseSession()
|
||||||
|
|
||||||
|
// 1. Identificar a quien llama — defensivo, el middleware ya debería haber
|
||||||
|
// redirigido a /login si no hay sesión.
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<h1 className="admin-title">Acceso restringido</h1>
|
||||||
|
<p className="admin-error">No se encontró una sesión activa.</p>
|
||||||
|
<a href="/login" className="admin-back-link">← Iniciar sesión</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificar platform_admin leyendo la fila REAL en user_roles — esta
|
||||||
|
// página usa getSupabaseAdmin() más abajo (bypasa RLS por completo), así
|
||||||
|
// que el gate de autorización tiene que ir acá, no delegarse a las policies.
|
||||||
|
const { data: callerRole } = await supabase
|
||||||
|
.from('user_roles')
|
||||||
|
.select('app_role')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (callerRole?.app_role !== 'platform_admin') {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<h1 className="admin-title">Acceso restringido</h1>
|
||||||
|
<p className="admin-error">
|
||||||
|
Esta sección es exclusiva para administradores de plataforma.
|
||||||
|
</p>
|
||||||
|
<a href="/admin/responses" className="admin-back-link">← Volver</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Datos — admin client para todo a partir de acá. La autorización ya
|
||||||
|
// quedó probada en el paso 2 con una lectura directa a la DB; usar admin
|
||||||
|
// para el resto evita depender de policies/claims del JWT que podrían
|
||||||
|
// haber quedado stale.
|
||||||
|
const admin = getSupabaseAdmin()
|
||||||
|
|
||||||
|
const [{ data: orgsData, error: orgsError }, { data: rolesData, error: rolesError }] =
|
||||||
|
await Promise.all([
|
||||||
|
admin.from('organizations').select('id, name').order('name'),
|
||||||
|
admin.from('user_roles').select('user_id, app_role, org_id, organizations(name)'),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (orgsError || rolesError) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<h1 className="admin-title">Usuarios</h1>
|
||||||
|
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||||
|
<pre className="admin-error-detail">
|
||||||
|
{(orgsError ?? rolesError)?.message}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizations = (orgsData ?? []) as OrgOption[]
|
||||||
|
const roles = (rolesData ?? []) as unknown as RoleRow[]
|
||||||
|
const usersMap = await fetchAllUsersMap(admin)
|
||||||
|
|
||||||
|
const userRows: UserDisplayRow[] = roles.map((r) => {
|
||||||
|
const u = usersMap.get(r.user_id)
|
||||||
|
return {
|
||||||
|
userId: r.user_id,
|
||||||
|
email: u?.email ?? '—',
|
||||||
|
orgName: r.organizations?.name ?? '—',
|
||||||
|
role: r.app_role,
|
||||||
|
createdAt: u?.createdAt ? formatDate(u.createdAt) : '—',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavBar active="usuarios" />
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<header className="admin-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-title">Usuarios</h1>
|
||||||
|
<p className="admin-subtitle">{userRows.length} usuarios registrados</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="admin-detail-section">
|
||||||
|
<h2 className="admin-detail-section-title">Invitar usuario</h2>
|
||||||
|
<InviteForm organizations={organizations} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-detail-section">
|
||||||
|
<h2 className="admin-detail-section-title">Usuarios existentes</h2>
|
||||||
|
<div className="admin-table-wrapper">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Organización</th>
|
||||||
|
<th>Rol</th>
|
||||||
|
<th>Fecha de creación</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{userRows.map((row) => (
|
||||||
|
<tr key={row.userId}>
|
||||||
|
<td>{row.email}</td>
|
||||||
|
<td>{row.orgName}</td>
|
||||||
|
<td>{row.role}</td>
|
||||||
|
<td>{row.createdAt}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{userRows.length === 0 && (
|
||||||
|
<p className="admin-empty">Todavía no hay usuarios invitados.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
app/api/dashboard/[widget]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
import { queryToFilters, filtersToRpc } from '@/lib/dashboard-utils'
|
||||||
|
|
||||||
|
// Route Handler server-side: llama a las RPC con el cliente Supabase.
|
||||||
|
// El service_role nunca llega al browser — vive solo en este servidor.
|
||||||
|
// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen datos
|
||||||
|
// sensibles internamente.
|
||||||
|
//
|
||||||
|
// Las 9 RPCs v2 (rpc_dashboard_kpis, etc.) se retiraron de este mapa junto
|
||||||
|
// con sus widgets — referencian códigos de pregunta que no existen en v3
|
||||||
|
// (Etapa 4H, migración de códigos, queda pendiente aparte). Mirada Empresa
|
||||||
|
// usa las rpc_me_* nuevas, ya verificadas contra datos reales.
|
||||||
|
const RPC_MAP: Record<string, string> = {
|
||||||
|
'me-prevalencia': 'rpc_me_prevalencia_cuidadores',
|
||||||
|
'me-riesgo-rotacion': 'rpc_me_riesgo_rotacion',
|
||||||
|
'me-ausentismo': 'rpc_me_ausentismo',
|
||||||
|
'me-presentismo': 'rpc_me_presentismo',
|
||||||
|
'me-punto-ciego': 'rpc_me_punto_ciego',
|
||||||
|
'me-apoyos': 'rpc_me_apoyos_demandados',
|
||||||
|
'me-talento-riesgo': 'rpc_me_talento_en_riesgo',
|
||||||
|
'me-carrera-congelada': 'rpc_me_carrera_congelada',
|
||||||
|
'me-intensidad-carga': 'rpc_me_intensidad_carga',
|
||||||
|
'me-segmentacion': 'rpc_me_segmentacion',
|
||||||
|
'me-disposicion-red': 'rpc_me_disposicion_red',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupabaseServer() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||||
|
// Usar anon key — las RPC son SECURITY DEFINER y aplican sus propias reglas
|
||||||
|
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
return createClient(url, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ widget: string }> }
|
||||||
|
) {
|
||||||
|
const { widget } = await params
|
||||||
|
const rpcName = RPC_MAP[widget]
|
||||||
|
|
||||||
|
if (!rpcName) {
|
||||||
|
return NextResponse.json({ error: 'Widget desconocido' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sp = request.nextUrl.searchParams
|
||||||
|
const surveyId = sp.get('survey_id')
|
||||||
|
if (!surveyId) {
|
||||||
|
return NextResponse.json({ error: 'survey_id requerido' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = queryToFilters(sp)
|
||||||
|
const supabase = getSupabaseServer()
|
||||||
|
|
||||||
|
const rpcParams: Record<string, unknown> = {
|
||||||
|
p_survey_id: surveyId,
|
||||||
|
p_filters: filtersToRpc(filters),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Único caso con parámetro extra: el umbral parametrizable de la estrella.
|
||||||
|
if (widget === 'me-talento-riesgo') {
|
||||||
|
const umbral = sp.get('umbral')
|
||||||
|
if (umbral) rpcParams.p_umbral_senales = parseInt(umbral, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase.rpc(rpcName, rpcParams)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`[dashboard/${widget}] RPC error:`, error.message)
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data)
|
||||||
|
}
|
||||||
191
app/auth/confirm/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
type Stage = 'validating' | 'ready' | 'submitting' | 'error'
|
||||||
|
|
||||||
|
const DEFAULT_LINK_ERROR = 'Pedile al administrador que te envíe una nueva invitación.'
|
||||||
|
|
||||||
|
// GoTrue redirige con #error=...&error_code=...&error_description=... cuando
|
||||||
|
// el token ya fue usado, venció, o es inválido — en vez de emitir PASSWORD_RECOVERY.
|
||||||
|
// Mapeo a mensajes accionables; default cubre códigos no contemplados acá.
|
||||||
|
function describeLinkError(errorCode: string | null): string {
|
||||||
|
switch (errorCode) {
|
||||||
|
case 'otp_expired':
|
||||||
|
return 'Este link ya fue usado o venció. ' + DEFAULT_LINK_ERROR
|
||||||
|
case 'access_denied':
|
||||||
|
return 'El acceso fue denegado para este link. ' + DEFAULT_LINK_ERROR
|
||||||
|
default:
|
||||||
|
return 'El link de invitación no es válido. ' + DEFAULT_LINK_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmPage() {
|
||||||
|
const [stage, setStage] = useState<Stage>('validating')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [linkErrorMessage, setLinkErrorMessage] = useState(DEFAULT_LINK_ERROR)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// GoTrue ya nos dice en el fragmento si el token es inválido/vencido/usado
|
||||||
|
// (#error=...&error_code=...) — leerlo evita esperar el timeout genérico.
|
||||||
|
const hash = window.location.hash.startsWith('#')
|
||||||
|
? window.location.hash.slice(1)
|
||||||
|
: window.location.hash
|
||||||
|
const hashParams = new URLSearchParams(hash)
|
||||||
|
const errorCode = hashParams.get('error_code')
|
||||||
|
|
||||||
|
if (errorCode) {
|
||||||
|
setLinkErrorMessage(describeLinkError(errorCode))
|
||||||
|
setStage('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supabase emite PASSWORD_RECOVERY tanto para recovery como para invite
|
||||||
|
// cuando el token de la URL (#access_token) es válido.
|
||||||
|
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
|
||||||
|
if (event === 'PASSWORD_RECOVERY') setStage('ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Carrera posible: si el token ya se procesó antes de montar el listener,
|
||||||
|
// ya hay sesión activa aunque el evento no se vuelva a emitir.
|
||||||
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
|
setStage((s) => (s === 'validating' && session ? 'ready' : s))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sin evento ni sesión tras unos segundos = token inválido/vencido/ausente
|
||||||
|
// sin que GoTrue haya mandado un error explícito en el fragmento.
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setStage((s) => (s === 'validating' ? 'error' : s))
|
||||||
|
}, 8000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listener.subscription.unsubscribe()
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [supabase])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const password = String(fd.get('password') ?? '')
|
||||||
|
const confirmPassword = String(fd.get('confirmPassword') ?? '')
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('La contraseña debe tener al menos 8 caracteres.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Las contraseñas no coinciden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setStage('submitting')
|
||||||
|
|
||||||
|
const { error: updateErr } = await supabase.auth.updateUser({ password })
|
||||||
|
|
||||||
|
if (updateErr) {
|
||||||
|
setError('No pudimos guardar tu contraseña. Probá de nuevo.')
|
||||||
|
setStage('ready')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/dashboard')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === 'validating') {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Validando tu invitación...</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Link inválido o vencido</h1>
|
||||||
|
<p role="alert" className="field-error login-error">
|
||||||
|
{linkErrorMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Creá tu contraseña</h1>
|
||||||
|
<form onSubmit={handleSubmit} className="login-form" noValidate>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="password" className="login-label">Contraseña</label>
|
||||||
|
<div className="login-password-wrapper">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
disabled={stage === 'submitting'}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-password-toggle"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="confirmPassword" className="login-label">Confirmar contraseña</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
disabled={stage === 'submitting'}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="field-error login-error">{error}</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={stage === 'submitting'} className="login-submit">
|
||||||
|
{stage === 'submitting' ? 'Guardando…' : 'Crear contraseña'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
app/dashboard/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export default function DashboardLoading() {
|
||||||
|
return (
|
||||||
|
<div className="db-loading-page" aria-busy="true" aria-label="Cargando dashboard…">
|
||||||
|
<div className="db-skeleton-block" style={{ height: 60, marginBottom: 16 }} />
|
||||||
|
<div className="db-grid">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="db-skeleton-block" style={{ gridColumn: 'span 3', height: 96 }} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i+4} className="db-skeleton-block" style={{ gridColumn: 'span 6', height: 240 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
app/dashboard/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { AdminNavBar } from '@/components/admin-nav-bar'
|
||||||
|
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
|
||||||
|
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
|
||||||
|
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
|
||||||
|
import { PuntoCiegoCard } from '@/components/dashboard/punto-ciego-card'
|
||||||
|
import { SenalesImpactoBar } from '@/components/dashboard/senales-impacto-bar'
|
||||||
|
import { ApoyosBarME } from '@/components/dashboard/apoyos-bar-me'
|
||||||
|
import { SegmentacionBar } from '@/components/dashboard/segmentacion-bar'
|
||||||
|
import { DisposicionRedCard } from '@/components/dashboard/disposicion-red-card'
|
||||||
|
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||||
|
|
||||||
|
// El survey_id se pasa como query param: /dashboard?survey_id=UUID
|
||||||
|
// '00000000-0000-0000-0000-000000000000' = flag p_all (vista agregada de todos los surveys)
|
||||||
|
const SURVEY_OPTIONS = [
|
||||||
|
{ label: 'Todos', id: '00000000-0000-0000-0000-000000000000' },
|
||||||
|
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
|
||||||
|
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
|
||||||
|
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type Lens = 'empresa' | 'pais' | 'humana'
|
||||||
|
|
||||||
|
const LENSES: { id: Lens; label: string; sub: string }[] = [
|
||||||
|
{ id: 'empresa', label: 'Mirada Empresa', sub: 'rendimiento · rotación · clima' },
|
||||||
|
{ id: 'pais', label: 'Mirada País', sub: 'desarrollo · vulnerabilidad · política' },
|
||||||
|
{ id: 'humana', label: 'Mirada Humana', sub: 'bienestar · derechos · desarrollo' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function PlaceholderLens({ title, hint }: { title: string; hint: string }) {
|
||||||
|
return (
|
||||||
|
<div className="db-placeholder" role="status">
|
||||||
|
<span className="db-placeholder-icon" aria-hidden="true">🔒</span>
|
||||||
|
<p className="db-placeholder-title">{title} — Disponible próximamente</p>
|
||||||
|
<p className="db-placeholder-hint">{hint}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001'
|
||||||
|
const [filters, setFilters] = useState<DashboardFilters>({})
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [lens, setLens] = useState<Lens>('empresa')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavBar active="dashboard" />
|
||||||
|
<div className="db-page">
|
||||||
|
<FilterSidebar
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggle={() => setSidebarCollapsed((c) => !c)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="db-main">
|
||||||
|
<header className="db-page-header">
|
||||||
|
<h1 className="db-page-title">Economía del Cuidado — Paraguay</h1>
|
||||||
|
<p className="db-page-subtitle">
|
||||||
|
Datos agregados · k-anonimato k≥5 · Encuesta Mapeo de Cuidadores 2026
|
||||||
|
{filters.depto && <span className="db-active-filter"> · Filtro: {filters.depto}</span>}
|
||||||
|
</p>
|
||||||
|
<nav className="db-survey-nav" aria-label="Selección de empresa">
|
||||||
|
{SURVEY_OPTIONS.map((opt) => (
|
||||||
|
<a
|
||||||
|
key={opt.id}
|
||||||
|
href={`/dashboard?survey_id=${opt.id}`}
|
||||||
|
className={
|
||||||
|
opt.id === surveyId
|
||||||
|
? 'db-survey-link db-survey-link-active'
|
||||||
|
: 'db-survey-link'
|
||||||
|
}
|
||||||
|
aria-current={opt.id === surveyId ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="db-tabs" aria-label="Mirada del dashboard">
|
||||||
|
{LENSES.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
type="button"
|
||||||
|
className={`db-tab${lens === l.id ? ' db-tab-active' : ''}`}
|
||||||
|
onClick={() => setLens(l.id)}
|
||||||
|
aria-current={lens === l.id ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span>{l.label}</span>
|
||||||
|
<span className="db-tab-lens">{l.sub}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{lens === 'empresa' && (
|
||||||
|
<>
|
||||||
|
<div className="db-lens-intro">
|
||||||
|
<strong>Mirada Empresa.</strong> Traduce el cuidado a lenguaje de negocio: cuánto cuesta el
|
||||||
|
punto ciego y qué talento está en riesgo.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fila 1: núcleo — estrella + prevalencia + punto ciego */}
|
||||||
|
<section className="db-grid db-section" aria-label="Indicadores núcleo">
|
||||||
|
<TalentoRiesgoCard surveyId={surveyId} filters={filters} />
|
||||||
|
<PrevalenciaCuidadoresCard surveyId={surveyId} filters={filters} />
|
||||||
|
<PuntoCiegoCard surveyId={surveyId} filters={filters} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Fila 2: señales de impacto (6) + apoyos demandados (6) */}
|
||||||
|
<section className="db-grid db-section" aria-label="Señales de impacto y apoyos">
|
||||||
|
<SenalesImpactoBar surveyId={surveyId} filters={filters} />
|
||||||
|
<ApoyosBarME surveyId={surveyId} filters={filters} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Fila 3: segmentación (12, SEC) */}
|
||||||
|
<section className="db-grid db-section" aria-label="Segmentación de la carga">
|
||||||
|
<SegmentacionBar surveyId={surveyId} filters={filters} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Fila 4: disposición a red de apoyo (4, SEC) */}
|
||||||
|
<section className="db-grid db-section" aria-label="Disposición a participar">
|
||||||
|
<DisposicionRedCard surveyId={surveyId} filters={filters} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="db-table-note" style={{ textAlign: 'right' }}>
|
||||||
|
k≥5 aplicado · segmentos con n<5 suprimidos
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lens === 'pais' && (
|
||||||
|
<PlaceholderLens
|
||||||
|
title="Mirada País"
|
||||||
|
hint="Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP) — desarrollo, vulnerabilidad, impacto económico. En construcción."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lens === 'humana' && (
|
||||||
|
<PlaceholderLens
|
||||||
|
title="Mirada Humana"
|
||||||
|
hint="Bienestar de quien cuida y de quien es cuidado — agotamiento, soledad del cuidado, disposición solidaria. En construcción."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
845
app/globals.css
@@ -2,18 +2,27 @@
|
|||||||
|
|
||||||
/* ── Tokens ── */
|
/* ── Tokens ── */
|
||||||
:root {
|
:root {
|
||||||
|
/* ── Marca RE ── */
|
||||||
|
--color-re-teal: #43BBC8;
|
||||||
|
--color-re-dark: #494F4F;
|
||||||
|
--color-re-orange: #F8AD13;
|
||||||
|
--color-re-orange-intense: #EE761A;
|
||||||
|
--color-re-light: #E7E6E5;
|
||||||
|
--color-re-white: #FFFFFF;
|
||||||
|
|
||||||
|
/* ── Sistema (primario = teal RE) ── */
|
||||||
--color-bg: #f9fafb;
|
--color-bg: #f9fafb;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--color-text: #111827;
|
--color-text: #111827;
|
||||||
--color-text-muted: #6b7280;
|
--color-text-muted: #6b7280;
|
||||||
--color-primary: #1d4ed8;
|
--color-primary: #43BBC8;
|
||||||
--color-primary-hover: #1e40af;
|
--color-primary-hover: #3DAAB6;
|
||||||
--color-primary-light: #dbeafe;
|
--color-primary-light: #e8f8fa;
|
||||||
--color-accent: #2563eb;
|
--color-accent: #43BBC8;
|
||||||
--color-error: #b91c1c;
|
--color-error: #b91c1c;
|
||||||
--color-error-bg: #fef2f2;
|
--color-error-bg: #fef2f2;
|
||||||
--color-border: #d1d5db;
|
--color-border: #d1d5db;
|
||||||
--color-border-focus: #2563eb;
|
--color-border-focus: #43BBC8;
|
||||||
--color-sensitive: #fef3c7;
|
--color-sensitive: #fef3c7;
|
||||||
--color-sensitive-border: #f59e0b;
|
--color-sensitive-border: #f59e0b;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
@@ -23,6 +32,7 @@
|
|||||||
/* ── Reset & base ── */
|
/* ── Reset & base ── */
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
html { font-size: 16px; scroll-behavior: smooth; }
|
html { font-size: 16px; scroll-behavior: smooth; }
|
||||||
|
html, body { height: 100%; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
@@ -30,7 +40,12 @@ body {
|
|||||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
/* El contenido entre header y footer crece para empujar el footer al fondo */
|
||||||
|
.layout-content { flex: 1; }
|
||||||
|
|
||||||
/* ── Skip link ── */
|
/* ── Skip link ── */
|
||||||
.skip-link {
|
.skip-link {
|
||||||
@@ -48,7 +63,11 @@ body {
|
|||||||
.skip-link:focus { left: 4px; }
|
.skip-link:focus { left: 4px; }
|
||||||
|
|
||||||
/* ── Screen reader only ── */
|
/* ── Screen reader only ── */
|
||||||
.sr-only {
|
/* El legend/label de los widgets dentro del wizard se oculta visualmente —
|
||||||
|
el enunciado ya se muestra una sola vez en .wizard-prompt-block. Se deja
|
||||||
|
el texto accesible (nombre del fieldset/label) en vez de quitarlo. */
|
||||||
|
.sr-only,
|
||||||
|
.wizard-answer-area .question-legend {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
@@ -132,16 +151,6 @@ body {
|
|||||||
border-radius: 0 var(--radius) var(--radius) 0;
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
color: var(--color-primary-hover);
|
color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
.section-sensitive-note {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: #92400e;
|
|
||||||
background: var(--color-sensitive);
|
|
||||||
border: 1px solid var(--color-sensitive-border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Question wrapper ── */
|
/* ── Question wrapper ── */
|
||||||
.question-wrapper {
|
.question-wrapper {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
@@ -168,6 +177,12 @@ body {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.required-mark { color: var(--color-error); }
|
.required-mark { color: var(--color-error); }
|
||||||
|
.q-code {
|
||||||
|
font-size: 0.78em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7A8FA0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Options list ── */
|
/* ── Options list ── */
|
||||||
.options-list { display: flex; flex-direction: column; gap: 8px; }
|
.options-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
@@ -254,6 +269,57 @@ body {
|
|||||||
.rating-step:hover .rating-dot { border-color: var(--color-accent); background: var(--color-primary-light); }
|
.rating-step:hover .rating-dot { border-color: var(--color-accent); background: var(--color-primary-light); }
|
||||||
.rating-step:has(input:checked):hover .rating-dot { background: var(--color-primary-hover); border-color: var(--color-primary-hover); color: #fff; }
|
.rating-step:has(input:checked):hover .rating-dot { background: var(--color-primary-hover); border-color: var(--color-primary-hover); color: #fff; }
|
||||||
|
|
||||||
|
/* ── Text short (single-line input) ── */
|
||||||
|
.text-short-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.text-short-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-border-focus);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Barrio autocomplete ── */
|
||||||
|
.barrio-autocomplete-wrapper { position: relative; }
|
||||||
|
.barrio-autocomplete-list {
|
||||||
|
/* position: fixed + top/left/width set by JS via getBoundingClientRect()
|
||||||
|
so the list escapes any overflow:auto ancestor (wizard-answer-area) */
|
||||||
|
position: fixed;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1.5px solid var(--color-border-focus);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.10);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.barrio-autocomplete-item {
|
||||||
|
padding: 9px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.barrio-autocomplete-item:hover,
|
||||||
|
.barrio-autocomplete-item--active {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: #494F4F;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Textarea ── */
|
/* ── Textarea ── */
|
||||||
.text-area {
|
.text-area {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -312,7 +378,7 @@ body {
|
|||||||
.btn-primary:hover { background: var(--color-primary-hover); }
|
.btn-primary:hover { background: var(--color-primary-hover); }
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-primary);
|
color: #494F4F;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover { background: var(--color-primary-light); }
|
.btn-secondary:hover { background: var(--color-primary-light); }
|
||||||
@@ -363,7 +429,7 @@ body {
|
|||||||
}
|
}
|
||||||
.summary-code {
|
.summary-code {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-primary);
|
color: #494F4F;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
.summary-answer {
|
.summary-answer {
|
||||||
@@ -378,6 +444,61 @@ body {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Geo select widget ── */
|
||||||
|
.geo-select-wrapper { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.geo-select-group { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.geo-select {
|
||||||
|
display: block; width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1.5px solid var(--color-border); border-radius: var(--radius);
|
||||||
|
font-size: 1rem; font-family: inherit; background: var(--color-surface);
|
||||||
|
color: var(--color-text); cursor: pointer; appearance: auto;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.geo-select:focus { outline: none; border-color: var(--color-border-focus); box-shadow: 0 0 0 3px rgba(37,99,235,0.15); }
|
||||||
|
.geo-select-disabled { opacity: 0.5; cursor: not-allowed; background: #f3f4f6; }
|
||||||
|
.geo-label-disabled { color: var(--color-text-muted); }
|
||||||
|
.geo-disabled-hint { font-size: 0.8125rem; font-weight: 400; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
/* ── Brand header ── */
|
||||||
|
.brand-header {
|
||||||
|
background: var(--color-primary-light); border-left: 4px solid var(--color-primary);
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
padding: 10px 14px; margin: 16px 0 0;
|
||||||
|
}
|
||||||
|
.brand-text { margin: 0; font-size: 0.875rem; color: var(--color-primary-hover); line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ── Consent screen ── */
|
||||||
|
.consent-container { max-width: 720px; margin: 0 auto; padding: 32px 16px 80px; }
|
||||||
|
.consent-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 24px; }
|
||||||
|
.consent-section { margin-bottom: 24px; }
|
||||||
|
.consent-section-title { font-size: 1.0625rem; font-weight: 700; margin: 0 0 10px; }
|
||||||
|
.consent-list { margin: 8px 0 0; padding-left: 20px; line-height: 1.7; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.consent-anon-box {
|
||||||
|
background: var(--color-sensitive); border: 1px solid var(--color-sensitive-border);
|
||||||
|
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 24px;
|
||||||
|
font-size: 0.9375rem; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.consent-checkboxes { margin-bottom: 28px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.consent-checkbox-label {
|
||||||
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
|
padding: 14px 16px; border: 1.5px solid var(--color-border); border-radius: var(--radius);
|
||||||
|
cursor: pointer; transition: border-color 0.15s, background 0.15s; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); }
|
||||||
|
.consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
|
||||||
|
.consent-checkbox-text { flex: 1; }
|
||||||
|
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: #494F4F; background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
|
||||||
|
.consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; }
|
||||||
|
.consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||||
|
.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
|
||||||
|
.btn-disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Thank you / error screens ── */
|
||||||
|
.thank-you-container, .error-container { max-width: 560px; margin: 80px auto; padding: 0 16px; }
|
||||||
|
.thank-you-title, .error-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 16px; }
|
||||||
|
.thank-you-body { color: var(--color-text-muted); line-height: 1.7; margin: 0 0 12px; }
|
||||||
|
|
||||||
/* ── No survey state ── */
|
/* ── No survey state ── */
|
||||||
.no-survey {
|
.no-survey {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
@@ -396,6 +517,170 @@ body {
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
WIZARD (4D) — layout de altura estable (UX 1+2+3)
|
||||||
|
1) viewport estable: enunciado anclado en el tercio superior, no centrado
|
||||||
|
2) header (progreso) y footer (Anterior/Siguiente) fijos, fuera del área
|
||||||
|
que cambia de tamaño — nunca "saltan" entre pantallas
|
||||||
|
3) área de respuesta con altura máxima + scroll interno + fade inferior
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
.wizard-shell {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100dvh - 160px); /* deja lugar al header/footer de marca (layout.tsx) */
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.wizard-topbar-brand {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage: altura fija, no auto-crece con el contenido de la pregunta actual. */
|
||||||
|
.wizard-stage {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 48px; /* ancla el enunciado en el tercio superior, no centrado vertical */
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-section-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.wizard-section-number {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #494F4F;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.wizard-section-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.wizard-section-desc {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enunciado: crece orgánicamente con el contenido, sin altura mínima fija. */
|
||||||
|
.wizard-prompt-block {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.wizard-instance-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.wizard-prompt-code {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7A8FA0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.wizard-prompt-text {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.wizard-prompt-hint {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Área de respuesta: altura máxima + scroll interno + fade inferior cuando hay más abajo.
|
||||||
|
flex: 0 1 auto (NO flex:1) — mide según su contenido, nunca se estira a llenar el alto
|
||||||
|
disponible. Si creciera con flex:1, una pregunta corta (3 opciones) dejaría la caja con
|
||||||
|
espacio vacío abajo y el fade ::after (sticky bottom:0) quedaría flotando justo después
|
||||||
|
del contenido real, lejos del borde inferior visual de la caja — en vez de pegado a él. */
|
||||||
|
.wizard-answer-area {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.wizard-answer-area.has-overflow { padding-bottom: 44px; }
|
||||||
|
/* El fade SOLO se pinta cuando .wizard-answer-area mide overflow real
|
||||||
|
(clase has-overflow agregada por JS — ver wizard-form.tsx). CSS no tiene
|
||||||
|
forma de detectar scrollHeight > clientHeight por sí solo: un ::after
|
||||||
|
incondicional (o un mask-image fijo) se pinta igual sobre el final de la
|
||||||
|
caja aunque el contenido entre completo, mostrando una franja/degradado
|
||||||
|
fantasma en preguntas cortas. */
|
||||||
|
.wizard-answer-area.has-overflow::after {
|
||||||
|
content: '';
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: block;
|
||||||
|
height: 28px;
|
||||||
|
margin-top: -28px;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--color-surface) 75%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-bottombar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: 16px 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.wizard-bottombar-hint {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.wizard-prompt-text { font-size: 1.1875rem; }
|
||||||
|
.wizard-section-title { font-size: 1.5rem; }
|
||||||
|
.wizard-answer-area { max-height: 400px; }
|
||||||
|
/* column-reverse: el botón primario (Siguiente/Enviar) queda arriba —
|
||||||
|
el pulgar llega más fácil al centro de la pantalla que a la base. */
|
||||||
|
.wizard-bottombar { flex-direction: column-reverse; }
|
||||||
|
.wizard-bottombar .btn { width: 100%; }
|
||||||
|
.wizard-bottombar-hint { order: 3; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Loading skeleton ── */
|
/* ── Loading skeleton ── */
|
||||||
.loading-wrapper {
|
.loading-wrapper {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
@@ -419,7 +704,531 @@ body {
|
|||||||
.btn { width: 100%; }
|
.btn { width: 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Survey header (marca RE) ── */
|
||||||
|
.survey-header-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.survey-header-logo { display: block; height: 48px; width: auto; flex-shrink: 0; }
|
||||||
|
.survey-header-title {
|
||||||
|
color: var(--color-re-white);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Survey footer ── */
|
||||||
|
.survey-footer-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 24px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
.survey-footer-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.survey-footer-label {
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.survey-footer-logo { height: 32px; width: auto; display: block; }
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
DASHBOARD — tema claro (marca Re)
|
||||||
|
Fondo: #E7E6E5 · Widgets: #FFFFFF · Teal: #43BBC8
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
.db-page {
|
||||||
|
display: flex; min-height: 100vh;
|
||||||
|
background: #E7E6E5; color: #494F4F;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
.db-main { flex: 1; padding: 24px; overflow-x: hidden; }
|
||||||
|
.db-page-header { margin-bottom: 20px; }
|
||||||
|
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #494F4F; margin: 0 0 4px; }
|
||||||
|
.db-page-subtitle { font-size: 0.8125rem; color: #6B7878; margin: 0; }
|
||||||
|
.db-active-filter { color: var(--color-re-teal); font-weight: 600; }
|
||||||
|
.db-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
|
.db-survey-link {
|
||||||
|
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
|
||||||
|
border: 1px solid rgba(67,187,200,0.3); color: #6B7878; text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.db-survey-link:hover { color: #494F4F; border-color: var(--color-re-teal); }
|
||||||
|
.db-survey-link-active { background: var(--color-re-teal); color: #FFFFFF; border-color: var(--color-re-teal); }
|
||||||
|
.db-section { margin-bottom: 16px; }
|
||||||
|
.db-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Widget shell ── */
|
||||||
|
.db-widget {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border: 1px solid rgba(67,187,200,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
.db-widget:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
|
||||||
|
.db-widget-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
}
|
||||||
|
.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #494F4F; margin: 0; }
|
||||||
|
.db-widget-n { font-size: 0.75rem; color: #6B7878; }
|
||||||
|
.db-widget-body { padding: 0 16px 16px; }
|
||||||
|
|
||||||
|
/* ── Suppressed / loading states ── */
|
||||||
|
.db-suppressed {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
min-height: 100px; text-align: center; color: #6B7878; gap: 6px;
|
||||||
|
}
|
||||||
|
.db-suppressed-icon { font-size: 1.5rem; }
|
||||||
|
.db-suppressed-hint { font-size: 0.75rem; color: #9CA8A8; }
|
||||||
|
.db-suppressed-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 4px; color: #9CA8A8;
|
||||||
|
}
|
||||||
|
.db-skeleton-block { background: rgba(73,79,79,0.08); border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; }
|
||||||
|
.db-error { color: #ef4444; font-size: 0.875rem; padding: 12px; }
|
||||||
|
.db-loading-page { padding: 24px; background: #E7E6E5; min-height: 100vh; }
|
||||||
|
|
||||||
|
/* ── KPI row ── */
|
||||||
|
.db-kpi-row {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.db-kpi-card {
|
||||||
|
background: #FFFFFF; border: 1px solid rgba(67,187,200,0.2);
|
||||||
|
border-radius: 10px; padding: 16px; text-align: center;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
.db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
|
||||||
|
/* Título de KPI: mayúsculas, bold, siempre visible */
|
||||||
|
.db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #494F4F; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
/* Subtítulo de KPI: código de pregunta + descripción */
|
||||||
|
.db-kpi-subtitle { font-size: 0.6875rem; color: #8A9A9A; font-weight: 400; line-height: 1.4; margin-bottom: 4px; }
|
||||||
|
.db-kpi-value {
|
||||||
|
font-size: 2rem; font-weight: 700; color: var(--color-re-teal);
|
||||||
|
font-variant-numeric: tabular-nums; margin-top: 4px;
|
||||||
|
}
|
||||||
|
.db-kpi-value-suppressed { font-size: 1.25rem; color: #9CA8A8; }
|
||||||
|
.db-kpi-note { font-size: 0.75rem; color: #6B7878; }
|
||||||
|
.db-kpi-suppressed-block { display: flex; flex-direction: column; align-items: center; gap: 4px; margin-top: 8px; }
|
||||||
|
/* Compat — se puede quitar si ya no se usa */
|
||||||
|
.db-kpi-label { font-size: 0.8125rem; color: #494F4F; }
|
||||||
|
.db-kpi-sub { font-size: 0.75rem; color: #6B7878; }
|
||||||
|
|
||||||
|
/* Subtítulo de widget-shell */
|
||||||
|
.db-widget-title-group { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||||
|
.db-widget-subtitle { font-size: 0.75rem; color: #8A9A9A; font-weight: 400; margin: 0; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* ── Mapa tile ── */
|
||||||
|
.db-map-wrapper { position: relative; }
|
||||||
|
.db-map-tooltip {
|
||||||
|
position: absolute; top: -28px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: #E7E6E5; color: #494F4F; font-size: 0.75rem;
|
||||||
|
padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(67,187,200,0.3);
|
||||||
|
white-space: nowrap; z-index: 10; pointer-events: none;
|
||||||
|
}
|
||||||
|
.db-tile-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px;
|
||||||
|
grid-template-rows: repeat(6, 1fr); min-height: 220px;
|
||||||
|
}
|
||||||
|
.db-tile {
|
||||||
|
border: none; border-radius: 4px; cursor: pointer; padding: 4px 2px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
transition: filter 0.15s, outline 0.15s;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.db-tile:hover { filter: brightness(1.2); }
|
||||||
|
.db-tile:focus-visible { outline: 2px solid var(--color-re-teal); outline-offset: 1px; }
|
||||||
|
.db-tile-selected { outline: 2px solid #fff; }
|
||||||
|
.db-tile-suppressed { background-image: repeating-linear-gradient(
|
||||||
|
45deg, transparent, transparent 4px, rgba(255,255,255,0.06) 4px, rgba(255,255,255,0.06) 8px
|
||||||
|
) !important; }
|
||||||
|
.db-tile-name { font-size: 0.5rem; color: #fff; text-align: center; line-height: 1.2; font-weight: 600; }
|
||||||
|
.db-tile-pct { font-size: 0.6rem; color: rgba(255,255,255,0.85); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── Donut ── */
|
||||||
|
.db-donut-wrapper { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.db-donut-svg { width: 130px; height: 130px; flex-shrink: 0; }
|
||||||
|
.db-donut-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.db-legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; }
|
||||||
|
.db-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.db-legend-val { margin-left: auto; font-weight: 600; color: var(--color-re-teal); }
|
||||||
|
|
||||||
|
/* ── Gauge ── */
|
||||||
|
.db-gauge-wrapper { text-align: center; }
|
||||||
|
.db-gauge-svg { width: 100%; max-width: 160px; }
|
||||||
|
.db-gauge-zona { font-size: 0.9rem; font-weight: 700; margin: 4px 0 2px; }
|
||||||
|
.db-gauge-hint { font-size: 0.7rem; color: #9CA8A8; margin: 0; }
|
||||||
|
|
||||||
|
/* ── Bar chart ── */
|
||||||
|
.db-bar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.db-bar-item { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.db-bar-label { font-size: 0.75rem; color: #494F4F; width: 180px; flex-shrink: 0; line-height: 1.3; }
|
||||||
|
.db-bar-track { flex: 1; background: rgba(73,79,79,0.10); border-radius: 4px; height: 14px; overflow: hidden; }
|
||||||
|
.db-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
|
||||||
|
.db-bar-pct { font-size: 0.75rem; color: var(--color-re-teal); width: 40px; text-align: right; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Heatmap / hexbin shared ── */
|
||||||
|
.db-heatmap-scroll { overflow-x: auto; }
|
||||||
|
.db-heatmap-grid { display: grid; gap: 2px; }
|
||||||
|
.db-heatmap-header {
|
||||||
|
font-size: 0.625rem; color: #6B7878; text-align: center; padding: 2px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.db-heatmap-rowlabel { font-size: 0.625rem; color: #6B7878; padding: 2px 4px; line-height: 1.2; }
|
||||||
|
.db-heatmap-cell {
|
||||||
|
border-radius: 3px; min-height: 32px; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.7rem; color: rgba(255,255,255,0.8); transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
.db-heatmap-cell:hover { filter: brightness(1.2); }
|
||||||
|
.db-hexbin-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
/* ── Pirámide ── */
|
||||||
|
.db-piramide { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.db-piramide-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
.db-piramide-row { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.db-piramide-label { font-size: 0.7rem; color: #6B7878; width: 90px; flex-shrink: 0; text-align: right; }
|
||||||
|
.db-piramide-bars { flex: 1; display: flex; gap: 2px; }
|
||||||
|
.db-piramide-bar-wrap { display: flex; flex-direction: column; }
|
||||||
|
.db-piramide-bar { height: 20px; border-radius: 3px; min-width: 4px; transition: width 0.4s; }
|
||||||
|
.db-piramide-n { font-size: 0.7rem; color: #6B7878; width: 24px; text-align: right; }
|
||||||
|
|
||||||
|
/* ── Tabla riesgo ── */
|
||||||
|
.db-table-wrapper { overflow-x: auto; }
|
||||||
|
.db-table-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 10px; }
|
||||||
|
.db-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
|
||||||
|
.db-table th { color: #6B7878; font-weight: 600; text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a3547; }
|
||||||
|
.db-table td { padding: 10px 12px; border-bottom: 1px solid #1e2d42; color: #494F4F; }
|
||||||
|
.db-table tr:last-child td { border-bottom: none; }
|
||||||
|
.db-risk-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 700; color: #fff; }
|
||||||
|
|
||||||
|
/* ── Sidebar ── */
|
||||||
|
.db-sidebar {
|
||||||
|
width: 220px; flex-shrink: 0; background: #F0EFEE;
|
||||||
|
border-right: 1px solid rgba(67,187,200,0.15);
|
||||||
|
padding: 12px; transition: width 0.3s ease;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.db-sidebar-collapsed { width: 52px; }
|
||||||
|
.db-sidebar-toggle {
|
||||||
|
background: none; border: 1px solid rgba(67,187,200,0.3);
|
||||||
|
color: var(--color-re-teal); border-radius: 6px; padding: 6px;
|
||||||
|
cursor: pointer; font-size: 0.75rem; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.db-sidebar-content { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
|
||||||
|
.db-filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.db-filter-label { font-size: 0.75rem; font-weight: 600; color: #6B7878; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.db-filter-select {
|
||||||
|
background: #FFFFFF; border: 1px solid #2a3547; color: #494F4F;
|
||||||
|
border-radius: 6px; padding: 6px 8px; font-size: 0.8125rem; width: 100%;
|
||||||
|
}
|
||||||
|
.db-filter-check { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #494F4F; cursor: pointer; }
|
||||||
|
.db-filter-check input { accent-color: var(--color-re-teal); width: 14px; height: 14px; }
|
||||||
|
.db-filter-reset {
|
||||||
|
background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3);
|
||||||
|
color: var(--color-re-teal); border-radius: 6px; padding: 8px;
|
||||||
|
cursor: pointer; font-size: 0.8125rem; font-weight: 600;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.db-filter-reset:hover { background: rgba(67,187,200,0.2); }
|
||||||
|
|
||||||
|
/* ── Tres miradas: tabs ── */
|
||||||
|
.db-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(67,187,200,0.15); }
|
||||||
|
.db-tab {
|
||||||
|
padding: 10px 18px; font-size: 0.8125rem; font-weight: 600; color: #6B7878;
|
||||||
|
cursor: pointer; border: none; background: none; border-bottom: 2px solid transparent;
|
||||||
|
display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
|
||||||
|
}
|
||||||
|
.db-tab:hover { color: #494F4F; }
|
||||||
|
.db-tab-active { color: var(--color-re-teal); border-bottom-color: var(--color-re-teal); }
|
||||||
|
.db-tab-lens { font-size: 0.6875rem; color: #5e6f8c; font-weight: 400; }
|
||||||
|
|
||||||
|
/* ── Tres miradas: intro de cada lente ── */
|
||||||
|
.db-lens-intro {
|
||||||
|
background: #F0EFEE; border-left: 3px solid var(--color-re-teal);
|
||||||
|
border-radius: 0 8px 8px 0; padding: 10px 14px; margin-bottom: 16px;
|
||||||
|
font-size: 0.8125rem; color: #494F4F; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card destacada (estrella) ── */
|
||||||
|
.db-widget-star {
|
||||||
|
border-color: var(--color-re-teal);
|
||||||
|
}
|
||||||
|
.db-widget-star .db-widget-title { color: #494F4F; }
|
||||||
|
|
||||||
|
/* ── Big stat (KPI de una sola cifra, dentro de WidgetShell) ── */
|
||||||
|
.db-big-stat { font-size: 2.375rem; font-weight: 700; color: var(--color-re-teal); line-height: 1; }
|
||||||
|
.db-big-stat-warn { color: #f0b429; }
|
||||||
|
.db-big-stat-danger { color: #f06b6b; }
|
||||||
|
.db-big-stat-sub { font-size: 0.75rem; color: #8A9A9A; margin-top: 6px; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* ── Punto ciego: contraste de dos barras ── */
|
||||||
|
.db-gap-viz { display: flex; align-items: flex-end; gap: 24px; justify-content: center; padding: 8px 0 4px; }
|
||||||
|
.db-gap-col { text-align: center; }
|
||||||
|
.db-gap-bar { width: 56px; border-radius: 6px 6px 0 0; margin: 0 auto; transition: height 0.4s; }
|
||||||
|
.db-gap-val { font-size: 0.8125rem; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.db-gap-lab { font-size: 0.6875rem; color: #8A9A9A; margin-top: 6px; }
|
||||||
|
.db-gap-brecha { text-align: center; font-size: 0.75rem; color: #9A7200; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* ── Placeholder (Mirada País / Humana, "próximamente") ── */
|
||||||
|
.db-placeholder {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 8px; min-height: 220px; text-align: center; color: #8A9A9A;
|
||||||
|
background: #F0EFEE; border: 1px dashed rgba(67,187,200,0.25); border-radius: 12px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
.db-placeholder-icon { font-size: 1.75rem; }
|
||||||
|
.db-placeholder-title { font-size: 1rem; font-weight: 600; color: #494F4F; }
|
||||||
|
.db-placeholder-hint { font-size: 0.8125rem; max-width: 420px; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ── Nav compartido (dashboard / respuestas / usuarios) ── */
|
||||||
|
.admin-nav-bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px; padding: 10px 24px; background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border); flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.admin-nav { display: flex; gap: 4px; }
|
||||||
|
.admin-nav-link {
|
||||||
|
padding: 6px 14px; border-radius: var(--radius); font-size: 0.875rem;
|
||||||
|
font-weight: 600; color: var(--color-text-muted); text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.admin-nav-link:hover { background: var(--color-primary-light); color: var(--color-text); }
|
||||||
|
.admin-nav-link-active { background: var(--color-primary-light); color: var(--color-primary-hover); }
|
||||||
|
|
||||||
|
/* ── Admin: control de respuestas (Etapa 3V) ── */
|
||||||
|
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||||
|
.admin-header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.admin-title { font-size: 1.375rem; font-weight: 700; color: var(--color-text); margin: 0 0 4px; }
|
||||||
|
.admin-subtitle { font-size: 0.8125rem; color: var(--color-text-muted); margin: 0; }
|
||||||
|
.admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
|
.admin-refresh {
|
||||||
|
display: inline-block; padding: 8px 16px; border-radius: var(--radius);
|
||||||
|
background: var(--color-primary); color: #fff; font-size: 0.8125rem;
|
||||||
|
font-weight: 600; text-decoration: none; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.admin-refresh:hover { background: var(--color-primary-hover); }
|
||||||
|
.admin-survey-select {
|
||||||
|
font-size: 0.8125rem; padding: 6px 10px; border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border); color: var(--color-text);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.admin-action-link {
|
||||||
|
font-size: 0.75rem; font-weight: 600; color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.admin-action-link:hover { text-decoration: underline; }
|
||||||
|
.admin-pagination {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 16px;
|
||||||
|
margin-top: 16px; font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.admin-pagination-link { color: var(--color-primary); text-decoration: none; font-weight: 600; }
|
||||||
|
.admin-pagination-link:hover { text-decoration: underline; }
|
||||||
|
.admin-pagination-disabled { color: var(--color-text-muted); cursor: default; }
|
||||||
|
.admin-pagination-current { color: var(--color-text-muted); }
|
||||||
|
.admin-back-link {
|
||||||
|
display: inline-block; margin-bottom: 12px; font-size: 0.8125rem;
|
||||||
|
font-weight: 600; color: var(--color-primary); text-decoration: none;
|
||||||
|
}
|
||||||
|
.admin-back-link:hover { text-decoration: underline; }
|
||||||
|
.admin-detail-section { margin-bottom: 24px; }
|
||||||
|
.admin-detail-section-title {
|
||||||
|
font-size: 1.0625rem; font-weight: 700; color: var(--color-text);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.admin-detail-instance { margin-bottom: 14px; }
|
||||||
|
.admin-instance-badge {
|
||||||
|
display: inline-block; font-size: 0.75rem; font-weight: 600;
|
||||||
|
color: var(--color-primary-hover); background: var(--color-primary-light);
|
||||||
|
padding: 3px 10px; border-radius: 999px; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.admin-table-wrapper {
|
||||||
|
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius); box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
|
||||||
|
.admin-table th {
|
||||||
|
position: sticky; top: 0; background: var(--color-re-dark); color: #fff;
|
||||||
|
font-weight: 600; text-align: left; padding: 10px 12px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); color: var(--color-text); vertical-align: top; }
|
||||||
|
.admin-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.admin-table tbody tr.admin-group-even { background: var(--color-bg); }
|
||||||
|
.admin-table tbody tr.admin-group-start td { border-top: 2px solid var(--color-re-dark); }
|
||||||
|
.admin-table tbody tr:hover { background: var(--color-primary-light); }
|
||||||
|
.admin-prompt { color: var(--color-text); }
|
||||||
|
.admin-row-toggle { cursor: pointer; }
|
||||||
|
.admin-toggle-icon {
|
||||||
|
display: inline-block; width: 14px; margin-right: 4px;
|
||||||
|
color: var(--color-text-muted); font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.admin-row-toggle-btn {
|
||||||
|
background: none; border: none; padding: 0; margin: 0;
|
||||||
|
font: inherit; color: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-nodata {
|
||||||
|
display: inline-block; padding: 1px 8px; border-radius: 10px;
|
||||||
|
background: var(--color-re-light); color: var(--color-re-dark);
|
||||||
|
opacity: 0.6; font-size: 0.7rem; font-style: italic;
|
||||||
|
}
|
||||||
|
.admin-empty { padding: 24px; text-align: center; color: var(--color-text-muted); }
|
||||||
|
.admin-error { color: var(--color-error); font-weight: 600; }
|
||||||
|
.admin-error-detail {
|
||||||
|
background: var(--color-error-bg); border-radius: var(--radius);
|
||||||
|
padding: 12px; font-family: 'Courier New', monospace; font-size: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.db-kpi-row { grid-template-columns: repeat(2,1fr); }
|
||||||
|
.db-sidebar { width: 100%; border-right: none; border-bottom: 1px solid rgba(67,187,200,0.15); }
|
||||||
|
.db-page { flex-direction: column; }
|
||||||
|
.admin-col-optional { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Reduced motion ── */
|
/* ── Reduced motion ── */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Login ── */
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 40px 36px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
.login-form { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.login-field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.login-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.login-error { margin: 0; }
|
||||||
|
.login-password-wrapper { position: relative; }
|
||||||
|
.login-password-wrapper .text-short-input { padding-right: 40px; }
|
||||||
|
.login-password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.login-password-toggle:hover { color: var(--color-text); }
|
||||||
|
.login-submit {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.login-submit:hover:not(:disabled) { background: var(--color-primary-hover); }
|
||||||
|
.login-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.login-back-button { margin-top: 16px; }
|
||||||
|
.login-forgot-link {
|
||||||
|
align-self: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.login-forgot-link:hover { color: var(--color-primary-hover); }
|
||||||
|
|
||||||
|
/* ── Invite form (/admin/usuarios) ── */
|
||||||
|
.invite-form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.invite-form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.invite-banner {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.invite-banner-success {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
.invite-banner-error {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.invite-form-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Admin sign-out ── */
|
||||||
|
.admin-header-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.admin-signout {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.admin-signout:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import { SurveyHeader } from '@/components/survey-header'
|
||||||
|
import { SurveyFooter } from '@/components/survey-footer'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Mapeo de Cuidadores',
|
title: 'Mapeo de Cuidadores',
|
||||||
@@ -17,7 +19,11 @@ export const viewport: Viewport = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<SurveyHeader />
|
||||||
|
<div className="layout-content">{children}</div>
|
||||||
|
<SurveyFooter />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
197
app/login/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
type Mode = 'login' | 'reset' | 'reset-sent'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [mode, setMode] = useState<Mode>('login')
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [resetEmail, setResetEmail] = useState('')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsPending(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const { error: authError } = await supabase.auth.signInWithPassword({
|
||||||
|
email: fd.get('email') as string,
|
||||||
|
password: fd.get('password') as string,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
setError('Email o contraseña incorrectos')
|
||||||
|
setIsPending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/admin/responses')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusa /auth/confirm: ya escucha PASSWORD_RECOVERY (lo armamos para el
|
||||||
|
// flujo de invitación, pero Supabase emite el mismo evento acá).
|
||||||
|
async function handleReset(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsPending(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const email = String(fd.get('email') ?? '')
|
||||||
|
|
||||||
|
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
|
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/confirm`,
|
||||||
|
})
|
||||||
|
|
||||||
|
setIsPending(false)
|
||||||
|
|
||||||
|
if (resetError) {
|
||||||
|
setError('No pudimos enviar el link. Probá de nuevo.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setResetEmail(email)
|
||||||
|
setMode('reset-sent')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'reset-sent') {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Revisá tu email</h1>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
Si <strong>{resetEmail}</strong> tiene una cuenta, te enviamos un link para crear una
|
||||||
|
contraseña nueva.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-submit login-back-button"
|
||||||
|
onClick={() => { setMode('login'); setError(null) }}
|
||||||
|
>
|
||||||
|
Volver al login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'reset') {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Recuperar contraseña</h1>
|
||||||
|
<form onSubmit={handleReset} className="login-form" noValidate>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="reset-email" className="login-label">Email</label>
|
||||||
|
<input
|
||||||
|
id="reset-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="field-error login-error">{error}</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={isPending} className="login-submit">
|
||||||
|
{isPending ? 'Enviando…' : 'Enviar link de recuperación'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-forgot-link"
|
||||||
|
onClick={() => { setMode('login'); setError(null) }}
|
||||||
|
>
|
||||||
|
← Volver al login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Acceso interno</h1>
|
||||||
|
<form onSubmit={handleSubmit} className="login-form" noValidate>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="email" className="login-label">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="password" className="login-label">Contraseña</label>
|
||||||
|
<div className="login-password-wrapper">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-password-toggle"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="field-error login-error">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="login-submit"
|
||||||
|
>
|
||||||
|
{isPending ? 'Ingresando…' : 'Ingresar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-forgot-link"
|
||||||
|
onClick={() => { setMode('reset'); setError(null) }}
|
||||||
|
>
|
||||||
|
¿Olvidaste tu contraseña?
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
app/page.tsx
@@ -1,21 +1,34 @@
|
|||||||
import { createClient } from '@/lib/supabase'
|
import { createClient } from '@/lib/supabase'
|
||||||
import type { Question, Survey } from '@/lib/types'
|
import type { BrandConfig, Survey } from '@/lib/types'
|
||||||
import { SurveyForm } from '@/components/survey-form'
|
import type { FormSchema, Question as EngineQuestion } from '@/lib/form-engine.types'
|
||||||
|
import { buildFormSchema } from '@/lib/form-engine'
|
||||||
|
import { WizardForm } from '@/components/wizard-form'
|
||||||
|
|
||||||
|
// Preguntas de consentimiento (S1, 1.1, 1.2): las resuelve ConsentScreen, no el
|
||||||
|
// motor de flujo — su texto legal (ADR-003) no vive en el hint de estas filas
|
||||||
|
// todavía. Se excluyen del FormSchema para que el wizard arranque en S2.
|
||||||
|
const CONSENT_CODES = new Set(['S1', '1.1', '1.2'])
|
||||||
|
|
||||||
// Force dynamic rendering — page fetches from Supabase at request time.
|
// Force dynamic rendering — page fetches from Supabase at request time.
|
||||||
// Never statically generated: credentials live in .env.local (runtime only).
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
// Server Component — fetches survey with anon key; RLS ensures only status='live' is returned.
|
// In Next.js 15, searchParams is a Promise and must be awaited.
|
||||||
export default async function Page() {
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ s?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
const { s: surveyId } = await searchParams
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
// 1. Fetch the live survey
|
// 1. Fetch survey — by ?s=UUID if provided, otherwise first live survey
|
||||||
const { data: surveyRow, error: surveyError } = await supabase
|
const surveyQuery = supabase
|
||||||
.from('surveys')
|
.from('surveys')
|
||||||
.select('id, title, status, is_anonymous, k_threshold')
|
.select('id, org_id, title, status, is_anonymous, k_threshold, consent_version_id, brand_config')
|
||||||
.eq('status', 'live')
|
|
||||||
.maybeSingle()
|
const { data: surveyRow, error: surveyError } = surveyId
|
||||||
|
? await surveyQuery.eq('id', surveyId).maybeSingle()
|
||||||
|
: await surveyQuery.eq('status', 'live').maybeSingle()
|
||||||
|
|
||||||
if (surveyError) {
|
if (surveyError) {
|
||||||
return (
|
return (
|
||||||
@@ -32,20 +45,22 @@ export default async function Page() {
|
|||||||
<div className="no-survey" role="main">
|
<div className="no-survey" role="main">
|
||||||
<h1 className="no-survey-title">No hay encuesta activa</h1>
|
<h1 className="no-survey-title">No hay encuesta activa</h1>
|
||||||
<p>
|
<p>
|
||||||
Para ver el formulario, la encuesta debe estar en estado <code>live</code>.
|
Para ver el formulario, la encuesta debe estar en estado <code>live</code>,
|
||||||
|
o pasá el UUID por parámetro: <code>?s=UUID</code>.
|
||||||
</p>
|
</p>
|
||||||
<pre className="no-survey-hint">
|
<pre className="no-survey-hint">
|
||||||
{`-- Activar la encuesta en Supabase Cloud:\nUPDATE surveys\nSET status = 'live'\nWHERE id = '30000000-0000-0000-0000-000000000001';`}
|
{`-- Activar en Supabase Cloud:\nUPDATE surveys SET status = 'live'\nWHERE id = '30000000-0000-0000-0000-000000000001';`}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch questions ordered by position
|
// 2. Fetch questions ordered by position — incluye hint/instance_generator/instance_of (4D)
|
||||||
const { data: questionRows, error: questionsError } = await supabase
|
const { data: questionRows, error: questionsError } = await supabase
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select(
|
.select(
|
||||||
'id, survey_id, code, type, prompt, position, is_sensitive, required, max_select, options, conditional_rules'
|
'id, survey_id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||||
|
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||||
)
|
)
|
||||||
.eq('survey_id', surveyRow.id)
|
.eq('survey_id', surveyRow.id)
|
||||||
.order('position', { ascending: true })
|
.order('position', { ascending: true })
|
||||||
@@ -59,22 +74,29 @@ export default async function Page() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const questions = questionRows as Question[]
|
// Cast en dos pasos: el cliente Supabase no tiene un Database genérico configurado
|
||||||
|
// (lib/supabase.ts), así que el tipo de fila que infiere para .select() no solapa
|
||||||
|
// directamente con EngineQuestion. Las columnas pedidas arriba sí coinciden 1:1.
|
||||||
|
const engineQuestions = questionRows as unknown as EngineQuestion[]
|
||||||
|
|
||||||
// 3. Log question count for validation (visible in server logs and browser Network tab)
|
console.log(`[4D] Survey loaded: id=${surveyRow.id} | questions=${engineQuestions.length}`)
|
||||||
console.log(
|
|
||||||
`[2A] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`
|
|
||||||
)
|
|
||||||
|
|
||||||
const survey: Survey = {
|
const survey: Survey = {
|
||||||
...(surveyRow as Omit<Survey, 'questions'>),
|
id: surveyRow.id,
|
||||||
questions,
|
org_id: surveyRow.org_id,
|
||||||
|
title: surveyRow.title,
|
||||||
|
status: surveyRow.status,
|
||||||
|
is_anonymous: surveyRow.is_anonymous,
|
||||||
|
k_threshold: surveyRow.k_threshold,
|
||||||
|
consent_version_id: surveyRow.consent_version_id,
|
||||||
|
brand_config: (surveyRow.brand_config as BrandConfig) ?? null,
|
||||||
|
questions: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// El consentimiento (S1, 1.1, 1.2) lo resuelve ConsentScreen, no el motor — ver nota arriba.
|
||||||
<SurveyForm
|
const schema: FormSchema = buildFormSchema(
|
||||||
survey={survey}
|
engineQuestions.filter((q) => !CONSENT_CODES.has(q.code))
|
||||||
questions={questions}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return <WizardForm survey={survey} schema={schema} />
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/.DS_Store
vendored
Normal file
BIN
assets/fundacion/.DS_Store
vendored
Normal file
BIN
assets/fundacion/ISOLOGO HORIZONATAL-FS.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/fundacion/ISOLOGO VERTICAL TAGLINE-FS-28.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/fundacion/ISOLOGO VERTICAL-FS.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/fundacion/ISOTIPO_FS.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/fundacion/LOGOTIPO-FS.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/fundacion/Versión Horiz_Isologotipo FS-17.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
380
assets/fundacion/Versión Horiz_Isologotipo FS-17.svg
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="535.614px" height="308.44px" viewBox="0 0 535.614 308.44" enable-background="new 0 0 535.614 308.44"
|
||||||
|
xml:space="preserve">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" fill="#129539" d="M181.184,99.452c-0.34-0.962-0.875-1.782-1.596-2.409
|
||||||
|
c-0.729-0.643-1.628-1.059-2.608-1.183c-2.093-0.275-4.58,0.708-6.829,3.731c-1.76,1.805-4.482,5.509-7.164,9.176
|
||||||
|
c-2.851,3.915-5.667,7.738-7.12,9.029c-0.727,0.664-1.556,0.967-2.206,0.945c-0.281-0.027-0.548-0.098-0.732-0.238h-0.021
|
||||||
|
c-0.165-0.14-0.303-0.323-0.4-0.588c-0.167-0.584-0.046-1.432,0.492-2.549c2.257-4.683,6.057-12.303,9.529-19.074
|
||||||
|
c2.979-5.827,5.676-11.001,6.939-13.166c2.279-3.803,2.374-7.205,1.248-9.457c-0.494-0.939-1.199-1.711-2.065-2.182
|
||||||
|
c-0.843-0.491-1.855-0.723-2.933-0.604c-2.441,0.232-5.228,2.177-7.482,6.47c-2.673,5.163-5.185,9.78-7.674,14.381
|
||||||
|
c-2.981,5.466-5.913,10.86-8.935,16.779c-0.521,0.815-1.193,1.426-1.828,1.701c-0.289,0.124-0.524,0.194-0.737,0.151
|
||||||
|
c-0.162-0.027-0.319-0.124-0.44-0.308c-0.259-0.324-0.421-0.913-0.421-1.831c-0.027-3.052,3.124-12.577,5.627-20.154
|
||||||
|
c1.037-3.191,1.971-6.021,2.536-7.933c0.891-3.116,2.374-8.484,3.521-12.945c1.032-3.937,1.828-7.177,1.828-7.625
|
||||||
|
c-0.021-2.23-0.516-3.916-1.291-5.114c-0.748-1.129-1.709-1.81-2.819-2.128c-1.075-0.34-2.252-0.286-3.391,0.043
|
||||||
|
c-1.512,0.47-2.962,1.432-4.094,2.787c-1.755,2.139-4.102,10.217-6.045,16.968l-0.608,2.058c-0.542,1.879-1.574,5.59-2.724,9.764
|
||||||
|
c-2.09,7.646-4.623,16.871-5.422,19.031c-0.373,1.053-1.01,1.798-1.685,2.149c-0.275,0.146-0.591,0.216-0.851,0.194
|
||||||
|
c-0.232-0.027-0.438-0.124-0.632-0.308c-0.395-0.378-0.702-1.097-0.748-2.252c-0.327-5.654,0.256-13.118,0.845-20.506
|
||||||
|
c0.491-6.194,0.958-12.313,0.915-17.319c0.235-3.32-0.842-5.643-2.468-6.934c-0.775-0.632-1.685-1.01-2.646-1.156
|
||||||
|
c-0.942-0.135-1.928-0.021-2.86,0.324c-2.068,0.756-3.896,2.685-4.601,5.676c-1.269,5.455-1.925,13.686-2.533,21.311
|
||||||
|
c-0.588,7.463-1.15,14.332-2.16,17.222c-0.448,1.215-1.199,3.24-2.136,4.854c-0.821,1.383-1.736,2.506-2.536,2.35
|
||||||
|
c-2.533-0.48-2.859-2.587-3.14-4.396c-0.046-0.389-0.119-0.793-0.189-1.16c-0.235-1.912-0.603-3.904-1.078-5.801
|
||||||
|
c-0.448-1.922-1.007-3.774-1.593-5.4c-1.064-2.835-3.005-5.551-5.239-7.344c-1.08-0.838-2.249-1.496-3.416-1.831
|
||||||
|
c-1.226-0.318-2.468-0.318-3.664,0.118c-1.013,0.373-1.974,1.086-2.841,2.166c-2.136,2.674-0.632,6.658,1.288,11.849
|
||||||
|
c1.95,5.228,4.393,11.751,3.832,19.042c-0.127,1.522-0.24,3.057-0.383,4.53c-0.762,9.202-1.38,17.33-0.148,26.965
|
||||||
|
c1.045,0.34,2.092,0.681,3.181,1.074c0,0-0.256-0.967,1.248-11.789c2.787-20.073,27.423-29.054,45.787-18.923
|
||||||
|
c12.418,6.854,17.333,18.696,18.124,31.619c3.98-6.604,6.618-12.285,7.59-15.429c0.443-1.501,2.959-5.396,6-9.808
|
||||||
|
c4.577-6.62,10.398-14.321,12.461-16.8C181.532,104.62,181.951,101.683,181.184,99.452z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" fill="#163F75" d="M153.942,161.811c-0.454,0-0.856,0-1.302,0
|
||||||
|
c-0.934,8.527-3.151,17-5.765,24.361c-8.868,25.03-8.111,45.039-45.396,37.861c-11.679-2.246-24.604-4.73-36.032-8.883
|
||||||
|
c14.938,42.295,79.458,45.109,90.397,6.248C159.669,207.789,161.961,181.332,153.942,161.811z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" fill="#E00914" d="M91.254,160.644c3.381,2.273-0.47-4.86,2.627-17.275
|
||||||
|
c4.412-17.567,20.135-29.179,36.528-20.128c21.699,11.968,17.783,40.968,10.744,60.857c-7.925,22.357-7.272,40.238-40.563,33.833
|
||||||
|
c-26.197-5.065-59.521-11.405-60.733-39.601C39.311,165.963,57.051,133.426,91.254,160.644L91.254,160.644z"/>
|
||||||
|
<path fill="#E00914" d="M199.191,221.652c4.323,0,7.555-1.291,9.834-3.916c1.733-1.943,2.6-4.504,2.6-7.604
|
||||||
|
c0-3.37-1.288-6.658-3.823-9.872c-1.167-1.458-2.487-2.884-3.848-4.271c-3.378-3.327-5.77-6.118-7.131-8.489
|
||||||
|
c-1.547-2.582-2.301-5.298-2.301-8.182c0-3.041,0.548-5.471,1.577-7.302c1.245-2.208,3.07-3.326,5.422-3.326
|
||||||
|
c3.256,0,5.365,1.998,6.421,5.908c0.467,1.755,0.678,3.71,0.678,5.789c0,2.819-0.278,5.54-0.842,8.235
|
||||||
|
c-0.07,0.384-0.116,0.751-0.116,1.129c0,1.388,0.681,2.09,2.063,2.09c1.528,0,2.605-1.388,3.243-4.175
|
||||||
|
c0.488-2.024,0.721-4.46,0.721-7.279c0-11.276-4.404-16.888-13.25-16.888c-4.226,0-7.625,1.167-10.158,3.56
|
||||||
|
c-1.312,1.22-2.344,2.689-3.075,4.438c-1.007,2.463-1.526,5.282-1.526,8.377c0,4.525,1.196,8.678,3.621,12.383
|
||||||
|
c1.658,2.581,4.072,5.465,7.345,8.635c1.142,1.178,2.228,2.279,3.227,3.381c2.182,2.42,3.238,4.672,3.238,6.756
|
||||||
|
c0,3.262-1.48,4.903-4.429,4.903c-2.651,0-4.366-1.388-5.076-4.196c-0.33-1.145-0.486-2.327-0.486-3.45
|
||||||
|
c0-1.858-0.751-2.792-2.252-2.792c-1.645,0-2.671,1.571-2.671,3.64c0,3.656,0.772,6.54,2.387,8.646
|
||||||
|
C192.557,220.361,195.419,221.652,199.191,221.652L199.191,221.652z M234.038,216.235c-3.008,0-5.238-1.755-6.71-5.249
|
||||||
|
c-1.296-3.121-1.947-7.345-1.947-12.691c0-10.963,3.138-16.428,9.402-16.428c2.889,0,4.95,1.782,6.267,5.373
|
||||||
|
c1.013,2.781,1.499,6.243,1.499,10.461c0,5.093-0.635,9.321-1.877,12.669C239.192,214.291,236.989,216.235,234.038,216.235
|
||||||
|
L234.038,216.235z M231.667,221.587c5.819,0,10.134-2.278,12.853-6.848c2.39-4.062,3.591-9.612,3.591-16.703
|
||||||
|
c0-5.563-0.872-10.164-2.632-13.749c-2.417-4.926-6.189-7.394-11.395-7.394c-6.011,0-10.487,2.273-13.425,6.874
|
||||||
|
c-2.603,3.991-3.918,9.451-3.918,16.348c0,5.897,1.034,10.66,3.124,14.365C222.519,219.238,226.48,221.587,231.667,221.587
|
||||||
|
L231.667,221.587z M263.008,221.728c0.889,0,1.974-0.141,3.211-0.491c1.218-0.33,2.233-0.768,2.983-1.41
|
||||||
|
c0.554-0.47,0.861-0.988,0.861-1.62c0-0.794-0.486-1.198-1.499-1.198c-0.324,0-0.821,0.075-1.477,0.265
|
||||||
|
c-0.426,0.118-0.918,0.184-1.474,0.184c-1.855,0-2.187-1.523-2.187-9.219v-38.548c0-1.62-0.284-2.9-0.77-3.915
|
||||||
|
c-0.732-1.523-2.001-2.274-3.751-2.274c-3.103,0-4.137,1.707-4.137,6.497v33.126c0,2.933,0.076,5.697,0.262,8.214
|
||||||
|
c0.21,2.717,0.772,4.925,1.663,6.681C257.958,220.485,260.049,221.728,263.008,221.728L263.008,221.728z M279.279,174.36
|
||||||
|
c1.653,0,2.825-0.61,3.594-1.755c0.545-0.777,0.805-1.717,0.805-2.797c0-1.193-0.4-2.317-1.205-3.327
|
||||||
|
c-0.767-1.01-1.831-1.534-3.122-1.534c-2.53,0-4.331,2.215-4.331,5.331C275.021,173,276.447,174.36,279.279,174.36L279.279,174.36z
|
||||||
|
M279.123,221.652c3.019,0,4.556-2.08,4.556-6.238v-32.023c0-4.828-1.129-6.497-4.129-6.497c-2.983,0-4.528,2.16-4.528,6.199v32.02
|
||||||
|
c0,1.102,0.089,2.133,0.232,3.143C275.583,220.556,276.849,221.652,279.123,221.652L279.123,221.652z M309.905,193.721
|
||||||
|
c0,2.9-0.237,5.936-0.683,9.073c-0.421,3.1-1.08,5.746-1.947,7.884c-1.571,3.965-3.542,5.936-5.935,5.936
|
||||||
|
c-3.029,0.162-3.988-6.729-3.988-14.009c0-4.963,1.482-20.43,8.587-20.43c1.949,0,3.138,0.47,3.634,1.447
|
||||||
|
c0.211,0.476,0.332,1.037,0.332,1.642V193.721z M318.535,169.997c0-3.208-0.138-6.497-4.131-6.497c-3.467,0-4.499,3.009-4.499,6.189
|
||||||
|
v7.458c0,0.572-0.078,0.875-0.259,0.875c-0.189,0-0.5-0.119-0.867-0.303c-1.083-0.572-2.279-0.826-3.521-0.826
|
||||||
|
c-4.698,0-8.352,2.673-11.006,8.073c-2.481,5.109-3.729,11.806-3.729,20.035c0,8.83,3.238,16.796,9.602,16.65
|
||||||
|
c3.375,0,6.186-1.756,8.435-5.229c0.662-1.068,1.04-1.642,1.153-1.642s0.191,0.232,0.191,0.632c0,3.191,1.032,6.238,4.499,6.238
|
||||||
|
c3.994,0,4.131-3.278,4.131-6.54V169.997z M348.777,221.652c3.964,0.075,4.061-3.322,4.129-6.54v-31.722
|
||||||
|
c-0.068-3.139-0.165-6.563-4.129-6.497c-1.385,0-2.443,0.427-3.075,1.226c-0.208,0.281-0.462,0.416-0.815,0.416
|
||||||
|
c-0.241,0-0.6-0.107-0.97-0.329c-1.501-0.864-2.951-1.313-4.355-1.313c-4.72,0-8.387,2.673-11.03,8.073
|
||||||
|
c-2.484,5.109-3.729,11.806-3.729,20.035c0,8.83,3.265,16.796,9.597,16.65c3.794,0,6.826-2.08,9.121-6.26
|
||||||
|
c0.259-0.496,0.448-0.729,0.567-0.729c0.116,0,0.167,0.275,0.167,0.75C344.254,218.605,345.299,221.652,348.777,221.652
|
||||||
|
L348.777,221.652z M335.616,216.613c-3.027,0.162-3.991-6.729-3.991-14.009c0-4.963,1.477-20.43,8.586-20.43
|
||||||
|
c2.7,0,4.042,0.967,4.042,2.911v10.342c0,1.879-0.216,4.336-0.664,7.366c-0.467,3.029-1.128,5.681-2.042,7.884
|
||||||
|
C339.999,214.643,338.006,216.613,335.616,216.613L335.616,216.613z M365.996,221.652c3.027,0,4.525-2.08,4.525-6.238v-9.996
|
||||||
|
c0-6.988,0.889-12.664,2.649-17c1.763-4.391,3.78-6.551,5.986-6.551c0.651,0,1.212,0.211,1.712,0.653
|
||||||
|
c0.632,0.572,1.24,0.87,1.828,0.87c1.62,0,2.887-1.664,2.887-3.44c0-1.977-2.322-3.057-5.16-3.057c-3.705,0-6.731,2.068-9.081,6.199
|
||||||
|
c-0.259,0.486-0.489,0.751-0.629,0.751c-0.122,0-0.192-0.265-0.192-0.805c0-4.104-1.499-6.146-4.525-6.146
|
||||||
|
c-3.894,0-4.128,3.262-4.128,6.448v31.771c0,1.069,0.097,2.133,0.235,3.143C362.429,220.556,363.739,221.652,365.996,221.652
|
||||||
|
L365.996,221.652z M393.254,174.36c1.645,0,2.841-0.61,3.616-1.755c0.516-0.777,0.799-1.717,0.799-2.797
|
||||||
|
c0-1.193-0.427-2.317-1.207-3.327c-0.789-1.01-1.823-1.534-3.138-1.534c-2.533,0-4.315,2.215-4.315,5.331
|
||||||
|
C389.01,173,390.414,174.36,393.254,174.36L393.254,174.36z M393.12,221.652c2.997,0,4.55-2.08,4.55-6.238v-32.023
|
||||||
|
c0-4.828-1.134-6.497-4.137-6.497c-2.997,0-4.523,2.16-4.523,6.199v32.02c0,1.102,0.067,2.133,0.229,3.143
|
||||||
|
C389.571,220.556,390.838,221.652,393.12,221.652L393.12,221.652z M423.867,193.721c0,2.9-0.205,5.936-0.656,9.073
|
||||||
|
c-0.418,3.1-1.077,5.746-1.95,7.884c-1.566,3.965-3.534,5.936-5.951,5.936c-3.029,0.162-3.999-6.729-3.999-14.009
|
||||||
|
c0-4.963,1.51-20.43,8.619-20.43c1.917,0,3.122,0.47,3.613,1.447c0.235,0.476,0.324,1.037,0.324,1.642V193.721z M432.529,169.997
|
||||||
|
c0-3.208-0.167-6.497-4.128-6.497c-3.473,0-4.534,3.009-4.534,6.189v7.458c0,0.572-0.043,0.875-0.254,0.875
|
||||||
|
c-0.184,0-0.467-0.119-0.867-0.303c-1.083-0.572-2.255-0.826-3.494-0.826c-4.72,0-8.378,2.673-11.003,8.073
|
||||||
|
c-2.511,5.109-3.731,11.806-3.731,20.035c0,8.83,3.237,16.796,9.575,16.65c3.4,0,6.187-1.756,8.444-5.229
|
||||||
|
c0.675-1.068,1.053-1.642,1.169-1.642c0.119,0,0.162,0.232,0.162,0.632c0,3.191,1.061,6.238,4.534,6.238
|
||||||
|
c3.961,0,4.128-3.278,4.128-6.54V169.997z M462.744,221.652c3.985,0.075,4.058-3.322,4.15-6.54v-31.722
|
||||||
|
c-0.092-3.139-0.165-6.563-4.15-6.497c-1.364,0-2.417,0.427-3.049,1.226c-0.21,0.281-0.494,0.416-0.823,0.416
|
||||||
|
c-0.257,0-0.607-0.107-0.961-0.329c-1.501-0.864-2.957-1.313-4.391-1.313c-4.714,0-8.373,2.673-10.995,8.073
|
||||||
|
c-2.492,5.109-3.737,11.806-3.737,20.035c0,8.83,3.24,16.796,9.578,16.65c3.82,0,6.848-2.08,9.121-6.26
|
||||||
|
c0.259-0.496,0.454-0.729,0.567-0.729c0.119,0,0.178,0.275,0.178,0.75C458.232,218.605,459.271,221.652,462.744,221.652
|
||||||
|
L462.744,221.652z M449.602,216.613c-3.024,0.162-4.001-6.729-4.001-14.009c0-4.963,1.499-20.43,8.605-20.43
|
||||||
|
c2.697,0,4.026,0.967,4.026,2.911v10.342c0,1.879-0.203,4.336-0.648,7.366c-0.467,3.029-1.131,5.681-2.044,7.884
|
||||||
|
C453.974,214.643,452,216.613,449.602,216.613L449.602,216.613z M492.913,193.721c0,2.9-0.208,5.936-0.656,9.073
|
||||||
|
c-0.427,3.1-1.08,5.746-1.95,7.884c-1.563,3.965-3.54,5.936-5.935,5.936c-3.027,0.162-4.013-6.729-4.013-14.009
|
||||||
|
c0-4.963,1.507-20.43,8.614-20.43c1.944,0,3.143,0.47,3.616,1.447c0.232,0.476,0.324,1.037,0.324,1.642V193.721z M501.573,169.997
|
||||||
|
c0-3.208-0.148-6.497-4.137-6.497c-3.461,0-4.523,3.009-4.523,6.189v7.458c0,0.572-0.048,0.875-0.237,0.875
|
||||||
|
c-0.203,0-0.492-0.119-0.867-0.303c-1.102-0.572-2.274-0.826-3.519-0.826c-4.717,0-8.373,2.673-11.003,8.073
|
||||||
|
c-2.484,5.109-3.729,11.806-3.729,20.035c0,8.83,3.24,16.796,9.57,16.65c3.405,0,6.224-1.756,8.449-5.229
|
||||||
|
c0.681-1.068,1.053-1.642,1.169-1.642c0.119,0,0.167,0.232,0.167,0.632c0,3.191,1.062,6.238,4.523,6.238
|
||||||
|
c3.988,0,4.137-3.278,4.137-6.54V169.997z"/>
|
||||||
|
<path fill="#2A2A2B" d="M190.7,230.309v11.546h-1.399v-11.546H190.7z M194.639,236.648v5.206h-1.334v-8.651h1.288v1.361h0.122
|
||||||
|
c0.213-0.454,0.516-0.805,0.913-1.086c0.424-0.265,0.964-0.395,1.62-0.395c0.864,0,1.574,0.275,2.111,0.816
|
||||||
|
c0.54,0.518,0.821,1.365,0.821,2.462v5.492h-1.336v-5.4c0-0.702-0.184-1.215-0.54-1.587c-0.348-0.4-0.824-0.589-1.458-0.589
|
||||||
|
c-0.632,0-1.145,0.21-1.569,0.637C194.853,235.314,194.639,235.903,194.639,236.648L194.639,236.648z M206.377,233.203v1.123h-1.855
|
||||||
|
v5.045c0,0.399,0.07,0.652,0.165,0.842c0.122,0.194,0.256,0.335,0.427,0.378c0.181,0.07,0.367,0.098,0.556,0.098
|
||||||
|
c0.138,0,0.281,0,0.375-0.027c0.092-0.026,0.162-0.026,0.213-0.043l0.281,1.188c-0.097,0.048-0.211,0.069-0.381,0.118
|
||||||
|
c-0.159,0.021-0.373,0.043-0.629,0.043c-0.378,0-0.721-0.064-1.099-0.237c-0.356-0.156-0.656-0.41-0.894-0.751
|
||||||
|
c-0.24-0.318-0.351-0.739-0.351-1.231v-5.422h-1.293v-1.123h1.293v-2.073h1.337v2.073H206.377z M211.915,242.044
|
||||||
|
c-0.826,0-1.55-0.189-2.144-0.557c-0.607-0.389-1.075-0.896-1.41-1.556c-0.324-0.675-0.486-1.457-0.486-2.344
|
||||||
|
c0-0.863,0.162-1.663,0.486-2.349c0.335-0.675,0.781-1.199,1.364-1.566c0.594-0.41,1.267-0.589,2.071-0.589
|
||||||
|
c0.442,0,0.885,0.07,1.336,0.232c0.418,0.141,0.845,0.4,1.199,0.724c0.346,0.33,0.653,0.772,0.861,1.34
|
||||||
|
c0.208,0.545,0.308,1.22,0.308,2.041v0.562h-6.284c0.046,0.918,0.303,1.62,0.789,2.111c0.494,0.497,1.134,0.751,1.909,0.751
|
||||||
|
c0.535,0,0.978-0.113,1.361-0.324c0.37-0.237,0.651-0.562,0.815-1.036l1.293,0.372c-0.216,0.664-0.61,1.172-1.218,1.583
|
||||||
|
C213.551,241.833,212.803,242.044,211.915,242.044L211.915,242.044z M209.217,236.838h4.95c0-0.734-0.219-1.334-0.665-1.831
|
||||||
|
c-0.416-0.47-0.982-0.729-1.706-0.729c-0.524,0-0.967,0.113-1.315,0.351c-0.375,0.238-0.688,0.567-0.894,0.939
|
||||||
|
C209.377,235.969,209.263,236.395,209.217,236.838L209.217,236.838z M217.54,241.854v-8.651h1.291v1.307h0.076
|
||||||
|
c0.154-0.416,0.435-0.767,0.861-1.053c0.427-0.254,0.87-0.395,1.41-0.395c0.089,0,0.208,0,0.351,0
|
||||||
|
c0.143,0.021,0.262,0.021,0.334,0.021v1.367c-0.021-0.033-0.143-0.033-0.311-0.06c-0.146-0.021-0.327-0.044-0.494-0.044
|
||||||
|
c-0.632,0-1.145,0.189-1.569,0.589c-0.429,0.378-0.604,0.87-0.604,1.458v5.46H217.54z M230.797,233.203l-3.189,8.651h-1.363
|
||||||
|
l-3.219-8.651h1.455l2.393,6.891h0.097l2.395-6.891H230.797z M235.857,242.044c-0.807,0-1.539-0.189-2.155-0.557
|
||||||
|
c-0.583-0.389-1.056-0.896-1.382-1.556c-0.33-0.675-0.491-1.457-0.491-2.344c0-0.863,0.162-1.663,0.491-2.349
|
||||||
|
c0.327-0.675,0.772-1.199,1.361-1.566c0.589-0.41,1.269-0.589,2.068-0.589c0.443,0,0.886,0.07,1.334,0.232
|
||||||
|
c0.416,0.141,0.848,0.4,1.193,0.724c0.354,0.33,0.665,0.772,0.87,1.34c0.21,0.545,0.305,1.22,0.305,2.041v0.562h-6.286
|
||||||
|
c0.046,0.918,0.302,1.62,0.796,2.111c0.492,0.497,1.129,0.751,1.896,0.751c0.545,0,0.988-0.113,1.364-0.324
|
||||||
|
c0.375-0.237,0.659-0.562,0.826-1.036l1.285,0.372c-0.216,0.664-0.607,1.172-1.215,1.583
|
||||||
|
C237.5,241.833,236.751,242.044,235.857,242.044L235.857,242.044z M233.166,236.838h4.952c0-0.734-0.21-1.334-0.664-1.831
|
||||||
|
c-0.419-0.47-1.007-0.729-1.704-0.729c-0.521,0-0.967,0.113-1.315,0.351c-0.375,0.238-0.686,0.567-0.891,0.939
|
||||||
|
C233.333,235.969,233.211,236.395,233.166,236.838L233.166,236.838z M242.83,236.648v5.206h-1.337v-8.651h1.293v1.361h0.089
|
||||||
|
c0.213-0.454,0.516-0.805,0.939-1.086c0.397-0.265,0.945-0.395,1.596-0.395c0.87,0,1.596,0.275,2.136,0.816
|
||||||
|
c0.535,0.518,0.793,1.365,0.793,2.462v5.492h-1.333v-5.4c0-0.702-0.162-1.215-0.516-1.587c-0.354-0.4-0.839-0.589-1.453-0.589
|
||||||
|
c-0.634,0-1.177,0.21-1.593,0.637C243.013,235.314,242.83,235.903,242.83,236.648L242.83,236.648z M254.303,242.044
|
||||||
|
c-0.821,0-1.526-0.189-2.117-0.578c-0.556-0.384-1.023-0.896-1.334-1.577c-0.327-0.68-0.467-1.452-0.467-2.301
|
||||||
|
c0-0.891,0.14-1.663,0.467-2.349c0.333-0.675,0.802-1.199,1.383-1.566c0.588-0.41,1.269-0.589,2.049-0.589
|
||||||
|
c0.607,0,1.14,0.119,1.636,0.352c0.492,0.205,0.889,0.535,1.199,0.935c0.324,0.399,0.519,0.896,0.581,1.437h-1.333
|
||||||
|
c-0.089-0.405-0.327-0.751-0.659-1.064c-0.346-0.291-0.815-0.464-1.404-0.464c-0.778,0-1.412,0.308-1.874,0.891
|
||||||
|
c-0.494,0.583-0.729,1.388-0.729,2.371c0,0.982,0.235,1.798,0.708,2.392c0.464,0.616,1.096,0.913,1.896,0.913
|
||||||
|
c0.513,0,0.964-0.135,1.336-0.394c0.378-0.287,0.613-0.638,0.727-1.129h1.333c-0.062,0.518-0.256,0.988-0.556,1.388
|
||||||
|
c-0.283,0.421-0.688,0.756-1.15,0.978C255.499,241.925,254.935,242.044,254.303,242.044L254.303,242.044z M259.652,241.854v-8.651
|
||||||
|
h1.312v8.651H259.652z M260.33,231.767c-0.281,0-0.494-0.092-0.678-0.28c-0.184-0.162-0.284-0.379-0.284-0.633
|
||||||
|
c0-0.253,0.1-0.469,0.284-0.637c0.184-0.184,0.397-0.248,0.678-0.248c0.256,0,0.472,0.064,0.664,0.248
|
||||||
|
c0.184,0.168,0.275,0.384,0.275,0.637c0,0.254-0.092,0.471-0.275,0.633C260.802,231.675,260.586,231.767,260.33,231.767
|
||||||
|
L260.33,231.767z M266.923,242.044c-0.775,0-1.453-0.189-2.039-0.557c-0.591-0.389-1.059-0.896-1.385-1.556
|
||||||
|
c-0.332-0.675-0.492-1.457-0.492-2.344c0-0.912,0.16-1.685,0.492-2.37c0.327-0.681,0.794-1.199,1.385-1.577
|
||||||
|
c0.586-0.378,1.264-0.557,2.039-0.557c0.772,0,1.477,0.179,2.065,0.557c0.589,0.378,1.029,0.896,1.364,1.577
|
||||||
|
c0.324,0.686,0.491,1.458,0.491,2.37c0,0.887-0.167,1.669-0.491,2.344c-0.335,0.659-0.775,1.167-1.364,1.556
|
||||||
|
C268.4,241.854,267.695,242.044,266.923,242.044L266.923,242.044z M266.923,240.845c0.588,0,1.077-0.156,1.477-0.438
|
||||||
|
c0.375-0.313,0.651-0.729,0.848-1.226c0.187-0.491,0.284-1.026,0.284-1.594c0-0.588-0.097-1.134-0.284-1.619
|
||||||
|
c-0.197-0.519-0.473-0.913-0.848-1.227c-0.4-0.291-0.889-0.464-1.477-0.464c-0.586,0-1.075,0.173-1.453,0.464
|
||||||
|
c-0.408,0.313-0.683,0.708-0.875,1.227c-0.187,0.485-0.256,1.031-0.256,1.619c0,0.567,0.07,1.103,0.256,1.594
|
||||||
|
c0.192,0.497,0.467,0.912,0.875,1.226C265.849,240.688,266.337,240.845,266.923,240.845L266.923,240.845z M266.313,232.031
|
||||||
|
l1.382-2.635h1.553l-1.763,2.635H266.313z M274.222,236.648v5.206h-1.342v-8.651h1.288v1.361h0.122
|
||||||
|
c0.189-0.454,0.491-0.805,0.918-1.086c0.424-0.265,0.937-0.395,1.596-0.395c0.889,0,1.593,0.275,2.128,0.816
|
||||||
|
c0.542,0.518,0.805,1.365,0.805,2.462v5.492h-1.318v-5.4c0-0.702-0.192-1.215-0.54-1.587c-0.356-0.4-0.848-0.589-1.453-0.589
|
||||||
|
c-0.629,0-1.172,0.21-1.569,0.637C274.427,235.314,274.222,235.903,274.222,236.648L274.222,236.648z M287.45,245.214
|
||||||
|
c-0.208,0-0.416,0-0.605-0.049c-0.17-0.021-0.283-0.07-0.359-0.092l0.329-1.177c0.492,0.118,0.894,0.118,1.223-0.027
|
||||||
|
c0.327-0.113,0.608-0.508,0.84-1.145l0.284-0.821l-3.191-8.7h1.431l2.398,6.891h0.089l2.4-6.891h1.423l-3.702,10.066
|
||||||
|
C289.51,244.576,288.671,245.214,287.45,245.214L287.45,245.214z M303.8,233.203v1.123h-1.917v7.528h-1.342v-7.528h-1.401v-1.123
|
||||||
|
h1.401v-1.199c0-0.491,0.122-0.912,0.351-1.242c0.238-0.329,0.516-0.566,0.894-0.729c0.375-0.189,0.775-0.265,1.196-0.265
|
||||||
|
c0.33,0,0.586,0.032,0.797,0.075c0.216,0.076,0.356,0.125,0.467,0.162l-0.392,1.156c-0.076-0.032-0.173-0.07-0.287-0.098
|
||||||
|
c-0.119-0.021-0.256-0.049-0.472-0.049c-0.416,0-0.748,0.092-0.935,0.33c-0.189,0.216-0.278,0.54-0.278,0.988v0.869H303.8z
|
||||||
|
M308.854,242.044c-0.802,0-1.488-0.189-2.071-0.557c-0.589-0.389-1.053-0.896-1.388-1.556c-0.324-0.675-0.486-1.457-0.486-2.344
|
||||||
|
c0-0.912,0.162-1.685,0.486-2.37c0.335-0.681,0.799-1.199,1.388-1.577c0.583-0.378,1.269-0.557,2.071-0.557
|
||||||
|
c0.767,0,1.445,0.179,2.031,0.557s1.056,0.896,1.385,1.577c0.334,0.686,0.497,1.458,0.497,2.37c0,0.887-0.162,1.669-0.497,2.344
|
||||||
|
c-0.33,0.659-0.799,1.167-1.385,1.556C310.299,241.854,309.622,242.044,308.854,242.044L308.854,242.044z M308.854,240.845
|
||||||
|
c0.575,0,1.072-0.156,1.445-0.438c0.378-0.313,0.653-0.729,0.842-1.226c0.194-0.491,0.286-1.026,0.286-1.594
|
||||||
|
c0-0.588-0.092-1.134-0.286-1.619c-0.189-0.519-0.464-0.913-0.842-1.227c-0.373-0.291-0.87-0.464-1.445-0.464
|
||||||
|
c-0.624,0-1.11,0.173-1.488,0.464c-0.37,0.313-0.659,0.708-0.84,1.227c-0.194,0.485-0.283,1.031-0.283,1.619
|
||||||
|
c0,0.567,0.089,1.103,0.283,1.594c0.181,0.497,0.47,0.912,0.84,1.226C307.745,240.688,308.231,240.845,308.854,240.845
|
||||||
|
L308.854,240.845z M314.782,241.854v-8.651h1.285v1.307h0.1c0.165-0.416,0.443-0.767,0.867-1.053
|
||||||
|
c0.396-0.254,0.864-0.395,1.385-0.395c0.095,0,0.211,0,0.375,0c0.135,0.021,0.262,0.021,0.33,0.021v1.367
|
||||||
|
c-0.049-0.033-0.143-0.033-0.305-0.06c-0.167-0.021-0.33-0.044-0.516-0.044c-0.632,0-1.15,0.189-1.55,0.589
|
||||||
|
c-0.421,0.378-0.634,0.87-0.634,1.458v5.46H314.782z M320.689,241.854v-8.651h1.299v1.361h0.113c0.195-0.471,0.473-0.821,0.87-1.086
|
||||||
|
s0.891-0.395,1.445-0.395c0.569,0,1.063,0.13,1.439,0.395s0.678,0.615,0.891,1.086h0.1c0.208-0.454,0.532-0.821,0.977-1.086
|
||||||
|
c0.448-0.265,0.988-0.395,1.604-0.395c0.767,0,1.404,0.249,1.896,0.734c0.494,0.481,0.746,1.237,0.746,2.242v5.794h-1.334v-5.794
|
||||||
|
c0-0.633-0.165-1.102-0.519-1.361c-0.351-0.281-0.767-0.421-1.239-0.421c-0.61,0-1.083,0.188-1.41,0.562
|
||||||
|
c-0.33,0.356-0.513,0.826-0.513,1.383v5.632h-1.336v-5.93c0-0.497-0.165-0.896-0.491-1.183c-0.308-0.291-0.735-0.464-1.223-0.464
|
||||||
|
c-0.356,0-0.681,0.092-0.988,0.286c-0.278,0.178-0.538,0.442-0.702,0.75c-0.189,0.33-0.286,0.702-0.286,1.14v5.4H320.689z
|
||||||
|
M337.044,242.065c-0.537,0-1.031-0.119-1.474-0.335c-0.454-0.173-0.821-0.48-1.083-0.886c-0.259-0.394-0.396-0.886-0.396-1.452
|
||||||
|
c0-0.492,0.121-0.887,0.308-1.193c0.184-0.309,0.443-0.567,0.772-0.729c0.332-0.189,0.699-0.303,1.104-0.399
|
||||||
|
c0.37-0.092,0.77-0.168,1.194-0.211c0.513-0.064,0.942-0.119,1.271-0.162c0.321-0.021,0.583-0.098,0.721-0.194
|
||||||
|
c0.165-0.07,0.235-0.227,0.235-0.442v-0.044c0-0.566-0.165-0.988-0.47-1.29c-0.284-0.309-0.751-0.476-1.364-0.476
|
||||||
|
c-0.629,0-1.129,0.141-1.499,0.427c-0.351,0.281-0.602,0.588-0.748,0.891l-1.272-0.442c0.238-0.54,0.516-0.945,0.918-1.247
|
||||||
|
c0.378-0.281,0.772-0.486,1.223-0.616c0.464-0.108,0.886-0.179,1.331-0.179c0.286,0,0.594,0.038,0.967,0.092
|
||||||
|
c0.351,0.065,0.702,0.217,1.05,0.422c0.332,0.194,0.616,0.496,0.851,0.912c0.232,0.405,0.329,0.962,0.329,1.643v5.702h-1.317v-1.166
|
||||||
|
h-0.07c-0.094,0.178-0.229,0.394-0.448,0.604c-0.21,0.216-0.489,0.395-0.842,0.54C337.981,241.995,337.561,242.065,337.044,242.065
|
||||||
|
L337.044,242.065z M337.255,240.866c0.518,0,0.961-0.092,1.339-0.297c0.348-0.216,0.632-0.476,0.823-0.799
|
||||||
|
c0.187-0.335,0.278-0.687,0.278-1.037v-1.215c-0.07,0.069-0.189,0.113-0.373,0.161c-0.189,0.065-0.405,0.119-0.659,0.168
|
||||||
|
c-0.232,0.016-0.472,0.064-0.705,0.086c-0.235,0.027-0.421,0.049-0.562,0.076c-0.354,0.043-0.686,0.113-0.958,0.211
|
||||||
|
c-0.313,0.097-0.548,0.254-0.732,0.447c-0.184,0.217-0.281,0.471-0.281,0.795c0,0.469,0.162,0.82,0.51,1.058
|
||||||
|
C336.294,240.753,336.712,240.866,337.255,240.866L337.255,240.866z M346.971,242.044c-0.799,0-1.507-0.189-2.084-0.578
|
||||||
|
c-0.6-0.384-1.04-0.896-1.367-1.577c-0.302-0.68-0.467-1.452-0.467-2.301c0-0.891,0.165-1.663,0.491-2.349
|
||||||
|
c0.332-0.675,0.775-1.199,1.361-1.566c0.589-0.41,1.267-0.589,2.039-0.589c0.613,0,1.172,0.119,1.666,0.352
|
||||||
|
c0.473,0.205,0.873,0.535,1.194,0.935c0.308,0.399,0.5,0.896,0.591,1.437h-1.337c-0.097-0.405-0.324-0.751-0.678-1.064
|
||||||
|
c-0.348-0.291-0.823-0.464-1.409-0.464c-0.772,0-1.413,0.308-1.879,0.891c-0.475,0.583-0.705,1.388-0.705,2.371
|
||||||
|
c0,0.982,0.23,1.798,0.705,2.392c0.467,0.616,1.107,0.913,1.879,0.913c0.516,0,0.958-0.135,1.333-0.394
|
||||||
|
c0.378-0.287,0.635-0.638,0.753-1.129h1.337c-0.092,0.518-0.284,0.988-0.564,1.388c-0.302,0.421-0.678,0.756-1.169,0.978
|
||||||
|
C348.161,241.925,347.626,242.044,346.971,242.044L346.971,242.044z M352.317,241.854v-8.651h1.336v8.651H352.317z M352.995,231.767
|
||||||
|
c-0.254,0-0.486-0.092-0.678-0.28c-0.165-0.162-0.262-0.379-0.262-0.633c0-0.253,0.097-0.469,0.262-0.637
|
||||||
|
c0.192-0.184,0.424-0.248,0.678-0.248c0.259,0,0.5,0.064,0.659,0.248c0.195,0.168,0.287,0.384,0.287,0.637
|
||||||
|
c0,0.254-0.092,0.471-0.287,0.633C353.494,231.675,353.254,231.767,352.995,231.767L352.995,231.767z M359.613,242.044
|
||||||
|
c-0.799,0-1.482-0.189-2.06-0.557c-0.589-0.389-1.059-0.896-1.388-1.556c-0.329-0.675-0.491-1.457-0.491-2.344
|
||||||
|
c0-0.912,0.162-1.685,0.491-2.37c0.33-0.681,0.799-1.199,1.388-1.577c0.578-0.378,1.261-0.557,2.06-0.557
|
||||||
|
c0.778,0,1.453,0.179,2.042,0.557c0.591,0.378,1.056,0.896,1.38,1.577c0.334,0.686,0.497,1.458,0.497,2.37
|
||||||
|
c0,0.887-0.162,1.669-0.497,2.344c-0.324,0.659-0.789,1.167-1.38,1.556C361.065,241.854,360.391,242.044,359.613,242.044
|
||||||
|
L359.613,242.044z M359.613,240.845c0.591,0,1.08-0.156,1.453-0.438c0.405-0.313,0.681-0.729,0.845-1.226
|
||||||
|
c0.192-0.491,0.284-1.026,0.284-1.594c0-0.588-0.092-1.134-0.284-1.619c-0.165-0.519-0.44-0.913-0.845-1.227
|
||||||
|
c-0.373-0.291-0.861-0.464-1.453-0.464c-0.607,0-1.077,0.173-1.482,0.464c-0.37,0.313-0.651,0.708-0.837,1.227
|
||||||
|
c-0.189,0.485-0.281,1.031-0.281,1.619c0,0.567,0.092,1.103,0.281,1.594c0.186,0.497,0.467,0.912,0.837,1.226
|
||||||
|
C358.536,240.688,359.005,240.845,359.613,240.845L359.613,240.845z M359.005,232.031l1.385-2.635h1.544l-1.758,2.635H359.005z
|
||||||
|
M366.887,236.648v5.206h-1.342v-8.651h1.293v1.361h0.119c0.213-0.454,0.513-0.805,0.937-1.086c0.399-0.265,0.937-0.395,1.596-0.395
|
||||||
|
c0.869,0,1.571,0.275,2.114,0.816c0.535,0.518,0.818,1.365,0.818,2.462v5.492h-1.339v-5.4c0-0.702-0.157-1.215-0.535-1.587
|
||||||
|
c-0.351-0.4-0.827-0.589-1.456-0.589c-0.637,0-1.147,0.21-1.577,0.637C367.101,235.314,366.887,235.903,366.887,236.648
|
||||||
|
L366.887,236.648z M379.322,245.116v-11.913h1.291v1.383h0.162c0.094-0.168,0.208-0.356,0.402-0.589
|
||||||
|
c0.156-0.237,0.418-0.442,0.745-0.638c0.33-0.184,0.775-0.275,1.339-0.275c0.731,0,1.385,0.179,1.922,0.557
|
||||||
|
c0.567,0.356,1.007,0.869,1.313,1.556c0.302,0.659,0.472,1.452,0.472,2.37c0,0.908-0.17,1.713-0.472,2.365
|
||||||
|
c-0.305,0.687-0.746,1.199-1.288,1.556c-0.562,0.367-1.199,0.557-1.917,0.557c-0.57,0-0.991-0.098-1.348-0.281
|
||||||
|
c-0.324-0.188-0.583-0.399-0.767-0.632c-0.165-0.232-0.308-0.442-0.402-0.61h-0.116v4.596H379.322z M380.628,237.54
|
||||||
|
c0,0.966,0.219,1.761,0.643,2.392c0.416,0.616,1.029,0.913,1.85,0.913c0.535,0,1.016-0.135,1.391-0.438
|
||||||
|
c0.37-0.286,0.651-0.686,0.837-1.198c0.189-0.497,0.287-1.054,0.287-1.669c0-0.616-0.097-1.178-0.287-1.669
|
||||||
|
c-0.186-0.491-0.467-0.864-0.815-1.145c-0.396-0.309-0.851-0.448-1.412-0.448c-0.821,0-1.434,0.308-1.85,0.891
|
||||||
|
C380.847,235.785,380.628,236.557,380.628,237.54L380.628,237.54z M391.542,242.065c-0.542,0-1.061-0.119-1.504-0.335
|
||||||
|
c-0.445-0.173-0.799-0.48-1.056-0.886c-0.259-0.394-0.397-0.886-0.397-1.452c0-0.492,0.097-0.887,0.305-1.193
|
||||||
|
c0.186-0.309,0.445-0.567,0.77-0.729c0.33-0.189,0.678-0.303,1.088-0.399c0.4-0.092,0.794-0.168,1.191-0.211
|
||||||
|
c0.54-0.064,0.961-0.119,1.288-0.162c0.33-0.021,0.57-0.098,0.732-0.194c0.143-0.07,0.208-0.227,0.208-0.442v-0.044
|
||||||
|
c0-0.566-0.135-0.988-0.442-1.29c-0.308-0.309-0.751-0.476-1.361-0.476c-0.629,0-1.155,0.141-1.504,0.427
|
||||||
|
c-0.351,0.281-0.61,0.588-0.748,0.891l-1.269-0.442c0.213-0.54,0.516-0.945,0.891-1.247c0.378-0.281,0.802-0.486,1.25-0.616
|
||||||
|
c0.437-0.108,0.888-0.179,1.328-0.179c0.289,0,0.589,0.038,0.964,0.092c0.356,0.065,0.712,0.217,1.061,0.422
|
||||||
|
c0.324,0.194,0.602,0.496,0.84,0.912c0.216,0.405,0.332,0.962,0.332,1.643v5.702h-1.342v-1.166h-0.04
|
||||||
|
c-0.095,0.178-0.265,0.394-0.473,0.604c-0.208,0.216-0.489,0.395-0.824,0.54C392.479,241.995,392.059,242.065,391.542,242.065
|
||||||
|
L391.542,242.065z M391.756,240.866c0.513,0,0.956-0.092,1.334-0.297c0.356-0.216,0.635-0.476,0.824-0.799
|
||||||
|
c0.189-0.335,0.253-0.687,0.253-1.037v-1.215c-0.04,0.069-0.154,0.113-0.345,0.161c-0.189,0.065-0.427,0.119-0.656,0.168
|
||||||
|
c-0.265,0.016-0.494,0.064-0.732,0.086c-0.216,0.027-0.397,0.049-0.562,0.076c-0.33,0.043-0.664,0.113-0.958,0.211
|
||||||
|
c-0.286,0.097-0.551,0.254-0.732,0.447c-0.162,0.217-0.254,0.471-0.254,0.795c0,0.469,0.162,0.82,0.511,1.058
|
||||||
|
C390.792,240.753,391.208,240.866,391.756,240.866L391.756,240.866z M397.945,241.854v-8.651h1.296v1.307h0.086
|
||||||
|
c0.148-0.416,0.424-0.767,0.848-1.053c0.424-0.254,0.891-0.395,1.404-0.395c0.243,0,0.448,0.038,0.637,0.092
|
||||||
|
c0.17,0.049,0.33,0.108,0.47,0.238l-0.448,1.117c-0.089-0.043-0.211-0.092-0.33-0.118c-0.138-0.021-0.278-0.044-0.438-0.044
|
||||||
|
c-0.64,0-1.158,0.189-1.577,0.589c-0.395,0.378-0.608,0.87-0.608,1.458v5.46H397.945z M406.58,242.065
|
||||||
|
c-0.538,0-1.029-0.119-1.475-0.335c-0.451-0.173-0.826-0.48-1.08-0.886c-0.262-0.394-0.402-0.886-0.402-1.452
|
||||||
|
c0-0.492,0.119-0.887,0.305-1.193c0.192-0.309,0.448-0.567,0.772-0.729c0.33-0.189,0.708-0.303,1.102-0.399
|
||||||
|
c0.378-0.092,0.802-0.168,1.202-0.211c0.51-0.064,0.937-0.119,1.267-0.162c0.329-0.021,0.583-0.098,0.726-0.194
|
||||||
|
c0.165-0.07,0.235-0.227,0.235-0.442v-0.044c0-0.566-0.162-0.988-0.47-1.29c-0.281-0.309-0.753-0.476-1.361-0.476
|
||||||
|
c-0.637,0-1.124,0.141-1.501,0.427c-0.348,0.281-0.61,0.588-0.753,0.891l-1.266-0.442c0.24-0.54,0.521-0.945,0.916-1.247
|
||||||
|
c0.373-0.281,0.777-0.486,1.223-0.616c0.47-0.108,0.888-0.179,1.339-0.179c0.278,0,0.613,0.038,0.967,0.092
|
||||||
|
c0.349,0.065,0.694,0.217,1.048,0.422c0.327,0.194,0.61,0.496,0.851,0.912c0.229,0.405,0.321,0.962,0.321,1.643v5.702h-1.313v-1.166
|
||||||
|
h-0.07c-0.092,0.178-0.232,0.394-0.446,0.604c-0.213,0.216-0.494,0.395-0.845,0.54C407.514,241.995,407.099,242.065,406.58,242.065
|
||||||
|
L406.58,242.065z M406.791,240.866c0.519,0,0.964-0.092,1.337-0.297c0.354-0.216,0.634-0.476,0.826-0.799
|
||||||
|
c0.186-0.335,0.278-0.687,0.278-1.037v-1.215c-0.07,0.069-0.189,0.113-0.378,0.161c-0.181,0.065-0.397,0.119-0.651,0.168
|
||||||
|
c-0.232,0.016-0.475,0.064-0.705,0.086c-0.235,0.027-0.424,0.049-0.564,0.076c-0.354,0.043-0.678,0.113-0.964,0.211
|
||||||
|
c-0.302,0.097-0.535,0.254-0.726,0.447c-0.192,0.217-0.281,0.471-0.281,0.795c0,0.469,0.165,0.82,0.513,1.058
|
||||||
|
C405.827,240.753,406.256,240.866,406.791,240.866L406.791,240.866z M418.809,230.309v11.546h-1.345v-11.546H418.809z
|
||||||
|
M423.777,242.065c-0.54,0-1.061-0.119-1.506-0.335c-0.438-0.173-0.792-0.48-1.056-0.886c-0.253-0.394-0.394-0.886-0.394-1.452
|
||||||
|
c0-0.492,0.097-0.887,0.308-1.193c0.184-0.309,0.44-0.567,0.77-0.729c0.329-0.189,0.683-0.303,1.077-0.399
|
||||||
|
c0.399-0.092,0.802-0.168,1.196-0.211c0.545-0.064,0.961-0.119,1.296-0.162c0.324-0.021,0.559-0.098,0.727-0.194
|
||||||
|
c0.146-0.07,0.213-0.227,0.213-0.442v-0.044c0-0.566-0.146-0.988-0.451-1.29c-0.294-0.309-0.748-0.476-1.358-0.476
|
||||||
|
c-0.635,0-1.145,0.141-1.499,0.427c-0.383,0.281-0.613,0.588-0.777,0.891l-1.24-0.442c0.208-0.54,0.513-0.945,0.886-1.247
|
||||||
|
c0.378-0.281,0.802-0.486,1.242-0.616c0.451-0.108,0.896-0.179,1.342-0.179c0.281,0,0.58,0.038,0.958,0.092
|
||||||
|
c0.348,0.065,0.705,0.217,1.058,0.422c0.33,0.194,0.613,0.496,0.845,0.912c0.206,0.405,0.321,0.962,0.321,1.643v5.702h-1.328v-1.166
|
||||||
|
h-0.049c-0.097,0.178-0.262,0.394-0.47,0.604c-0.208,0.216-0.497,0.395-0.821,0.54C424.717,241.995,424.271,242.065,423.777,242.065
|
||||||
|
L423.777,242.065z M423.988,240.866c0.516,0,0.961-0.092,1.339-0.297c0.354-0.216,0.629-0.476,0.821-0.799
|
||||||
|
c0.165-0.335,0.259-0.687,0.259-1.037v-1.215c-0.049,0.069-0.165,0.113-0.356,0.161c-0.192,0.065-0.413,0.119-0.659,0.168
|
||||||
|
c-0.259,0.016-0.486,0.064-0.721,0.086c-0.24,0.027-0.429,0.049-0.564,0.076c-0.33,0.043-0.653,0.113-0.961,0.211
|
||||||
|
c-0.313,0.097-0.537,0.254-0.724,0.447c-0.165,0.217-0.267,0.471-0.267,0.795c0,0.469,0.167,0.82,0.521,1.058
|
||||||
|
C423.002,240.753,423.454,240.866,423.988,240.866L423.988,240.866z M434.641,241.854v-8.651h1.336v8.651H434.641z M435.321,231.767
|
||||||
|
c-0.259,0-0.47-0.092-0.656-0.28c-0.194-0.162-0.286-0.379-0.286-0.633c0-0.253,0.092-0.469,0.286-0.637
|
||||||
|
c0.187-0.184,0.397-0.248,0.656-0.248c0.256,0,0.492,0.064,0.678,0.248c0.192,0.168,0.284,0.384,0.284,0.637
|
||||||
|
c0,0.254-0.092,0.471-0.284,0.633C435.813,231.675,435.577,231.767,435.321,231.767L435.321,231.767z M439.754,236.648v5.206h-1.342
|
||||||
|
v-8.651h1.291v1.361h0.122c0.187-0.454,0.492-0.805,0.916-1.086c0.421-0.265,0.937-0.395,1.593-0.395
|
||||||
|
c0.896,0,1.596,0.275,2.136,0.816c0.535,0.518,0.799,1.365,0.799,2.462v5.492h-1.313v-5.4c0-0.702-0.192-1.215-0.54-1.587
|
||||||
|
c-0.356-0.4-0.851-0.589-1.461-0.589c-0.634,0-1.164,0.21-1.571,0.637C439.965,235.314,439.754,235.903,439.754,236.648
|
||||||
|
L439.754,236.648z M451.228,242.044c-0.826,0-1.506-0.189-2.087-0.578c-0.589-0.384-1.062-0.896-1.364-1.577
|
||||||
|
c-0.305-0.68-0.47-1.452-0.47-2.301c0-0.891,0.165-1.663,0.491-2.349c0.305-0.675,0.786-1.199,1.364-1.566
|
||||||
|
c0.586-0.41,1.264-0.589,2.039-0.589c0.613,0,1.15,0.119,1.642,0.352c0.492,0.205,0.891,0.535,1.199,0.935
|
||||||
|
c0.321,0.399,0.516,0.896,0.588,1.437h-1.32c-0.116-0.405-0.327-0.751-0.678-1.064c-0.348-0.291-0.818-0.464-1.404-0.464
|
||||||
|
c-0.772,0-1.412,0.308-1.874,0.891c-0.476,0.583-0.71,1.388-0.71,2.371c0,0.982,0.213,1.798,0.71,2.392
|
||||||
|
c0.461,0.616,1.072,0.913,1.874,0.913c0.516,0,0.958-0.135,1.329-0.394c0.378-0.287,0.616-0.638,0.753-1.129h1.32
|
||||||
|
c-0.073,0.518-0.267,0.988-0.54,1.388c-0.311,0.421-0.705,0.756-1.177,0.978C452.418,241.925,451.857,242.044,451.228,242.044
|
||||||
|
L451.228,242.044z M457.911,230.309v11.546h-1.336v-11.546H457.911z M465.792,238.317v-5.114h1.339v8.651h-1.339v-1.447h-0.089
|
||||||
|
c-0.192,0.411-0.521,0.794-0.945,1.102c-0.418,0.324-0.985,0.459-1.617,0.459c-0.815,0-1.499-0.253-2.02-0.794
|
||||||
|
c-0.513-0.539-0.794-1.36-0.794-2.462v-5.509h1.342v5.417c0,0.637,0.184,1.128,0.535,1.501c0.356,0.373,0.794,0.567,1.361,0.567
|
||||||
|
c0.327,0,0.656-0.07,1.013-0.26c0.345-0.167,0.632-0.427,0.869-0.777C465.682,239.301,465.792,238.857,465.792,238.317
|
||||||
|
L465.792,238.317z"/>
|
||||||
|
<path fill="#2A2A2B" d="M475.694,235.147l-1.202,0.324c-0.113-0.303-0.3-0.589-0.581-0.843c-0.289-0.259-0.699-0.399-1.291-0.399
|
||||||
|
c-0.519,0-0.961,0.118-1.313,0.378c-0.354,0.232-0.543,0.54-0.543,0.907c0,0.341,0.138,0.589,0.378,0.777
|
||||||
|
c0.24,0.211,0.604,0.356,1.128,0.503l1.285,0.297c1.526,0.378,2.298,1.177,2.298,2.344c0,0.523-0.14,0.945-0.418,1.339
|
||||||
|
c-0.281,0.399-0.686,0.713-1.202,0.913c-0.516,0.237-1.104,0.356-1.76,0.356c-0.891,0-1.615-0.189-2.204-0.578
|
||||||
|
c-0.586-0.384-0.934-0.945-1.099-1.674l1.266-0.329c0.235,0.944,0.916,1.403,2.015,1.403c0.615,0,1.104-0.135,1.479-0.394
|
||||||
|
c0.354-0.26,0.538-0.584,0.538-0.967c0-0.605-0.421-1-1.286-1.21l-1.437-0.335c-0.793-0.184-1.38-0.491-1.733-0.891
|
||||||
|
c-0.378-0.373-0.559-0.896-0.559-1.48c0-0.491,0.138-0.912,0.395-1.29c0.283-0.373,0.656-0.675,1.15-0.892
|
||||||
|
c0.472-0.205,1.012-0.324,1.62-0.324c0.861,0,1.55,0.179,2.014,0.557C475.132,234.019,475.483,234.51,475.694,235.147
|
||||||
|
L475.694,235.147z M477.854,241.854v-8.651h1.336v8.651H477.854z M478.535,231.767c-0.262,0-0.47-0.092-0.659-0.28
|
||||||
|
c-0.192-0.162-0.284-0.379-0.284-0.633c0-0.253,0.092-0.469,0.284-0.637c0.189-0.184,0.397-0.248,0.659-0.248
|
||||||
|
c0.254,0,0.492,0.064,0.681,0.248c0.186,0.168,0.286,0.384,0.286,0.637c0,0.254-0.1,0.471-0.286,0.633
|
||||||
|
C479.026,231.675,478.789,231.767,478.535,231.767L478.535,231.767z M485.147,242.044c-0.796,0-1.474-0.189-2.068-0.557
|
||||||
|
c-0.581-0.389-1.026-0.896-1.35-1.556c-0.335-0.675-0.505-1.457-0.505-2.344c0-0.912,0.17-1.685,0.505-2.37
|
||||||
|
c0.324-0.681,0.77-1.199,1.35-1.577c0.594-0.378,1.272-0.557,2.068-0.557c0.778,0,1.456,0.179,2.044,0.557
|
||||||
|
c0.608,0.378,1.056,0.896,1.38,1.577c0.327,0.686,0.494,1.458,0.494,2.37c0,0.887-0.167,1.669-0.494,2.344
|
||||||
|
c-0.324,0.659-0.772,1.167-1.38,1.556C486.603,241.854,485.925,242.044,485.147,242.044L485.147,242.044z M485.147,240.845
|
||||||
|
c0.589,0,1.083-0.156,1.456-0.438c0.399-0.313,0.678-0.729,0.867-1.226c0.165-0.491,0.262-1.026,0.262-1.594
|
||||||
|
c0-0.588-0.097-1.134-0.262-1.619c-0.189-0.519-0.467-0.913-0.867-1.227c-0.373-0.291-0.867-0.464-1.456-0.464
|
||||||
|
c-0.61,0-1.077,0.173-1.474,0.464c-0.381,0.313-0.662,0.708-0.848,1.227c-0.189,0.485-0.278,1.031-0.278,1.619
|
||||||
|
c0,0.567,0.089,1.103,0.278,1.594c0.187,0.497,0.467,0.912,0.848,1.226C484.07,240.688,484.537,240.845,485.147,240.845
|
||||||
|
L485.147,240.845z M484.537,232.031l1.388-2.635h1.544l-1.755,2.635H484.537z M492.425,236.648v5.206h-1.315v-8.651h1.288v1.361
|
||||||
|
h0.095c0.213-0.454,0.516-0.805,0.937-1.086c0.402-0.265,0.939-0.395,1.596-0.395c0.867,0,1.59,0.275,2.117,0.816
|
||||||
|
c0.535,0.518,0.818,1.365,0.818,2.462v5.492h-1.345v-5.4c0-0.702-0.162-1.215-0.508-1.587c-0.356-0.4-0.848-0.589-1.455-0.589
|
||||||
|
c-0.637,0-1.172,0.21-1.599,0.637C492.63,235.314,492.425,235.903,492.425,236.648L492.425,236.648z"/>
|
||||||
|
<path fill="#163F75" d="M250.517,155.119c3.775,0,3.982-3.133,3.982-6.216v-30.609c0-3.148-0.208-6.318-3.982-6.318
|
||||||
|
c-3.402,0-4.35,2.98-4.35,5.983v7.484c0,3.641-0.205,7.162-0.626,10.58c-0.429,3.424-1.059,6.27-1.971,8.543
|
||||||
|
c-1.499,3.872-3.402,5.768-5.751,5.768c-2.552,0-3.818-3.71-3.818-11.092c0-7.037,0.259-13.35,0.753-18.961
|
||||||
|
c0.235-2.273,0.354-4.077,0.354-5.417c0-1.971-1.085-2.959-3.243-2.959c-1.201,0-2,0.454-2.459,1.313
|
||||||
|
c-1.296,2.44-1.95,10.893-1.95,25.322c0,11.26,3.005,16.579,9.194,16.579c3.259,0,5.983-1.663,8.098-4.995
|
||||||
|
c0.721-1.103,1.142-1.686,1.261-1.686c0.094,0,0.159,0.238,0.159,0.745C246.167,152.192,247.115,155.119,250.517,155.119
|
||||||
|
L250.517,155.119z M287.159,155.119c1.169,0,1.971-0.427,2.443-1.285c1.24-2.484,1.896-10.903,1.896-25.307
|
||||||
|
c0-3.435-0.259-6.275-0.772-8.619c-1.177-5.352-3.964-8.003-8.395-8.003c-3.664,0-6.572,1.928-8.708,5.772
|
||||||
|
c-0.281,0.551-0.521,0.778-0.659,0.778c-0.138,0-0.216-0.205-0.216-0.616c0-3.958-1.426-5.935-4.334-5.935
|
||||||
|
c-1.55,0-2.625,0.523-3.194,1.464c-0.532,0.912-0.796,2.506-0.796,4.779v30.598c0,1.054,0.07,2.063,0.21,3.02
|
||||||
|
c0.335,2.209,1.599,3.289,3.78,3.289c1.855,0,3.122-0.896,3.797-2.663c0.356-0.95,0.537-2.058,0.537-3.348v-7.928
|
||||||
|
c0-8.182,0.875-14.382,2.582-18.681c1.555-3.823,3.478-5.746,5.805-5.746c1.677,0,2.86,1.647,3.394,4.839
|
||||||
|
c0.333,1.923,0.476,4.315,0.476,7.178c-0.021,6.054-0.284,12.064-0.826,18.063c-0.232,2.274-0.354,4.078-0.354,5.4
|
||||||
|
c0,1.988,1.11,2.949,3.292,2.949H287.159z M316.507,128.123c0,2.808-0.21,5.719-0.634,8.748c-0.397,3.003-1.059,5.557-1.874,7.599
|
||||||
|
c-1.526,3.823-3.429,5.724-5.725,5.724c-2.935,0.141-3.85-6.475-3.85-13.512c0-4.784,1.439-19.679,8.284-19.679
|
||||||
|
c1.855,0,3.027,0.47,3.47,1.404c0.237,0.476,0.329,1.01,0.329,1.577V128.123z M324.837,105.268c0-3.094-0.146-6.264-3.964-6.264
|
||||||
|
c-3.362,0-4.366,2.916-4.366,5.983v7.177c0,0.54-0.064,0.848-0.235,0.848c-0.186,0-0.473-0.118-0.842-0.308
|
||||||
|
c-1.056-0.54-2.185-0.799-3.381-0.799c-4.525,0-8.065,2.608-10.606,7.793c-2.392,4.931-3.585,11.373-3.585,19.284
|
||||||
|
c0,8.517,3.121,16.212,9.221,16.072c3.284,0,5.979-1.696,8.139-5.05c0.656-1.031,1.01-1.566,1.123-1.566
|
||||||
|
c0.103,0,0.167,0.206,0.167,0.605c0,3.072,1.004,6.011,4.366,6.011c3.818,0,3.964-3.176,3.964-6.309V105.268z M354.839,155.055
|
||||||
|
c3.851,0.064,3.918-3.197,3.988-6.309v-30.565c-0.07-3.035-0.138-6.324-3.988-6.275c-1.307,0-2.317,0.405-2.935,1.198
|
||||||
|
c-0.205,0.266-0.467,0.405-0.796,0.405c-0.229,0-0.583-0.097-0.932-0.329c-1.434-0.82-2.841-1.274-4.229-1.274
|
||||||
|
c-4.528,0-8.047,2.608-10.601,7.793c-2.39,4.931-3.588,11.373-3.588,19.284c0,8.517,3.119,16.212,9.245,16.072
|
||||||
|
c3.653,0,6.589-2.021,8.768-6.033c0.257-0.469,0.448-0.701,0.567-0.701c0.094,0,0.156,0.254,0.156,0.724
|
||||||
|
C350.494,152.116,351.507,155.055,354.839,155.055L354.839,155.055z M342.175,150.193c-2.911,0.141-3.855-6.475-3.855-13.512
|
||||||
|
c0-4.784,1.437-19.679,8.284-19.679c2.605,0,3.891,0.935,3.891,2.813v9.947c0,1.831-0.2,4.197-0.626,7.107
|
||||||
|
c-0.445,2.938-1.083,5.486-1.971,7.599C346.396,148.293,344.495,150.193,342.175,150.193L342.175,150.193z M379.405,154.979
|
||||||
|
c2.762,0,5.02-0.75,6.802-2.252c1.785-1.507,3.024-3.461,3.64-5.881c0.114-0.324,0.138-0.572,0.138-0.751
|
||||||
|
c0-1.128-0.827-1.804-1.807-1.804c-0.821,0-1.434,0.416-1.809,1.329c-1.191,2.954-2.765,4.434-4.736,4.434
|
||||||
|
c-2.563,0-4.458-1.853-5.754-5.558c-0.843-2.419-1.264-5.146-1.264-8.273c0-7.544,1.013-12.961,3.027-16.266
|
||||||
|
c1.107-1.83,2.328-2.749,3.683-2.749c1.785,0,2.954,1.199,3.567,3.575c0.332,1.404,0.519,3.208,0.519,5.39
|
||||||
|
c0,0.988-0.068,1.923-0.262,2.911c-0.064,0.442-0.094,1.145-0.094,2.014c0,1.453,0.17,2.814,1.906,2.814c1.993,0,3-2.744,3-8.188
|
||||||
|
c0-9.191-3.151-13.793-9.378-13.793c-3.265,0-6.081,1.221-8.408,3.662c-4.01,4.223-6.027,11.098-6.027,20.629
|
||||||
|
c0,3.791,0.47,6.999,1.361,9.607c0.985,2.847,2.533,5.114,4.623,6.832C374.035,154.212,376.465,154.979,379.405,154.979
|
||||||
|
L379.405,154.979z M401.434,109.47c1.566,0,2.724-0.562,3.464-1.69c0.502-0.756,0.761-1.637,0.761-2.7
|
||||||
|
c0-1.14-0.378-2.225-1.158-3.181c-0.748-0.988-1.761-1.485-3-1.485c-2.465,0-4.174,2.111-4.174,5.136
|
||||||
|
C397.327,108.179,398.69,109.47,401.434,109.47L401.434,109.47z M401.263,155.055c2.917,0,4.396-2.021,4.396-6.011v-30.863
|
||||||
|
c0-4.649-1.083-6.275-3.969-6.275c-2.881,0-4.363,2.095-4.363,5.989v30.852c0,1.054,0.067,2.063,0.216,3.02
|
||||||
|
C397.861,153.975,399.084,155.055,401.263,155.055L401.263,155.055z M429.821,149.843c-2.89,0-5.047-1.712-6.475-5.093
|
||||||
|
c-1.245-2.997-1.855-7.053-1.855-12.215c0-10.531,3.024-15.846,9.051-15.846c2.778,0,4.763,1.739,6.032,5.195
|
||||||
|
c0.958,2.668,1.428,6.017,1.428,10.083c0,4.903-0.61,8.965-1.801,12.221C434.792,147.937,432.653,149.843,429.821,149.843
|
||||||
|
L429.821,149.843z M427.542,154.979c5.582,0,9.737-2.203,12.386-6.583c2.301-3.926,3.451-9.288,3.451-16.099
|
||||||
|
c0-5.368-0.821-9.813-2.527-13.252c-2.328-4.736-5.986-7.14-11.009-7.14c-5.773,0-10.088,2.214-12.926,6.643
|
||||||
|
c-2.511,3.845-3.775,9.104-3.775,15.764c0,5.659,1.004,10.255,3.019,13.819C418.728,152.705,422.527,154.979,427.542,154.979
|
||||||
|
L427.542,154.979z M425.924,108.67c1.288,0,3.213-0.799,5.773-2.349c2.549-1.545,3.869-3.116,3.869-4.661
|
||||||
|
c0-1.339-0.659-1.998-1.974-1.998c-1.239,0-3.051,1.129-5.395,3.402c-2.322,2.23-3.47,3.732-3.47,4.574
|
||||||
|
C424.728,108.319,425.122,108.67,425.924,108.67L425.924,108.67z M472.821,155.119c1.19,0,1.968-0.427,2.441-1.285
|
||||||
|
c1.239-2.484,1.898-10.903,1.898-25.307c0-3.435-0.259-6.275-0.756-8.619c-1.188-5.352-3.98-8.003-8.419-8.003
|
||||||
|
c-3.662,0-6.545,1.928-8.703,5.772c-0.278,0.551-0.491,0.778-0.629,0.778c-0.143,0-0.213-0.205-0.213-0.616
|
||||||
|
c0-3.958-1.458-5.935-4.364-5.935c-1.525,0-2.608,0.523-3.188,1.464c-0.545,0.912-0.799,2.506-0.799,4.779v30.598
|
||||||
|
c0,1.054,0.097,2.063,0.232,3.02c0.308,2.209,1.574,3.289,3.756,3.289c1.879,0,3.116-0.896,3.829-2.663
|
||||||
|
c0.343-0.95,0.535-2.058,0.535-3.348v-7.928c0-8.182,0.842-14.382,2.56-18.681c1.566-3.823,3.473-5.746,5.79-5.746
|
||||||
|
c1.717,0,2.886,1.647,3.429,4.839c0.327,1.923,0.464,4.315,0.464,7.178c-0.046,6.054-0.278,12.064-0.84,18.063
|
||||||
|
c-0.235,2.274-0.332,4.078-0.332,5.4c0,1.988,1.085,2.949,3.281,2.949H472.821z"/>
|
||||||
|
<g>
|
||||||
|
<path fill="#163F75" d="M206.898,156.113c3.626,0,4.701-3.09,4.701-6.249v-28.687c0-1.08,0.634-1.625,1.812-1.625l7.788,0.092
|
||||||
|
c1.807,0.092,2.806-0.908,2.806-2.085c0-1.086-0.362-1.814-1.094-2.263c-0.632-0.362-1.537-0.546-2.892-0.546
|
||||||
|
c-2.079,0-4.158,0.184-6.237,0.184c-1.456,0-2.182-0.453-2.182-2.711v-5.427c0-1.998,0.91-2.992,2.725-2.992
|
||||||
|
c0.996,0,2.352,0,3.523,0.098c1.094,0,8.689,0,9.502,0c2.714,0,3.988-1.27,3.988-2.452c0-1.62-1.091-2.533-3.076-2.803
|
||||||
|
c-0.913-0.184-1.636-0.184-2.362-0.184h-9.224h-8.238c-2.986,0-4.79,1.183-5.519,3.629c-0.27,1.172-0.448,2.532-0.448,4.158v43.252
|
||||||
|
c0,1.091,0.086,2.176,0.27,3.17C203.099,154.936,204.46,156.113,206.898,156.113L206.898,156.113z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<defs>
|
||||||
|
<rect id="SVGID_23_" x="-476.587" y="-28.438" width="597.06" height="844.021"/>
|
||||||
|
</defs>
|
||||||
|
<clipPath id="SVGID_2_">
|
||||||
|
<use xlink:href="#SVGID_23_" overflow="visible"/>
|
||||||
|
</clipPath>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 40 KiB |
BIN
assets/fundacion/Versión Vertical_Isologotipo FS-26.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/inq/MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/inq/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
1
assets/inq/inq-color-svg.svg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
assets/re/.DS_Store
vendored
Normal file
BIN
assets/re/Paleta de colores RE-04.jpg
Normal file
|
After Width: | Height: | Size: 798 KiB |
1
assets/re/RE - SVG.svg
Normal file
|
After Width: | Height: | Size: 124 KiB |