9 Commits

88 changed files with 4047 additions and 526 deletions
+110 -286
View File
@@ -1,326 +1,150 @@
# GLM ID Card Generator
> Herramienta web que permite al equipo de RRHH generar carnets corporativos GLM listos para imprimir en formato CR80 o enviar digitalmente, con verificacion de autenticidad via QR conectado a BambooHR en tiempo real.
## ¿Qué hace este proyecto?
Sistema de generación de ID corporativas para el departamento de RRHH de GomezLee Marketing (GLM), que opera en 14 países. El sistema permite:
1. **Generar IDs** (frente y reverso) de empleados de forma individual o por lote (Excel/CSV).
2. **Editar fotos con IA**: usa Google Gemini para vestir al empleado con un uniforme Columbia, bordar el logo de GLM en el pecho derecho y reemplazar el fondo por blanco.
3. **Códigos QR**: cada ID incluye un QR único que redirige a `https://digitalcompass.agency/empleado?id=X` para verificar en tiempo real el estatus del empleado contra BambooHR.
4. **Exportación a PNG**: descarga los ID como PNG listos para impresión CR80 o envío por WhatsApp.
5. **Verificación pública**: cualquier persona puede escanear el QR y ver la foto, nombre, puesto, país y estatus del empleado.
### Arquitectura
- **Frontend Generator**: React + Vite (`glm-card-generator/`)
- **Frontend Verificación QR**: React + Vite (`glm-card-qr/`)
- **Workflow de IA**: n8n con Google Gemini para edición de fotos
- **Base de datos**: Supabase (PostgreSQL)
- **Almacenamiento**: Supabase Storage + Google Drive
- **Verificación**: BambooHR API
---
## Informacion General
## Instalación y ejecución
| Campo | Detalle |
|---|---|
| Proyecto | GLM ID Card Generator |
| Area | RRHH — 14 paises |
| Estado | En Progreso |
| Developer Principal | Eidan Then |
| IT Manager | Luis Matos |
| Fecha de Inicio | 2026-06-16 |
| Fecha de Cierre Estimada | 2026-07-28 |
| Ciclo Shape Up | Ciclo 2 — Semana 1 de 6 |
| Board de Ejecucion | https://pmit.digitalcompass.agency/boards/v8iyihdd7u2v |
| PRD del Proyecto | https://docs.google.com/document/d/1SZMEcx5BZGf6CtDk5ZEb08Nx1xMBeyVd9zoqMDFhHgI/edit?usp=sharing |
### Requisitos
---
- Node.js >= 18
- npm
## Objetivo
### Problema que resuelve
Hoy RRHH depende del area de diseno para generar cada carnet corporativo, lo que toma entre 1 y 3 dias habiles por colaborador. No existe estandar centralizado entre paises ni mecanismo para verificar la autenticidad o el estado activo de un carnet en campo.
### Solucion implementada
Un archivo HTML que RRHH abre en cualquier navegador. El operador selecciona el cliente de un desplegable precargado, ingresa los datos del colaborador, sube su foto y descarga dos PNG — frente y reverso — listos para imprimir en CR80 o enviar por WhatsApp. El reverso incluye un QR unico que al escanearse consulta BambooHR en tiempo real y devuelve el estado del colaborador. Para produccion masiva, el operador sube un CSV y el sistema genera todos los carnets en lote dentro de un ZIP.
### Usuarios y beneficiarios
- Operador: Equipo de RRHH en los 14 paises de GLM
- Verificador: Supervisores, clientes y auditores que escanean el QR en campo
- Beneficiario: Colaboradores que reciben su carnet impreso o digital
---
## Arquitectura
### Diagrama de flujo
```
MODO INDIVIDUAL
[RRHH abre glm_id_card.html] → [Selecciona cliente / sube logo] → [Ingresa datos + foto]
→ [Sistema genera QR unico con URL de verificacion] → [Vista previa en tiempo real]
→ [Descarga Frente PNG + Reverso PNG con QR]
MODO BATCH
[RRHH sube CSV] → [Sistema lee cada fila] → [Genera carnet completo por colaborador]
→ [Descarga ZIP con todos los PNG]
VERIFICACION QR
[Escaneo del QR en campo] → [verify.gomezlee.com/carnet/{ID}]
→ [Endpoint Cloud Run consulta BambooHR API]
→ [Devuelve pagina: Nombre, Puesto, Cliente, Estado (Activo / Inactivo / Terminado)]
```
### Stack tecnologico
| Componente | Tecnologia | Proposito |
|---|---|---|
| Frontend / Generador | HTML5 + CSS3 + JavaScript | Interfaz interactiva del generador — sin frameworks externos |
| Exportacion PNG | html2canvas (libreria JS) | Convierte el DOM del carnet a PNG de alta resolucion para impresion CR80 |
| Generacion de QR | QRCode.js (libreria JS) | Genera el QR unico por colaborador con la URL del endpoint de verificacion |
| Procesamiento batch | JavaScript + FileReader API | Lee el CSV y genera carnets en lote sin intervencion manual por fila |
| Endpoint de verificacion | Python + Google Cloud Run | Recibe el ID del QR, consulta BambooHR y devuelve pagina de estado del colaborador |
| Integracion BambooHR | BambooHR API REST | Fuente de verdad para nombre, puesto, pais, cliente y estado del colaborador |
| Version control | Gitea | Repositorio del generador y del endpoint |
### Integraciones externas
| Sistema | Tipo de integracion | Datos que fluyen |
|---|---|---|
| BambooHR | API REST | Nombre, puesto, pais, cliente asignado, estado del empleado (activo / inactivo / terminado) |
| Google Cloud Run | Endpoint HTTP publico | Recibe ID del colaborador desde el QR y devuelve pagina de verificacion |
| CSV local | Archivo de entrada | Nombre, puesto, pais, cliente, id_colaborador, ruta_foto (opcional) |
---
## Configuracion y Setup
### Prerequisitos
- [ ] Acceso a BambooHR API (API Key del subdominio GLM)
- [ ] Proyecto configurado en Google Cloud con Cloud Run habilitado
- [ ] Variable de entorno `BAMBOOHR_API_KEY` configurada en Cloud Run
- [ ] Variable de entorno `BAMBOOHR_SUBDOMAIN` configurada en Cloud Run
- [ ] Dominio `verify.gomezlee.com` apuntando al servicio de Cloud Run
- [ ] Lista de clientes precargados definida y aprobada por RRHH (nombre + logo PNG)
### Variables de entorno
| Variable | Descripcion | Donde se obtiene |
|---|---|---|
| `BAMBOOHR_API_KEY` | API Key de BambooHR | BambooHR → Admin → API Keys |
| `BAMBOOHR_SUBDOMAIN` | Subdominio de la cuenta GLM en BambooHR | URL de BambooHR: {subdominio}.bamboohr.com |
| `VERIFY_BASE_URL` | URL base del endpoint de verificacion | Ej: https://verify.gomezlee.com |
> Las credenciales nunca se commitean al repo. Todas las keys van en el vault de credenciales GLM o en los secrets de Google Cloud Run.
### Instalacion — Endpoint de verificacion
### 1. Clonar el repositorio
```bash
# Clonar el repo
git clone https://gitea.glm.com/glm-it/glm-id-card-generator
# Instalar dependencias del endpoint
cd endpoint/
pip install -r requirements.txt --break-system-packages
# Configurar variables de entorno locales para pruebas
cp .env.example .env
# Editar .env con las credenciales reales
# Correr el endpoint en local
python main.py
# Deploy a Google Cloud Run
gcloud run deploy glm-id-card-verify \
--source . \
--region us-central1 \
--allow-unauthenticated
git clone <repo-url>
cd GLM ID Card Generator
```
### Distribucion del generador HTML
El archivo `glm_id_card.html` se distribuye directamente a RRHH via email o carpeta compartida. No requiere instalacion. El operador lo abre en Chrome o Edge.
---
## Como funciona
### Flujo paso a paso — Modo individual
1. El operador abre `glm_id_card.html` en Chrome o Edge
2. Selecciona el cliente del desplegable precargado (o sube un logo nuevo)
3. Ingresa nombre completo, puesto, pais e ID del colaborador
4. Sube la foto del colaborador — aparece recortada en circulo en el frente
5. El sistema genera automaticamente el QR con la URL `verify.gomezlee.com/carnet/{ID}`
6. El operador visualiza la vista previa en tiempo real del frente y el reverso
7. Descarga el Frente PNG y el Reverso PNG por separado
8. Envia los PNG al colaborador o los imprime en formato CR80
### Flujo paso a paso — Modo batch
1. El operador prepara el CSV segun el formato requerido (ver seccion Formato CSV)
2. Sube el CSV al generador
3. El sistema procesa cada fila, genera el carnet completo y produce un ZIP
4. El ZIP contiene los archivos nombrados como `{nombre_colaborador}_frente.png` y `{nombre_colaborador}_reverso.png`
### Flujo de verificacion QR
1. Supervisor o cliente escanea el QR del reverso del carnet con cualquier telefono
2. El QR abre `verify.gomezlee.com/carnet/{ID_COLABORADOR}` en el navegador del telefono
3. El endpoint consulta BambooHR con el ID del colaborador
4. Se devuelve una pagina web con: nombre, puesto, cliente, pais y estado del colaborador
5. Si el colaborador esta activo: pagina verde con sus datos completos
6. Si el colaborador esta inactivo o terminado: pagina roja con mensaje "Carnet no vigente"
### Formato CSV para batch
| Columna | Requerida | Descripcion |
|---|---|---|
| `nombre_completo` | Si | Nombre completo del colaborador |
| `puesto` | Si | Puesto o cargo |
| `pais` | Si | Pais de operacion |
| `cliente` | Si | Nombre del cliente (debe coincidir exactamente con la lista precargada) |
| `id_colaborador` | Si | ID del colaborador en BambooHR |
| `ruta_foto` | No | URL publica de la foto. Si se omite, el circulo de foto queda vacio |
### Schedules y triggers
| Trigger | Descripcion |
|---|---|
| Manual — abre el HTML | El operador genera carnets cuando RRHH lo necesita |
| Escaneo del QR | El endpoint se dispara on-demand cada vez que alguien escanea un carnet en campo |
---
## Testing
### Como probar el sistema
### 2. Configurar variables de entorno
```bash
# Test unitario del endpoint
cd endpoint/
python -m pytest tests/
# Para el generador de IDs
cp glm-card-generator/.env.example glm-card-generator/.env
# Test de integracion — verificacion real contra BambooHR
python tests/test_bamboohr_integration.py
# Test manual del generador HTML
# 1. Abrir glm_id_card.html en Chrome
# 2. Llenar todos los campos con datos de prueba
# 3. Exportar PNG y verificar dimensiones: 1012 x 638 px a 96 dpi display (equivalente a 300 dpi en CR80)
# 4. Imprimir en hoja CR80 y verificar calidad
# 5. Escanear el QR generado y confirmar que abre el endpoint correctamente
# Para el visor QR
cp glm-card-qr/.env.example glm-card-qr/.env
```
### Casos de prueba minimos
Editar los archivos `.env` con los valores correspondientes.
| Caso | Input | Output esperado | Estado |
|---|---|---|---|
| Carnet individual completo | Todos los campos + foto + cliente seleccionado | PNG frente y reverso descargados correctamente | Pendiente |
| Carnet sin foto | Todos los campos sin foto | PNG generado con circulo vacio en frente | Pendiente |
| Batch CSV valido | CSV con 20 colaboradores | ZIP con 40 PNG (frente + reverso por colaborador) en menos de 2 minutos | Pendiente |
| QR colaborador activo | ID valido en BambooHR, estado activo | Pagina verde con datos completos del colaborador | Pendiente |
| QR colaborador inactivo | ID valido en BambooHR, estado inactivo | Pagina roja con mensaje "Carnet no vigente" | Pendiente |
| QR ID inexistente | ID que no existe en BambooHR | Mensaje de error claro, sin crash del endpoint | Pendiente |
| Cliente nuevo con logo manual | Logo subido manualmente sin seleccionar del desplegable | Logo aparece correctamente en el reverso del PNG | Pendiente |
| BambooHR API caida | Timeout del endpoint al consultar BambooHR | Mensaje de error legible, endpoint no crashea | Pendiente |
### 3. Instalar dependencias y ejecutar
---
## Errores conocidos y troubleshooting
| Error | Causa probable | Solucion |
|---|---|---|
| QR no abre la pagina de verificacion | Dominio verify.gomezlee.com no apunta a Cloud Run | Verificar DNS y configuracion del servicio en Google Cloud Run |
| PNG exportado se ve diferente al preview | html2canvas no soporta alguna propiedad CSS usada | Revisar consola del navegador, simplificar el estilo CSS del elemento afectado |
| Endpoint devuelve 401 | API Key de BambooHR vencida o incorrecta | Renovar la API Key en BambooHR y actualizar el secret en Google Cloud Run |
| Batch no genera todos los carnets | Fila del CSV con campo obligatorio vacio | Revisar el CSV — todas las columnas requeridas deben tener valor en cada fila |
| Logo del cliente no aparece en el PNG | Logo en formato no soportado o con CORS bloqueado | Usar PNG con fondo transparente, minimo 300px de ancho, hosteado en el mismo dominio o con CORS abierto |
| Endpoint lento al responder | BambooHR API con latencia alta | El endpoint tiene cache de 5 minutos por ID — si es la primera consulta puede tardar hasta 3 segundos |
---
## Monitoreo
- **Google Cloud Run Logs:** Revisar logs del servicio `glm-id-card-verify` en Google Cloud Console para errores del endpoint
- **BambooHR API:** Si el endpoint falla consistentemente, verificar el estado de la API de BambooHR y los limites de rate limit
- **Output esperado:** El endpoint debe responder en menos de 3 segundos. Si supera ese tiempo de forma sistematica, revisar la implementacion del cache.
---
## Estructura del repositorio
**Generador de IDs:**
```bash
cd glm-card-generator
npm install
npm run dev
```
/glm-id-card-generator
├── README.md <- Este archivo
├── glm_id_card.html <- Generador de carnets (distribuir a RRHH)
├── /assets
│ ├── /logos-clientes <- Logos PNG de clientes precargados (aprobados por RRHH)
│ └── glm-logo.png <- Logo GLM para el generador
├── /endpoint
│ ├── main.py <- Punto de entrada del servicio de verificacion
│ ├── requirements.txt <- Dependencias Python del endpoint
│ ├── .env.example <- Variables de entorno de ejemplo (sin valores reales)
│ ├── Dockerfile <- Para deploy en Google Cloud Run
│ └── /tests
│ ├── test_endpoint.py <- Tests unitarios del endpoint
│ └── test_bamboohr_integration.py <- Tests de integracion con BambooHR API
├── /batch
│ └── ejemplo_colaboradores.csv <- CSV de ejemplo con el formato requerido para batch
├── /docs
│ ├── guia-uso-rrhh.pdf <- Guia de uso para operadores de RRHH
│ └── architecture.png <- Diagrama de arquitectura del sistema
├── CHANGELOG.md <- Historial de cambios
└── DECISIONS.md <- Log de decisiones tecnicas
Abre en: `http://localhost:5173/empleado-id/`
**Visor QR:**
```bash
cd glm-card-qr
npm install
npm run dev
```
Abre en: `http://localhost:5173/empleado`
### 4. Build para producción
```bash
# Generador
cd glm-card-generator
npm run build
# El output está en glm-card-generator/dist/
# Visor QR
cd glm-card-qr
npm run build
# El output está en glm-card-qr/dist/
```
---
## CHANGELOG
## Variables de entorno
```
[2026-07-28] v1.0 — Launch inicial en produccion
### `glm-card-generator/.env.example`
```env
VITE_SUPABASE_ANON_KEY=J5JS7HG...
VITE_SUPABASE_URL=https://tudominio
VITE_WEBHOOK_TOKEN=6d4g56d4fgd...
VITE_N8N_WEBHOOK_URL=https://tudominio/webhook/carnet
```
---
### `glm-card-qr/.env.example`
## DECISIONS LOG
```env
VITE_SUPABASE_URL=https://tudominio
VITE_SUPABASE_ANON_KEY=J5JS7HG...
```
### DEC-001 — Generador como HTML local vs. app web hosteada
### Descripción de variables
- Fecha: 2026-06-16
- Contexto: Se evaluo si el generador debia ser una app web hosteada en Google Cloud o un archivo HTML distribuido localmente a RRHH.
- Opciones consideradas: App web hosteada (requiere autenticacion, servidor, mantenimiento) vs. HTML local (distribucion simple, sin dependencias de red para generar el carnet).
- Decision: HTML local distribuido a RRHH en Fase 1.
- Razon: El equipo de RRHH opera en 14 paises con distintos niveles de conectividad. El HTML local garantiza que el generador funciona sin internet. Solo el endpoint de verificacion QR requiere conexion.
### DEC-002 — Verificacion QR via BambooHR en tiempo real vs. base de datos propia
- Fecha: 2026-06-16
- Contexto: Se evaluo mantener una base de datos propia en Supabase con el estado de cada carnet vs. consultar BambooHR directamente al momento del escaneo.
- Opciones consideradas: Base de datos propia (requiere sincronizacion manual o automatica con BambooHR) vs. BambooHR como fuente de verdad (estado siempre actualizado, sin base de datos adicional).
- Decision: BambooHR como fuente de verdad con consulta en tiempo real.
- Razon: Elimina el riesgo de datos desincronizados. Cuando RRHH da de baja a un colaborador en BambooHR, el QR lo refleja de inmediato sin ningun paso adicional.
| Variable | Descripción |
|---|---|
| `VITE_SUPABASE_URL` | URL del proyecto Supabase |
| `VITE_SUPABASE_ANON_KEY` | Clave anónima de Supabase |
| `VITE_WEBHOOK_TOKEN` | Token de autenticación para el webhook de n8n |
| `VITE_N8N_WEBHOOK_URL` | URL del webhook en n8n (`/webhook/carnet`) |
---
## Contactos del proyecto
## Explicación del workflow de n8n
| Rol | Nombre | Contacto |
|---|---|---|
| Product Owner | Maximo Gomez | [email/WhatsApp] |
| IT Manager | Luis Matos | [email/WhatsApp] |
| Developer Principal | Eidan Then | [email/WhatsApp] |
**Archivo**: `n8n-workflow/GLM ID Card Generator.json`
---
El workflow **"GLM ID Card Generator"** se ejecuta cuando el frontend envía una foto de empleado al webhook `POST /webhook/carnet` autenticado con el token en el header.
## Definition of Done
### Flujo
Checklist antes de mover a Completado en Kan.bn:
1. **Webhook** — Recibe la solicitud POST con la foto del empleado (y opcionalmente un array de empleados para lote).
2. **IF: "Es un binario y employeeId?"** — Determina si es un envío individual (tiene `employeeId` y foto binaria) o por lote.
3. **IF: "Es un binario?"** — Verifica si el body contiene `empleados` (array de empleados en modo lote).
4. **Split Out** + **Loop Over Items** — En modo lote, separa el array y procesa cada empleado uno por uno.
5. **Download file** (Google Drive) — Descarga la foto del empleado desde Google Drive (según URL recibida).
6. **Descargar logo GLM** (HTTP Request) — Obtiene el logo de GLM desde Supabase Storage.
7. **Edit an image (Google Gemini)** — Edita la foto del empleado con IA:
- Modelo: `gemini-3-pro-image` o `gemini-3.1-flash-image`
- Viste a la persona con un uniforme Columbia (camisa de botones color `#2d3748`)
- Borda el logo de GLM en el pecho derecho
- Reemplaza el fondo por blanco sólido
8. **Extract from File** — Extrae el binario de la imagen editada.
9. **Guardar foto** (Google Drive) — Guarda la foto editada en la carpeta "Fotos Temporal Carnet Empleados".
10. **Hacer url publica** (Google Drive) — Comparte la imagen públicamente.
11. **Actualizar foto del empleado** (Supabase) — Actualiza el registro del empleado en la tabla `empleados_glm` con la `photo_url` y marca `photo_uploaded = FALSE`.
12. **Merge** — Combina los resultados.
13. **Respond to Webhook** — Devuelve la URL de la foto editada al frontend.
- [ ] Todos los criterios de exito cumplidos y verificados
- [ ] PNG exportado validado fisicamente en impresion CR80
- [ ] Modo batch probado con CSV de al menos 20 colaboradores
- [ ] QR escaneado y verificacion confirmada contra BambooHR en ambiente real
- [ ] Endpoint de verificacion desplegado en Google Cloud Run y accesible publicamente
- [ ] Generador probado en Chrome y Edge en al menos 2 paises
- [ ] Lista de clientes precargados cargada y validada con RRHH
- [ ] Codigo commiteado y pusheado a Gitea
- [ ] README completo y actualizado
- [ ] Variables de entorno documentadas en `.env.example`
- [ ] Prueba de usuario real con al menos un miembro del equipo RRHH
- [ ] Luis Matos valido el output tecnico
- [ ] Maximo aprobo el resultado final
### Credenciales usadas
---
_Documento mantenido por el equipo GLM IT · Ultima actualizacion: 2026-06-05_
| Tipo | Nombre |
|---|---|
| HTTP Header Auth | GLM ID Generator |
| Google Drive OAuth2 | Eidan - Google Drive account |
| Google Gemini API | Eidan - Google Gemini(PaLM) Api account |
| Supabase API | Sofia test - Supabase account |
@@ -0,0 +1,200 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 6 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 612 792 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/BaseFont /Helvetica-BoldOblique /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 612 792 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 612 792 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 612 792 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 20 0 R /MediaBox [ 0 0 612 792 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 21 0 R /MediaBox [ 0 0 612 792 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 22 0 R /MediaBox [ 0 0 612 792 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/PageMode /UseNone /Pages 15 0 R /Type /Catalog
>>
endobj
14 0 obj
<<
/Author (GomezLee Marketing) /CreationDate (D:20260715142442+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260715142442+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (Manual de Uso del sistema de generacion de carnets corporativos) /Title (Manual de Uso \204 GLM ID Card Generator) /Trapped /False
>>
endobj
15 0 obj
<<
/Count 7 /Kids [ 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R ] /Type /Pages
>>
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 777
>>
stream
GasJO?#SFN'Sc)P($C`S;s(92?)E_3Ca>rnfYu@01q4qQM5^fPe6ft#-QpR)'2k?.qt9"D]S?R)+19Fp!OsT:lR`:_TQgYj*!+"uiaVO8Z82n+-"`%FCm]3/O8+9Vo22^9(-:Z#O#S?!D%>uL?qfc3."4JPfglP"PhSQ2a#ch5&dAIbKc:.'7p!4qc^D-fS,dq3n7e,"M_tDnO(1_Ur-MZog$IcF7kjA:&0-1MAKdb@#Pi2SOR1;l*TS&-M?^$JT`tDH"s/(g%_b5VL<uVa(BWlJJHtAu"]XnmcH.9B9r;ng8YS4-1?QJpV]]\8WETP4H$Y'jO8coCn6BI6eQeIR[%DV(4dZ4.LJh>OlSUO9\7UM&_`3X]-'Cu4=?\:24BVYdU@BK"r;I/`0$a&::#`71+N]L#c_>X9s5J$E"l'jj,.Ojg'h<:Qo63>9R"0GhaV6(u?<J+41S2]hlEX@b@m5"p$D6h@Ja[!qq=q\VLi0P:qoQ7n%a?:(Pc@b9X<^cADIp;09'NHE_YR:"@$1cIO1=+JlMA&oqUP=#aS*D6mV:Ek'kg`fbgK7QE)SYj9&8T9?sDXR7N'lRq1Q=0V<-!m2cG]`RnJ%V8PB"dG'1E?5.3q"G0LpD?I#)$g=5Z,FrVI@m:1ohOT::t`6RBdUODKX8A-g'V%1$M\8V7=Xah#Gr<JX5a(emCm3:p(B+I1tWbUG4[St2[4Z7";1o]lr<P@^?rNs$K6BCZ3?.Z+i9%`-/</]iepDCR>%?"UEE7We<,8g@_X*[UL~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2240
>>
stream
Gau`Ua`?-*&A@B[\oL?h/fDL>j:m?GAL<Wm;3VS??Uhhm*XBL-"4196j3p..O<=j%Eam3m8_=6\]m`-#YQHf_3;RPs!;o$9?X>_M\1h9?j'+^kV_F^7]Kae>>]=NX`&79@cV@s(#KmY;fYq@Y#C$L'P$W*do[('VL]Wp]'ehUoF[#e?3Zc37E;&I>5.NY"!qUUiInC?5"c68ij*O6mli^`:_Z"OVOV@VX)g#$[^d%V+0AejUn%2oa-=B99*Q@5uS(LKA0Aj/X/%",mJoatq=Ul=)Rqg34ORU84,FGg-QaML=@F!>"k&Mp'Tp%2KO]^RDq&$WUAOg#F0%nYS[j1m>1UFD$a,8g88SK=;8ONT1+'GbWb2N3X$Lti/L9m0(qc:4ld9MeB'Bb1a,:BrLo,o]oCsODAjPoCR`J_k0n\o/sS2LtnW?b0[R]B?'g%9YN\?-J&Nib!#X1+72/PMbRLoCg.Gf^]$V*^;Q"C3Qi7j*Lqa0%+>0N)V9pMG>nZ!B.uQQOruD$ZRh*_%"d4>Ku:c=t?j+pX,3=#XW63?4#eHp&Rgn8^Q-%7cU`(HWBk2ZJ?Cn*Yoce52=V!rSE@^c"2l[GuL">R;Fk8fBX3C^ji/f(#hlN7@d%e&=f,r_Y7U0[:VDP)j4#5<<E,7d'hs"Z_4E>U`\Z,T6cY;h;d^22@H[ffJ5Vb6h.E8m^OTWg,tdiWJ%B86)>@6hR3WD!u/7O:L+UC0!#^[@4(=*;*a'Ul&fZQ>`A1MCI+iP<$6D247EW,-Eqi"og@>*-4dOc"g0#;_Kl_;O0%IgF;V3.T+a/@'J-E[n$uaPUQr/G:U293;\ul*a&A-@0lWoPYTVrP2`j1[9P2i&5;C>AT=b1I&?McRTC>^$r8snT/I2j'[?CXXpd/t2=Z'(2qC&81adN-YY$H>'TI^r-*GMr0"@A/Kh/$D_]0go=im'U`E+j[LfAc:(0n\5F<\)*.n*a0$\@g+_K)ET(;*a@:n1B2@FlHs33H!-BF)Jn;T]$Q!Pbu#<m$abX+r[jS@;5<+$9khYUosto6clQIS4Dh\s>+(&R2M>A1U*@=%\^V=Q(#nh/-=,L-9Xs9&]dT3kfU.8?3[ppX$nZ>\6rW1\!bA#gXYIF_+>$Wa6W2A4SLN"HE6nP0\@`!EVnue_1RcQ>'qTR1#f+X4]Y_"PsajSVSinDBYCi*kF5SA*lj5`Q2D`d^PHUj%h*NUkI.gCX&&PS_%>)i9lj<j;_b[?g=!XRU#-s2<q5']\6>lqa+dWH>$W+,o.]D1e,*\B#atk<?Z,%isGn9&0?`@'o.O5^PL="oIt2WPpgW,^8<sa8=)sn^(/IPdIEFaOOHoc'?4nE*d_[H/XVO$DC3u!q5`iM]9tV6<>'5m=5qUlY`d@oCd^I$*'qJ6*6'Np.t%f[c=ctTpQ*9l'LtB6Oa3rKH6.6Xn]o8^a-'!lFhr1)UG_"h/_s96PL:/q$haVcPaN#U0,kmIU=&=ec3KYTf8:N<BJ?FSkWG&LR<'4Y<Oh]Hld&#"A0i$LN3afp"j0pk#c9SI;"4].J`FijK1kS,<q.$S*m!aW``?ZCc`OF3ms!<A7V1$Ul+h*S0r@$c9GX?:SR0MG*W3@AhC7X?(:C8C.Z7#$Bp[Wj]^XZE:6tek_KK;Q_Jb`1_\DhW/;[u[_<mABj=NhT_p+Hm.dZLOY&aoEMlk5UHRJ]b@HtjpYm]k%CAbU'H!<<$"Vm*4K$rM^]?An3]b:6VF!"34]<9c5W%XSeVOWaLDIkghR!>m8AVX8<8:K'ik#jCKR,DW^cc9uf^AdOVRMhR3s5:-1HMjH)+:-cB@o(9aP1DY9%kk.ln9W7"@6`a8U`UK2.\VY"CmCK]UN^Z%)Bh6\/\RQHM(1S?;'26Lau'pu4%PU'W9bO]7/kegj<i^o"3GsJOK`J/h-OTP8$-U6!VE,YP's$$0r;,`pOl`j=NeSdo;=ct599BaNr`U$s"O5\a5bg``m#HZU8i[p-9/\sbq>g4Pr3FJMTbDm([0c!*s3lLqI)AZO,&p5'lrpA-p.?;VW"!3'L)A>V8O!^j1:N[,Fj\S>>7VJrm#Y6i;@b;l!<(eOs@TK'5_BIfLoI^4/5maoeBc7X8%_&o*isZEqmETE??a*#/tFFnXKPA38PY5j<:em23TeD>;e8n>RT87d\\I"f:j,_TTh8L,MRICj52c6rPU_PgVTDgEN0eF:PW%AREg`jf+MO(2f5&UJ9Gn<BYPEA%iX6^jBj?b=Zu~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1580
>>
stream
Gau`T=``U]%"@rS^uV3Lh+8J]\,)E1NpEh7>g>P"ljGDo>%Mf#.4K+[r:^8eI)aGYRFIX`,+0]?')2G<,R4GOGl9j#!Ic',aZ0TD_KZ:?Ai>SQclKU0\*>"J\5E!f9fo\?^c-(;[c@63^)btE(/PSLn-7^VR1Gm!&mPe2hSl_rA^Kh]nFZSOm^PLHI=Ui.N'@qaZ\"jA`^%(H,+gs"n&"dFF!C&,b]MF5!e>difuCKBj4gHmQiSne2*;rq0a!%5g\@Y^R;81*'+@<1M$0[gDMq,c3/=]7l+pII1+RG@$-"aEng'9*nQ8AT((i7p763K,bm\N=%@88J@'.96;s([Cilm$>>$44L9b8a5[`?VS<rt=_/*-S!%)0T_D<@uToIn'pm-KjXUu][0Su$"XD4-&H.\9q)cMO1#kcB7pcka>IEp./4<*9^E_/Rl5$P_@bRHre2=%G:dqAhO/C'm+l8L;Ma+f+G?3g]X\+IQ_L?*;N$;IPIM7bt6!?:W:QI&mG[E4!RT6H0"`Q<T+af>1">D4AB2U2pA6;5aH)hmkljCb/O=ob0N5:G_4c%+8B0fKKmc1t5n"d+@tuTeBb$r2e7#^g&@*eX0+60]/RrQEV?N3(qT&!Q7.`&/M`WNBrp4IEn."Dnf9Tm89HMPcUd`1Q[oqWS?5Q<:NA.9H^B+6("SA6t1gFYq=051!,]JOsd[!*J:-97/e(A(GQMY3;WK&m:Rk]XKqLgQI+bI+c/;L7Zr$NOtJ7fPkgA2$AFB2;DXOe'N;!jU-&uER,;$8@VST/]a6^MbK7$a/#^"X-F&/*^5&<;`*X'&E<n=PUXe"6QB'l($CSshN:T_.Khn4r#_f.]^slUiFmN*S%2K?V6GjLXTk8YSnk^)'a(<U^V0kJM<@U75AY+c7%`KSN8)q=Ro=##rqdAC8T>kQmSUB=r8F.+5JOL-sNZBFO@+\?aaQM'4r893!`]+Gk@?tP)m1W+K93!hJ+]Kf36a10in&Cf;0\4O4(opRA-ZH'U#8urdfJNkiK18BGEKl<K,`9W15XLm'Ic?A)M+Jm%5a:ZGYLr7!.]']]8uYpkP5@nP29=i+E$hJ_Z9-tu[M5Q$.T39G.Ui\#H9=W&k89(9K;_+'AH>pB8=2Yg.3'c?:Li09YHBUD6<"c@CC)fIe0iEZ/*kG<<em@%[,.phnI?=uTBdVgA(HQ^dsI';a=`X$^G_="dkXAtaY@4]j;>U4&lL3BPg62,B'o]V/#-:!nS[^h#@jrinUNKp:d6LZRK6(2p<ce3BNqtUF+H15BN1)S"UGilMsp1GL)VIVV>i&u*aOk0^9&9#gp="r2iMEg>*(tLHBbdhBL<%V$J'@5*;+c+Cq.7QJaM@dg;V`-M]UXA@DCI&NeX*F]<&S`0>SHN1Z&O5NiE6,WBQuu%#Z*i$uGOT^-CWo*ZJ`6T-"6hiU?>6\$iHMYcb+sZM%WUd)d'pKsjC=DE@D+:thOLm>U`5ap4W]'<7qgS!_,cN.PIaW$WbX_i8&a05\O8d"a?;Ye<h@ZcJGZ`T!usPdBeg'I%14)J;JH0"_o`a^',$n=n00%9djl8S_soepj9&jE(~>endstream
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2305
>>
stream
Gau`U=``=U&:XAWf^V<um^N\ARsf?h7<Kj.Nih@cUR;Na_(mbkTn&kY9RhF++=U0sjKe@O+:3RFICkDCOFW0P7/r;\J3O;.p)O0C_;Go]@lQ1+6"b0-m6o9&5YO=NQ/dU9L4ST>iOD0]iRN'U*!&'#09D@.k846pY,02cd-BK[4.gkj%eMFBhKK0*fA/5"I"fCTMIJ##W7dR5?HFT<Xqu>eHsL8N!lqP#)Ckg_#QL+SgT0rebBC!!%,Wl>!#;b*PD%-O/X%$CC"4'mBq!Kf7LVIm3L*jbd'A$9d$UY.!H2A]U/]'?biu-jJJ'.`]Rt<J/]TRD\dZ&iCk,G/^Iaj*mKkUqpjSBlO>r+c!j:Q&<r!HIrQ,;_HRfO\Vr9Ui2lBM8.2o%RIANK4Epb]0BrPh$QD16UCu8L4]=R?6\$%E1KnVa'!j9s#PGs(MYJ4c+MMbmJL?G;\F%WqXXtHDBjTrFKAWffB7$=,PFY#t@TJFQ>>[B\B;Gi&+VB.C$f9*BV+7g:CE/Q[K$Q0Uo-*@_<;?Wa=fl@Pchd##'mq0pt@B4frf=esZ]"e)`0g&[%\WHZ+r;4&G9M^;?fI#MZap6/=$U-,?m]1$k[+?%MPXLh@bDHkgE!or*XOSmPA#h,[dto4[,6>':170b`E4h2g/.s-V;`;m.67%=2O/I@<ZOA#idg%&\84\-&C3n%!Jh9dgQU_+UN1TgNJBj43`?:h%s670O:(%/&Z8#(9/P[K!UeK<+183TG<no2t1GYT+DN-N^Xdd9Yc*BB?Gt*%G_41l.AS\\BS0oc1:hUPBne'`=:kqroN?T-hlHTRdm"3@l#_kt1#esS%nq+p>OHfU@(-#j=!+d[H(h)Z69@rGTm0B\q/PlqJ_[3*+GLCX3_s-HOX)IAGnH%@c0+:%.AH?FZL9maBl54HXP2NK:XI%>K/)=gDViZuW2X9iGlBa&[,bIJ0U`8QngtItt`Ik"1kotT[\:&(OAON=<H&ujOYo72"cZe-I;23\+92'^>-:"X^<6Wt"i&5A?An+_4EgBG/\5$W$#$LA(km#gL;(t2P]"Zc=aAL0$B4>d,;Z((Q*enQsq];&@P[D&L(21_.WS2?IX_/t(k1Q!!Xh;"FmVb4WLm1-^cG-g'WN^@/k)`uG4PlW[)s"Maoc)uCf(rfXKQZdIZ_'V);]+\b_pS0,#F8(4B(TntjfPaQ^VI9lSC=[R;gP3DYBTGkNUf0uA<YK:Y#P\G"qgf``#^@nU;O<Y35%r)jZH>Zkk\N-mklHBUR6ol\Vtad^,_L+(qr"B+7FM0aWFt5knJanTJT-]GD/:,_klG?$rla=dthGpOX^l[CTBA#ZIi2$bO;cH]'?=efc.-\[]2'g[]5d'pV*ZDBB+5$)qSZpCdpWhF5^AgE)'!VcdfOSW+N7>XN'e=)V(ZY26J$Y).IpG4-[D.rENeEfG[LJ>M`GfCHG82.JoC\)4MW"K5PM)DPb-jcP*_@ltE'Yd!A"0:TCM$f)\gPZmN5Y=boX-MIsJT"m+UF[dC4sX_GQ'C<@YmjW+Y#I:]O-f3NK&10Rm\[bAWbnKZ>.%*sg.)n2+Ij%T467H_hB&Vcom>cEtcJZe=>bM_+$SZ1Ofp,oW/E-&ou_p(4\DYG\oOm9Uk\o"s";M?%W3V`VR'tb7W=G/j'Ph<,\Xdl!#'E+aWYt1F@SCT]*e58GaT1G0,),0XZpG$Wp]N<#$4rUS@OZmfR'ZXWl\fT<;bI260&p6m&9cB<,\@G$bSam@5l#M+/bY1qR#o/b(m;Xs[[It1TZU4Q>+ENPIR5tUN(7+f!F"LjNeH9Tqr_u$)E`iue\k!FR!QHp&%2ehqHQ-kr)6IcL>9ZX$9Q;*%gR"oRZh*`q)V"ad*_rc[<ui55S0f<[1XnTN(c*Zr)F;V0*&X4u!4-F<H/pE6f4&oaV0X"Fon&uE4(H_(@46P<(Z,q@FF/Gr(S<5lCn9mT,u6D6'8>^>aEm[c`*0:jc8#3nn4r6W3_@BU&U3T0a\8Y2s47^%2/J]SL,t_*6">%XHd-i[p.lmNFXulA@[HHA#<c6*OfQR+4Z]X=M@T3M:uc9.Z)oN2(.Ei"0]^9"WSTFjobKQ(WG,$0fRG.($2p9);',$6/i1ujSsH.hZTklW%tT4LIT<",W`WQYi`:JEXju6>^`i7Hies`268iX/^E0Z#N@f&D`S48;gq4bXbc"EU3G!9I?C+%Y"3Yj]jlR>^:par.f3"$ORh)iq+rZnM;oE0,h9<3%[!BBpRUbYXETqAgfkQiCHTmF\=F.eR4*5A-n*LdLq#ihu)qibH=QF3Vq%q"G)U/~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 862
>>
stream
Gatm8?#SFN'Rf.GS>V3ERu*-rp"6YdYl\4c<0JZ)aTB@X,r'I-qm%<YlQh:iG3Yk0KfI\ukM1FP^#nhJa6WbN%X_$u]B8#QaV%i6M.DQ2deVFmG6GEWmtfQ?qHb.e&kr.!,j=VgT.I2skJ1<o5dCZh0-"5WmdPBTpc]`-0Y+^.)'l!An-mDPP&lE27M+0.*r333&bgo+_"%;`Le!U7IuarPDgIH6qks3eoR.=(Kn1%0P_b,Y*ZsDMO0cV8HNY!^CfH.C`acPV2Dpj.&L()?/h2Ja8G'PKR9?BO1s*fB&[UDfEqG&r?FV)p=1)N,pYaq$k'ktA/At2ZBM0Ni6k3RY9XJ.E*]eLEn%W?$*JTe0fO6UD%\qQ7FP42MG:iEh/8OUdE+glCbM2:Sk\;fb**nLBKc`s?;,M%3(_"HK6ZKR`dYh5c;q*lS3fDB#/sT^MW!dHBALp+O+nf\cp&LX'#B9pPA22=:ftH+r`*Q6biI'u3,t-du(S1lj)DDEZb]b+N;c&/R@]T7ngTJnonM%n@<QjYMb5F;RoVii1RI]+Ye7NK+Z4ks(,Pob](0.c)ku9p*Xqd$erE+"_Mqa^HG2:MhOr!+$8\)q8jX457NTL!5-)Zad'`j-7jY+(<bN:R)q^OsWC+@cj,$ieMbmJ\dYpi\0TeWTAj3"7F)KV)]/59])Ci&eWl]#67&o50Y(-7(#1,[[dbNRkffd\:h=fGjrqm5_=fUQ,cX)OC[em##>mO`a&q4-`:fbq[M6^a7*c4r*%8-Nk_8jdGF6iQ=Tj`CN;&:q^P/@6_CEipr^OW!jbhgD-\l-8IgYp[4k[Pc1]WmK0'HVo^520oKcDg,m,IH93@%m#1?]I8Ep~>endstream
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1747
>>
stream
Gaua??ZXs[%)Ccl.<'^f\\1\cJ'!%1*@5jo33D6-@@%d6U-7Wa;5GPIh<i)jTHm`bm4@L5l/`Q3U'Lhq!?`3e9`20fh`(^_4,;]G/?irX0>VZ<(ZlBejsQcsoSa6FWFtSk\`&pncud*C]eHg5oNVp!J6L>%X-N+b7L4QXPIM#*Qe,<dT9C[TX0^K7BKi"o^4uGHbhnN#<eI+&`l*#e7\Sr>Tb%%\mRC7@)Re8P$3:(AhgP=Gp[VVEna%D9JSt`9)Ia4OCUc%T2D)g*mG8%K/?`1`$&@9@AkGV(Zg@bT/EM.'i>7mQ31$UL$22&p+Xo-&r$U1]fre`fI2tMWPlZ>0r%V\nQRA(2^mC7t7H0Jd$p6I&m.OWj2^b5ZpOb6&XUNf=r2kq?)p)dl*0>;Rj>LNiAYk^[fHcGM**l>n`ok`k1d7Bc*kj3PN:Vq->f,U`C=g"e'\<a&ha=NTP42`oT>QJa">4uF's",sOa[:YQEhm"gp*W@+'Rm-35apVIbs:k1M8X2[L5h!RMClV)&!uf=/VS=,U8-cp]gX.c,LR[dFi2\4bZ\<*CUoIhrF2(_ZYB^OS6i4%10]#UAqT1Da5c2lVMB(Dh[P=qq>8*iACgI0%3=t^nD?W(nm9\f7GM$\12(8+I5%;3E/-&98Pni\J?V.2+JEtY/F5V9L6g/bu0\n^PNQ:Nt6;<!A@rKF-\0dG`&d>Z'%8j%-e!KVobIk'joFm`W<jI&Yld17r3=,+&u$/YW%L]/[Ou.F]:6=_]3`d4V#'8o.e@'h0tk2%&1a_8oN?LV;9]tme@J[f,Ro/'T`'m57pK@!]f>q2C+'[5P@PT8bD`:,#[Ik6M%,6Z!Rpc8EOKS<X`)9ac'4:`Wo)k+HqMRP[I=lQ,)>d?g+R`AI/[/\?b'<;mqbpP<o-W;jlU=;tsJdZB@1&;8P8JapR<=Jg$^&5Du6,Jgj1W";Y'J/J$;DY_r\n)DTho.*:>OCa%oPfdTq:Q>akg,ArCd)jl0_IgKY_BagVmbRTVFJ`]-:RUc:U13E,OTlk-9_@eA16?;Rs>-N?sS=Yo<Z:S($.K@EZACE%=?:A"ZEY:0nE2I3]qL:fs'AjhA%I!7I_"F3M"H:\Fg(sO&fQK)=C?-A/8K53@]?d\U$*2PGc/XE\-c/52k\H]3LXU,:pu<q@%&OZB1`(ZCFa;4Zct.+D>JpTV0B64bIB$;!B8&aE)'R93;jW+@EDToL0-#"pYHSNd(nQ4.&L7RI'N/fCiDE`t$@s'bnKlcFGBLoG=ZuiU8:sSI]bVf<[%iF!$0*29h(s-L;E):uqJ["UcESLrg`NHscdDdu#2j(N-@1/i"1Rml,eA8P#1s7\cJJP;dlhXu=HBF8G?3&oim'6opg>TN$a[L!O4-`O'J"T'aamk>DSfIs0`%sIH3t@>COOOIiu]8iKVT.n^.YH0=cb>+KdKh@;:=uqJ[hg3+tF?\0j9W^_2[9eVTmi&gO2PdUQdBAe=Q04etLbGl6uM":SnT?9NouF32]f2QNa5R_-H80i?#8`RH^9oa9cuW9*utT?F!8grrQRo^dbEbF9IhaLNoJK/Bq;@F(2ao,so8D$asI<ei2m)':*Rp@BkXm92H8U3J%1Q$eELG!Dc2M6chr&Z4tWl])gd[1-rlTd@l=a;^D,AXZTZoNGc:t:%7Qn&pF$G'K5]AD=iH_eP.XHZU2WR8dF\%-[N6Z"bQhQWjg@\0=APX(\+@3-5M^/0O#cV-'&SE]Q!Ep$gF9,~>endstream
endobj
22 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1799
>>
stream
Gatm<=d.Su%"@rS^f+m88JRB!gQuf$*S7Ffp@]ZY(L!ORVNU0,fOQf:nHF#:^:g#`G\kJ9$%0YrJ:JK(pgM]kQiOdk_Rg<e(l.>qQu1J0S\T2*pbJe>E+!;NKK%qGAHok_K4-jUcgg;Q%ZWf_TEVEZmb3C+T#4-)!b\9tX5V+@E!TP.pO<XMj6P+=0`;F/2^GXo&6*C<>h:CpRH+,rCgT+91#Fn!Z_e'0rkn35Htuftbq4\U@!Jj=q7%"XQn6mUcV8#]-=9\j-.5]0&4q,j99CY4s)@DB<5^OD2#IJT)5]IG$RKsTknAlgE*p"CLYIRJX^ejU$Nn%!nV9#D58l5nK<Kp7Tu88i#roA+nE+'d%tct)qksqSafoX"m:=`H84I*%O*4$_ls/[A]<#OIS>=OJ05dpWN%]t<(ZY*YD;iN?_`05YQ>.72Xu;9o9!M+JE#h@&8]&"l^4ZlV750n(bXA-\'^9qJcD=h\8ESR?jn]B;'dbnX<oZ(2.WRH-UK_D`;m1FKaI$?Iga_Ae*K297bW"J=B(C1$^:B!A+l(r3]UoV9c6"t&eq!e=n)r^n\eU:%0`g#VqT["\mWNekgO?d#,C:PQgp.i<^MjQ9$1g(X,7$8M4>6j)fE`j4Sni\R'kfPG*<_:9/>lXgE10j(7l?TB[F:Q,]P>$k:\o'!b^>Z_Dnh%H9L31cJ6BCn?"<9Nn7%1Dd:dDl#G]eXj$:8"TK:]0b(":b4c<kqp@q:E67o6)oHV4+7%"EbU'cHD%]\H<B@oGGiBbLqW-!>^[LXqI7N6*%YA[cl<7*F^e0u:,A#0ZHOMYcBb>LeJ<2r821<=$U9RghAk-NZJ^r+_a"C`Pt/Fbh7JB;[XZNB`!-t,R(g$]<>aR?B%@^CPqU!eW,lJrW[rEAIuW@pjLFd>TnQ_h^WWh)$neBJE86^d!-#R%7ef6I"`X`@"i(s;c`^\<asHqkW!e0h7G#7sc[o;,3WPL73_[m(*9`fR765kb89d($?BcI>1W,5r$^%&(a?=eNl;?F?_l7pMI6N"gsdDX&'u]MF#Db]idjM)CIF*3pW<%eX>&&><ZGZAbWq*)Ic9&e^VtS@Gt-Q9s>_&8MlGT;8kr)i`;Ml9nP!)Co4k;T#g`6>d0Z(m#8WT,p5@19@6I5A$UJr+YpXPjq>f]RIP!ADnn.3GD,e/98s'ELEUHn*$Jc'c,dS>CE7C>@qWQ631FcM5No^Qd=Ym"IdOiq3DMY][1RYLbWHLq%!#:1IBW1+d>hA+-$@D)hWU7n)O2O$,9$nki%HK!GM3EJ!ZP)EeRq'&L_79RRA(-*H[RZ3]<2m)%X\k7&TkI'AktDJVPq*HE7L54:8qnHrEUZi&Tqg\#lE4;O#[;H7Y?THl07"&tsFoF-=^q"#CZfE^;%&=fa=ZF;:2qXE?;Y6=IGg8S*tPrX^k#X$UVh!h%J&)<u=H(+&*e3rbp,V4F`sFL_'+d->*/KrVnjHDS(GU3gXip9eGuR(5,UID0V_F]>D]f#aT9W?%o6e[XG[Vu,#S\Mi@3k'Pp+YFBBOPHB"GgZ-Y4p:`o:rIF+-LWs]5CqC#Hk\Z;rn[]%_IDS/FXdt(?=sDo2!@o,Liq9a(9<&^dXjg'pZH.<SVD"W%*BjqVYpDm61OX(rS"@s6!pXm5"e:RN8tSW\=)FA^]UCmVpCHp;^^pl2Lic`G<Quqm>,0:jE9l*rcfC>`nYXaFcB?m?.o2.8MSWT3`'EM9kfBb"?9L4IHbW?kVP:53a@e&tnfh#=WiL_s8HiI,Y?,k=-jqR=CB7P\asSKo/U)uds1&QQ+T~>endstream
endobj
xref
0 23
0000000000 65535 f
0000000061 00000 n
0000000122 00000 n
0000000229 00000 n
0000000341 00000 n
0000000456 00000 n
0000000651 00000 n
0000000770 00000 n
0000000965 00000 n
0000001160 00000 n
0000001355 00000 n
0000001551 00000 n
0000001747 00000 n
0000001943 00000 n
0000002013 00000 n
0000002374 00000 n
0000002473 00000 n
0000003341 00000 n
0000005673 00000 n
0000007345 00000 n
0000009742 00000 n
0000010695 00000 n
0000012534 00000 n
trailer
<<
/ID
[<209ad14cd09696f06ac4a251f78bf2ef><209ad14cd09696f06ac4a251f78bf2ef>]
% ReportLab generated PDF document -- digest (opensource)
/Info 14 0 R
/Root 13 0 R
/Size 23
>>
startxref
14425
%%EOF
+4
View File
@@ -0,0 +1,4 @@
VITE_SUPABASE_ANON_KEY=J5JS7HG...
VITE_SUPABASE_URL=https://tudominio
VITE_WEBHOOK_TOKEN=6d4g56d4fgd...
VITE_N8N_WEBHOOK_URL=https://tudominio/webhook/carnet
@@ -8,7 +8,6 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
+130
View File
@@ -0,0 +1,130 @@
# Manual de Uso — GLM ID Card Generator
## ¿Qué hace esta aplicación?
Genera carnets corporativos **CR80** (tamaño tarjeta de crédito) para los colaboradores de GomezLee Marketing. El sistema crea dos imágenes PNG por cada carnet: el **Frente** (con foto, nombre y puesto del colaborador) y el **Reverso** (con el logo del cliente y un código QR único). Al escanear el QR, cualquier persona puede verificar en tiempo real si el colaborador está activo en BambooHR.
### Capacidades principales
- **Modo Individual**: Ingresa los datos de un colaborador y descarga su carnet.
- **Modo Lote**: Sube un archivo Excel/CSV con varios colaboradores y descarga un ZIP con todos los carnets.
- **Verificación QR**: Escanea el código QR del reverso para consultar el estado actual del colaborador.
---
## Requisitos
- Navegador **Google Chrome** o **Microsoft Edge** (actualizado).
- Cuenta de Google corporativa **@gomezleemarketing.com**.
- Conexión a Internet.
---
## Paso a paso — Inicio de sesión
1. Abre la URL de la aplicación en Chrome o Edge.
2. Haz clic en **"Continuar con Google"**.
3. Inicia sesión con tu correo **@gomezleemarketing.com**.
4. La aplicación te redirigirá automáticamente al generador de carnets.
> Si tu cuenta no es del dominio `@gomezleemarketing.com`, la aplicación no permitirá el acceso.
---
## Paso a paso — Modo Individual
1. **Nombre**: Escribe el nombre completo del colaborador (ej. "Alexi Zabala").
2. **Puesto**: Escribe el cargo del colaborador (ej. "Mercaderista").
3. **Subir foto**: Haz clic en "Subir foto del colaborador" y selecciona una foto desde tu computadora. La foto se procesará con IA automáticamente.
4. **Idioma**: Selecciona **Español** o **Inglés** — esto cambia el fondo del reverso del carnet.
5. **Cliente / Proyecto**: Selecciona el cliente asignado de la lista desplegable.
6. **ID del empleado / Cédula**: Ingresa el número de cédula o ID del colaborador **sin guiones ni espacios**. El código QR se genera automáticamente al escribir.
7. Revisa la **vista previa en tiempo real** que muestra el frente y el reverso del carnet.
8. Haz clic en **"⬇ Descargar"**.
9. Se descargarán dos archivos PNG:
- `{Nombre}Frente.png`
- `{Nombre}Reverso.png`
> El botón "Descargar" solo se habilita cuando los campos Nombre, Puesto e ID están llenos.
---
## Paso a paso — Modo Lote (carga masiva)
1. Prepara un archivo **Excel (.xlsx)** o **CSV** con las siguientes columnas exactas:
| Columna | Requerido | Descripción |
|---|---|---|
| `nombre` | Sí | Nombre completo del colaborador |
| `puesto` | Sí | Cargo del colaborador |
| `cliente/proyecto` | Sí | Cliente asignado (ver lista más abajo) |
| `cedula` | Sí | Cédula o ID del colaborador (sin guiones ni espacios) |
| `lenguaje` | No | `Esp` para español (default), `Ing` para inglés |
| `foto_url` | No | URL pública de la foto (Google Drive o enlace directo) |
2. En la aplicación, en el panel **"Carga Masiva de Colaboradores"**, haz clic en **"Seleccionar Archivo Excel"**.
3. Selecciona tu archivo. El sistema mostrará cuántos colaboradores se cargaron.
4. Haz clic en **"⬇ Descargar"** en la sección de lote.
5. El sistema procesará cada colaborador: obtendrá las fotos, generará los carnets y empaquetará todo.
6. Se descargará un archivo **`Lote_Carnets_GLM.zip`**.
7. Dentro del ZIP, cada colaborador tiene una carpeta con:
- `Frente.png`
- `Reverso.png`
### Clientes disponibles
La columna `cliente/proyecto` debe coincidir con uno de estos (no importa mayúsculas/minúsculas):
- Generico (por defecto)
- Claro
- Colgate
- KitchenAid
- Kraft
- Motorola
- Nestle
- P&G
- Philip Morris
- Whirlpool
### Formato de URLs de foto
Si usas Google Drive como `foto_url`, puedes pegar el enlace de compartir normal (ej. `https://drive.google.com/file/d/.../view`). El sistema lo convertirá automáticamente para poderlo descargar.
---
## Paso a paso — Verificación QR
Cualquier persona con un teléfono puede verificar un carnet:
1. Escanea el código QR que aparece en el **Reverso** del carnet.
2. Se abrirá una página web con los datos del colaborador:
- **Nombre**
- **Puesto**
- **País**
- **Estado** (Activo = verde / No Activo = rojo)
3. Si el colaborador está **Activo** en BambooHR, la página se muestra en verde.
4. Si el colaborador está **Inactivo o Terminado**, la página se muestra en rojo con la leyenda "Empleado No Activo".
> Esta verificación es en tiempo real contra BambooHR — no requiere que RRHH haga ningún paso adicional.
---
## Consejos útiles
- Los archivos PNG se generan a **650 x 1004 px** (equivalente a 300 dpi en CR80), listos para impresión.
- El ID del empleado se limpia automáticamente de guiones y espacios.
- Durante el procesamiento por lote, las fotos se guardan automáticamente en Supabase y se asocian al colaborador.
- Si una foto falla (Drive con permisos restringidos, URL caída), el carnet se genera igual con el círculo de foto vacío. El resto de colaboradores no se ven afectados.
---
## Solución de problemas
| Problema | Causa probable | Solución |
|---|---|---|
| No puedo iniciar sesión | La cuenta no es @gomezleemarketing.com | Usa tu correo corporativo de GLM |
| La foto no se procesa | La IA no responde | La foto original se usará igual — el carnet se genera sin edición IA |
| El QR no se genera | El ID del empleado tiene menos de 3 caracteres | Ingresa un ID de al menos 3 dígitos |
| Error al leer el Excel | El archivo no tiene las columnas correctas | Revisa que tenga: nombre, puesto, cliente/proyecto, cedula |
| El ZIP no se descarga | Demasiados colaboradores o fotos muy pesadas | Reduce el lote a máximo 50 colaboradores por archivo |
| La descarga no inicia | El navegador bloqueó las descargas múltiples | Permite descargas en la configuración del navegador |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg"
href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_white_background.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>glm-card-generator</title>
<script type="module" crossorigin src="/empleado-id/assets/index-C9T6gjmL.js"></script>
<link rel="stylesheet" crossorigin href="/empleado-id/assets/index-DRjn2cG3.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg"
href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_white_background.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>glm-card-generator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

@@ -2,7 +2,6 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-route
import { supabase } from "./services/supabase";
import { useEffect, useState, createContext, useContext } from "react";
import Login from "./components/Login";
import EmployeeCard from "./components/EmployeeCard";
import IdCardGenerator from "./Generator";
import "./components/Login.css";
@@ -19,7 +18,6 @@ function AuthProvider({ children }) {
useEffect(() => {
if (!supabase) {
console.warn("Supabase no configurado - falta VITE_SUPABASE_ANON_KEY");
setLoading(false);
return;
}
@@ -158,16 +156,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/empleado-id/:employeeNumber"
element={
<ProtectedRoute>
<EmployeeCard />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/empleado-id/" replace />} />
</Routes>
</BrowserRouter>
@@ -357,4 +357,267 @@ body {
background: #4F758B;
color: #ffffff;
border-color: #4F758B;
}
/* ── REPORT FLOATING BUTTON ── */
.report-fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 28px;
background: #4F758B;
color: #fff;
border: none;
box-shadow: 0 4px 20px rgba(79, 117, 139, 0.4);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: width 0.35s ease, background 0.2s ease, box-shadow 0.2s ease;
z-index: 1000;
overflow: hidden;
white-space: nowrap;
}
.report-fab:hover,
.report-fab.attention {
width: 150px;
}
.report-fab:hover {
background: #3d5d6e;
box-shadow: 0 6px 28px rgba(79, 117, 139, 0.5);
}
.report-fab.attention {
background: #E65100;
box-shadow: 0 6px 28px rgba(230, 81, 0, 0.55);
animation: fabPulse 1.2s ease-in-out infinite;
}
@keyframes fabPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
.report-fab-label {
max-width: 0;
opacity: 0;
overflow: hidden;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
transition: max-width 0.35s ease, opacity 0.25s ease, margin-right 0.35s ease;
}
.report-fab:hover .report-fab-label,
.report-fab.attention .report-fab-label {
max-width: 90px;
opacity: 1;
margin-right: 8px;
}
.report-fab:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(79, 117, 139, 0.3);
}
/* ── REPORT MODAL ── */
.report-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 1100;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.report-modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow: hidden;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.report-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #E0E0E0;
}
.report-modal-header h3 {
font-size: 15px;
font-weight: 700;
color: #4F758B;
letter-spacing: 0.5px;
}
.report-modal-close {
background: none;
border: none;
color: #6B8FA3;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.15s;
}
.report-modal-close:hover {
background: #F0F0F0;
color: #4F758B;
}
.report-modal-body {
padding: 20px;
}
.report-modal-hint {
font-size: 12px;
color: #6B8FA3;
margin-bottom: 10px;
font-weight: 500;
}
.report-modal-textarea {
width: 100%;
padding: 12px;
border: 1px solid #E0E0E0;
border-radius: 8px;
font-family: Arial, Helvetica, sans-serif;
font-size: 13px;
color: #2a2a2a;
resize: vertical;
min-height: 100px;
max-height: 300px;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.report-modal-textarea:focus {
border-color: #6CC24A;
}
.report-modal-textarea::placeholder {
color: #B0B0B0;
}
.report-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #E0E0E0;
background: #FAFAFA;
}
.report-modal-cancel,
.report-modal-send {
padding: 10px 20px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
font-family: Arial, Helvetica, sans-serif;
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.5px;
}
.report-modal-cancel {
background: #fff;
border: 1px solid #D0D0D0;
color: #4F758B;
}
.report-modal-cancel:hover {
background: #F0F0F0;
border-color: #B0B0B0;
}
.report-modal-send {
background: #6CC24A;
border: none;
color: #fff;
}
.report-modal-send:hover:not(:disabled) {
background: #5ab03f;
}
.report-modal-send:disabled {
background: #A0B2BC;
cursor: not-allowed;
}
/* ── REPORT MODAL SUCCESS STATE ── */
.report-modal-success {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 48px 24px;
animation: successPop 0.3s ease;
}
@keyframes successPop {
0% {
opacity: 0;
transform: scale(0.85);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.report-modal-success svg {
filter: drop-shadow(0 4px 12px rgba(108, 194, 74, 0.35));
}
.report-modal-success p {
font-size: 15px;
font-weight: 700;
color: #4F758B;
letter-spacing: 0.5px;
}
/* Responsive */
@media (max-width: 480px) {
.report-fab {
bottom: 16px;
right: 16px;
width: 50px;
height: 50px;
}
.report-modal {
margin: 12px;
max-height: calc(100vh - 24px);
}
}
@@ -1,8 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import './Generator.css';
import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib, clientTemplates } from './script/script.jsx';
import { uploadEmployeePhoto, saveClienteProyecto } from './services/storage.js';
import { supabase } from "./services/supabase";
import { saveClienteProyecto } from './services/storage.js';
// Convierte datos binarios recibidos (base64, buffer, array de bytes) en un Blob URL local de forma robusta
const convertBinaryToBlobUrl = (data) => {
@@ -32,32 +31,101 @@ const convertBinaryToBlobUrl = (data) => {
return URL.createObjectURL(blob);
}
} catch (e) {
console.error("Error convirtiendo formato binario a Blob URL:", e);
// ignore
}
return null;
};
const friendlyError = (message) => {
const err = new Error(message);
err.friendly = true;
return err;
};
export default function IdCardGenerator() {
// --- Estados formulario individual
const [name, setName] = useState('');
const [role, setRole] = useState('');
const [department, setDepartment] = useState('');
const [country, setCountry] = useState('');
const [language, setLanguage] = useState('Esp');
const [selectedClient, setSelectedClient] = useState('Generico');
const [employeeId, setEmployeeId] = useState('');
const [photoSrc, setPhotoSrc] = useState('');
const [photoFile, setPhotoFile] = useState(null);
const [pendingPhotoFile, setPendingPhotoFile] = useState(null);
const [photoProcessing, setPhotoProcessing] = useState(false);
const [downloadStatus, setDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
// Bloquea el botón Descargar hasta que nombre, puesto e ID estén llenos
const isFormComplete = name.trim() && role.trim() && employeeId.trim();
// --- Estados Reporte de Errores/Ayuda
const [showReportModal, setShowReportModal] = useState(false);
const [reportMessage, setReportMessage] = useState('');
const [reportEmail, setReportEmail] = useState('');
const [reportSending, setReportSending] = useState(false);
const [reportSent, setReportSent] = useState(false);
const reportCloseTimeoutRef = useRef(null);
const [fabAttention, setFabAttention] = useState(false);
const fabAttentionTimeoutRef = useRef(null);
// Bloquea el botón Descargar hasta que nombre, departamento, puesto, país, ID y foto estén llenos
const isFormComplete = name.trim() && department.trim() && role.trim() && country.trim() && employeeId.trim() && photoFile;
// --- Estados Lote / Excel Masivo
const [bulkEmployees, setBulkEmployees] = useState([]);
const [bulkStatusText, setBulkStatusText] = useState('');
const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
const [bulkCountry, setBulkCountry] = useState('');
const [showTooltip, setShowTooltip] = useState(false);
const handleSendReport = async () => {
if (!reportMessage.trim() || !reportEmail.trim() || reportSending) {
return;
}
setReportSending(true);
try {
const response = await fetch('https://agenteit.digitalcompass.agency/webhook/reporte-carnet', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: 'Reporte - Generador de ID',
body: reportMessage.trim(),
email: reportEmail.trim(),
fecha: new Date().toISOString()
})
});
if (!response.ok) {
throw new Error(`El servidor rechazó la petición (Código: ${response.status})`);
}
setReportMessage('');
setReportEmail('');
setReportSent(true);
reportCloseTimeoutRef.current = setTimeout(() => {
setShowReportModal(false);
setReportSent(false);
}, 1500);
} catch (error) {
console.error('Error al enviar el reporte:', error);
triggerFabAttention();
alert('❌ No pudimos enviar tu reporte. Revisa tu conexión e inténtalo de nuevo.');
} finally {
setReportSending(false);
}
};
const triggerFabAttention = () => {
setFabAttention(true);
clearTimeout(fabAttentionTimeoutRef.current);
fabAttentionTimeoutRef.current = setTimeout(() => {
setFabAttention(false);
}, 5000);
};
const fileInputRef = useRef(null);
const excelInputRef = useRef(null);
const qrCanvasRef = useRef(null);
@@ -72,27 +140,42 @@ export default function IdCardGenerator() {
}
}, [employeeId]);
const handlePhotoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
setPhotoFile(file);
setPhotoSrc(URL.createObjectURL(file));
const processAndSetPhoto = async (file) => {
setPhotoProcessing(true);
setPendingPhotoFile(file);
try {
const editedBlob = await processPhotoWithAI(file);
const editedUrl = URL.createObjectURL(editedBlob);
setPhotoSrc(editedUrl);
setPhotoFile(new File([editedBlob], file.name, { type: editedBlob.type || 'image/jpeg' }));
setPendingPhotoFile(null);
} catch (error) {
console.error('Error al procesar la foto con IA en n8n:', error);
alert('No se pudo procesar la foto con IA. Se usará la foto original para la vista previa.');
console.error('Error al procesar la foto con IA:', error);
triggerFabAttention();
setPhotoSrc('');
setPhotoFile(null);
alert('No pudimos mejorar tu foto. Por favor intenta nuevamente; si sigue fallando, repórtanos el error con el botón de ayuda.');
} finally {
setPhotoProcessing(false);
}
};
const handlePhotoUpload = (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file || photoProcessing) return;
setPhotoSrc(URL.createObjectURL(file));
processAndSetPhoto(file);
};
const handleRetryPhotoAI = () => {
if (!pendingPhotoFile || photoProcessing) return;
setPhotoSrc(URL.createObjectURL(pendingPhotoFile));
processAndSetPhoto(pendingPhotoFile);
};
const processPhotoWithAI = async (file) => {
const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL;
const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN;
@@ -113,7 +196,8 @@ export default function IdCardGenerator() {
});
if (!response.ok) {
throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`);
console.error('n8n rechazó la petición (foto IA). Status:', response.status);
throw friendlyError('El servicio no está disponible en este momento. Espera un momento e inténtalo de nuevo.');
}
const contentType = response.headers.get('content-type') || '';
@@ -121,13 +205,13 @@ export default function IdCardGenerator() {
const data = await response.json();
const bin = data.edited || data.data || data.foto_processed_base64 || data.photo || data.binary || (Array.isArray(data) && (data[0]?.edited || data[0]?.data));
const blobUrl = convertBinaryToBlobUrl(bin);
if (!blobUrl) throw new Error('n8n no devolvió un binario válido.');
if (!blobUrl) throw friendlyError('La foto procesada llegó dañada. Intenta subirla de nuevo.');
const blob = await (await fetch(blobUrl)).blob();
return blob;
}
const blob = await response.blob();
if (!blob || blob.size === 0) throw new Error('n8n devolvió un binario vacío.');
if (!blob || blob.size === 0) throw friendlyError('La foto procesada llegó vacía. Intenta subirla de nuevo.');
return blob;
};
@@ -156,13 +240,13 @@ export default function IdCardGenerator() {
});
if (!response.ok) {
throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`);
console.error('n8n rechazó la petición (lote). Status:', response.status);
throw friendlyError('El servicio no está disponible en este momento. Espera un momento e inténtalo de nuevo.');
}
const resData = await response.json();
return Array.isArray(resData) ? resData : (resData.empleados || resData.data || []);
} catch (error) {
console.error('Error de comunicación con n8n:', error);
throw error;
}
};
@@ -172,22 +256,15 @@ export default function IdCardGenerator() {
if (!cleanCedula) return;
try {
// Se usa el cliente extraído directamente del array renderizado
const clienteProyectoValue = currentEmployee?.selectedClient || 'Generico';
await saveClienteProyecto(cleanCedula, clienteProyectoValue);
console.log(`[Supabase DB] Guardado: ${cleanCedula}${clienteProyectoValue}`);
const divisionValue = currentEmployee?.division || '';
const departamentoValue = currentEmployee?.department || '';
const posicionValue = currentEmployee?.position || '';
const paisValue = bulkCountry || '';
const customFile = new File([blobData], `${cleanCedula}.jpg`, { type: 'image/jpeg' });
const remoteStorageUrl = await uploadEmployeePhoto(cleanCedula, customFile);
await saveClienteProyecto(cleanCedula, divisionValue, departamentoValue, posicionValue, paisValue);
if (remoteStorageUrl) {
await supabase.rpc("save_employee_photo", {
p_employee_number: cleanCedula,
p_photo_url: remoteStorageUrl
});
}
} catch (error) {
console.error(`[Supabase Sync Fallido] Cédula ${cleanCedula}:`, error);
// silent
}
};
@@ -209,7 +286,7 @@ export default function IdCardGenerator() {
const jsonRows = XLSXLib.utils.sheet_to_json(ws);
if (jsonRows.length === 0) {
setBulkStatusText('❌ El archivo está vacío.');
setBulkStatusText('❌ El archivo no tiene datos. Descarga la plantilla y agrega los colaboradores.');
return;
}
@@ -245,26 +322,32 @@ export default function IdCardGenerator() {
return {
name: String(row.nombre || '').trim(),
role: String(row.puesto || '').trim(),
department: String(row.departamento || '').trim(),
position: String(row.posicion || '').trim(),
division: String(row.division || '').trim(),
country: String(row.pais || '').trim(),
selectedClient: rawClient,
language: rawLang,
employeeId: cleanedCedula,
fotoUrl: String(row.foto_url || '').trim()
};
}).filter(emp => emp.name && emp.role && emp.employeeId);
}).filter(emp => emp.name && emp.employeeId);
setBulkEmployees(formatted);
setBulkStatusText(`✅ ¡Cargados ${formatted.length} colaboradores! Listo para procesar.`);
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
} catch (innerErr) {
console.error(innerErr);
setBulkStatusText('❌ Error de lectura. Revisa el formato de columnas.');
console.error('Error leyendo el Excel:', innerErr);
triggerFabAttention();
setBulkStatusText('❌ No pudimos leer el archivo. Descarga la plantilla oficial y verifica las columnas.');
}
};
reader.readAsBinaryString(file);
} catch (err) {
console.error(err);
setBulkStatusText('❌ Error cargando el motor XLSX.');
console.error('Error cargando la librería XLSX:', err);
triggerFabAttention();
setBulkStatusText('❌ Tuvimos un problema técnico. Recarga la página e inténtalo de nuevo.');
}
};
@@ -290,6 +373,10 @@ export default function IdCardGenerator() {
return {
name: n8nEmp.name || originalEmp.name,
role: n8nEmp.role || originalEmp.role,
department: n8nEmp.department || originalEmp.department || '',
position: n8nEmp.position || originalEmp.position || '',
division: n8nEmp.division || originalEmp.division || '',
country: n8nEmp.country || originalEmp.country || '',
selectedClient: validClient,
language: validLang,
employeeId: n8nEmp.employeeId || originalEmp.employeeId,
@@ -301,19 +388,19 @@ export default function IdCardGenerator() {
employees: employeesReadyForRender,
baseUrl,
onStateChange: null,
// CORRECCIÓN AQUÍ: Buscamos al empleado en nuestro arreglo usando la cédula que nos devuelve script.jsx
onProcessEmployeePhoto: (cedula, blobData) => {
const currentEmp = employeesReadyForRender.find(emp => emp.employeeId === cedula);
return handleBulkSupabaseSync(cedula, blobData, currentEmp);
}
});
setBulkStatusText('🎉 ¡Lote de carnets guardado correctamente!');
setBulkStatusText(` ¡Lote completado! ${employeesReadyForRender.length} carnets procesados.`);
setBulkEmployees([]);
} catch (error) {
console.error(error);
setBulkStatusText(`❌ Error al procesar: ${error.message || error}`);
console.error('Error al procesar el lote:', error);
triggerFabAttention();
setBulkStatusText('❌ Algo salió mal al generar los carnets. Inténtalo de nuevo; si sigue fallando, avísanos con el botón de ayuda.');
} finally {
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
}
@@ -321,16 +408,44 @@ export default function IdCardGenerator() {
const triggerDownload = async () => {
if (!validateForm()) return;
setDownloadStatus({ processing: true, text: '🔄 Descargando...' });
try {
await saveClienteProyecto(employeeId, selectedClient);
if (photoFile) {
const photoUrl = await uploadEmployeePhoto(employeeId, photoFile);
await supabase.rpc("save_employee_photo", {
p_employee_number: employeeId,
p_photo_url: photoUrl
});
setDownloadStatus({ processing: true, text: '🔄 Descargando...' });
try {
// Enviar binario (base64) + employeeId como JSON al webhook n8n
const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL;
const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN;
let photoBase64 = null;
if (photoSrc && photoSrc.startsWith('blob:')) {
const response = await fetch(photoSrc);
if (response.ok) {
const blob = await response.blob();
photoBase64 = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(',')[1]);
reader.readAsDataURL(blob);
});
}
}
const payload = {
data: photoBase64,
employeeId: employeeId
};
const response = await fetch(N8N_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': TOKEN_SECRETO
},
body: JSON.stringify(payload)
});
if (!response.ok) {
console.error('n8n rechazó la petición (individual). Status:', response.status);
throw friendlyError('El servicio no está disponible en este momento. Espera un momento e inténtalo de nuevo.');
}
await downloadIDCards({
@@ -339,18 +454,40 @@ export default function IdCardGenerator() {
qrCanvas: qrCanvasRef.current,
onStateChange: setDownloadStatus
});
// Guardar cédula + cliente_proyecto en carnet_empleados_creados_glm al completar descarga
const cleanCedula = String(employeeId || '').replace(/[-\s]/g, '');
if (cleanCedula) {
try {
await saveClienteProyecto(cleanCedula, selectedClient, department, role, country);
} catch (saveError) {
console.error('Error al guardar en Supabase:', saveError);
// No lanzamos el error para que la descarga se complete
}
}
} catch (err) {
console.error(err);
alert(err.message);
console.error('Error al generar el carnet:', err);
console.error('Detalles del error:', {
name: err?.name,
message: err?.message,
stack: err?.stack,
friendly: err?.friendly
});
triggerFabAttention();
alert(err?.friendly
? err.message
: `Algo salió mal al generar el carnet. Error: ${err?.message || 'Desconocido'}`);
setDownloadStatus({ processing: false, text: '⬇ Descargar' });
}
};
const validateForm = () => {
if (!name.trim()) { alert("Debe ingresar el nombre."); return false; }
if (!role.trim()) { alert("Debe ingresar el puesto."); return false; }
if (!employeeId.trim()) { alert("Debe ingresar el ID del empleado."); return false; }
if (!photoFile) { alert("Debe subir la foto del colaborador."); return false; }
if (!name.trim()) { alert("Escribe el nombre del colaborador para continuar."); return false; }
if (!department.trim()) { alert("Escribe el departamento del colaborador para continuar."); return false; }
if (!role.trim()) { alert("Escribe el puesto del colaborador para continuar."); return false; }
if (!country.trim()) { alert("Selecciona el país del colaborador para continuar."); return false; }
if (!employeeId.trim()) { alert("Escribe el ID o cédula del colaborador para continuar."); return false; }
if (!photoFile) { alert("Sube la foto del colaborador para continuar."); return false; }
return true;
};
@@ -394,27 +531,64 @@ export default function IdCardGenerator() {
fontWeight: 'normal', textTransform: 'none'
}}>
Formato de columnas requeridas en el Excel: <br />
<b style={{ color: '#6CC24A' }}>nombre, puesto, cliente/proyecto, cedula, lenguaje, foto_url</b>.<br />
<b style={{ color: '#6CC24A' }}>nombre, puesto, departamento, division, posicion, pais, cliente/proyecto, cedula, lenguaje, foto_url</b>.<br />
</div>
)}
</div>
<div className="upload-btn" style={{ background: '#475569', marginTop: '12px' }} onClick={() => excelInputRef.current.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
Seleccionar Archivo Excel
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<a
href="https://docs.google.com/spreadsheets/d/1F5DAvLL82bpW2pL_jdu4FgcAnjbzV3VwlRh89aS2Khc/export?format=xlsx"
target="_blank"
rel="noopener noreferrer"
className="upload-btn"
style={{ background: '#6CC24A', color: '#fff', textDecoration: 'none', padding: '8px 16px', fontSize: '13px' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '6px', verticalAlign: 'middle' }}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Descargar plantilla Excel
</a>
</div>
<div className="upload-btn" style={{ background: '#475569' }} onClick={() => excelInputRef.current.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
Seleccionar Archivo Excel
</div>
<input type="file" accept=".xlsx, .xls, .csv" style={{ display: 'none' }} ref={excelInputRef} onChange={handleExcelUpload} />
</div>
<input type="file" accept=".xlsx, .xls, .csv" style={{ display: 'none' }} ref={excelInputRef} onChange={handleExcelUpload} />
{bulkStatusText && (
<div style={{ fontSize: '12px', marginTop: '8px', fontWeight: '500', color: '#475569' }}>
{bulkStatusText}
</div>
)}
<div className="field" style={{ marginTop: '12px' }}>
<label>País</label>
<select value={bulkCountry} onChange={(e) => setBulkCountry(e.target.value)}>
<option value="">Selecciona un país</option>
<option value="Republica Dominicana">República Dominicana</option>
<option value="Guatemala">Guatemala</option>
<option value="El Salvador">El Salvador</option>
<option value="Honduras">Honduras</option>
<option value="Nicaragua">Nicaragua</option>
<option value="Costa Rica">Costa Rica</option>
<option value="Mexico">México</option>
<option value="Colombia">Colombia</option>
<option value="Jamaica">Jamaica</option>
<option value="Panama">Panamá</option>
<option value="Trinidad and Tobago">Trinidad and Tobago</option>
</select>
</div>
</div>
<div className="divider"></div>
@@ -423,9 +597,9 @@ export default function IdCardGenerator() {
<div className="panel-section-title">Exportar Lote</div>
<button
className="export-btn blue"
disabled={bulkDownloadStatus.processing || bulkEmployees.length === 0}
disabled={bulkDownloadStatus.processing || bulkEmployees.length === 0 || !bulkCountry.trim()}
onClick={triggerBulkDownload}
style={{ opacity: bulkEmployees.length === 0 ? 0.6 : 1 }}
style={{ opacity: (bulkEmployees.length === 0 || !bulkCountry.trim()) ? 0.6 : 1 }}
>
{bulkDownloadStatus.text}
</button>
@@ -445,14 +619,53 @@ export default function IdCardGenerator() {
<label>Nombre</label>
<input type="text" placeholder="Ej: Alexi Zabala" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="field">
<label>Departamento</label>
<input type="text" placeholder="Ej: Mercadeo" value={department} onChange={(e) => setDepartment(e.target.value)} />
</div>
<div className="field">
<label>Puesto</label>
<input type="text" placeholder="Ej: Mercaderista" value={role} onChange={(e) => setRole(e.target.value)} />
</div>
<div className="field">
<label>País</label>
<select value={country} onChange={(e) => setCountry(e.target.value)}>
<option value="">Selecciona un país</option>
<option value="Republica Dominicana">República Dominicana</option>
<option value="Guatemala">Guatemala</option>
<option value="El Salvador">El Salvador</option>
<option value="Honduras">Honduras</option>
<option value="Nicaragua">Nicaragua</option>
<option value="Costa Rica">Costa Rica</option>
<option value="Mexico">México</option>
<option value="Colombia">Colombia</option>
<option value="Jamaica">Jamaica</option>
<option value="Panama">Panamá</option>
<option value="Trinidad and Tobago">Trinidad and Tobago</option>
</select>
</div>
<div className="upload-btn" onClick={() => !photoProcessing && fileInputRef.current.click()} style={{ opacity: photoProcessing ? 0.6 : 1, cursor: photoProcessing ? 'wait' : 'pointer' }}>
{photoProcessing ? '⏳ Procesando foto con IA...' : 'Subir foto del colaborador'}
</div>
<input type="file" accept="image/*" style={{ display: 'none' }} ref={fileInputRef} onChange={handlePhotoUpload} />
{pendingPhotoFile && !photoProcessing && (
<>
<div style={{
fontSize: '11px', fontWeight: '600', color: '#E65100',
background: '#FFF3E0', border: '1px solid #FFCC80',
borderRadius: '4px', padding: '7px 10px'
}}>
La foto no pudo mejorarse. Inténtalo de nuevo.
</div>
<button
className="export-btn"
style={{ background: '#E65100' }}
onClick={handleRetryPhotoAI}
>
Reintentar mejora de foto
</button>
</>
)}
</div>
<div className="divider"></div>
@@ -555,6 +768,84 @@ export default function IdCardGenerator() {
</div>
</div>
</div>
<button
className={`report-fab ${fabAttention ? 'attention' : ''}`}
onClick={() => {
clearTimeout(fabAttentionTimeoutRef.current);
setFabAttention(false);
clearTimeout(reportCloseTimeoutRef.current);
setReportMessage('');
setReportEmail('');
setReportSent(false);
setShowReportModal(true);
}}
aria-label="Reportar error o pedir ayuda"
title="Reportar error o pedir ayuda"
>
<span className="report-fab-label">Reportar</span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</button>
{showReportModal && (
<div className="report-modal-overlay" onClick={() => { if (!reportSending && !reportSent) setShowReportModal(false); }}>
<div className="report-modal" onClick={(e) => e.stopPropagation()}>
{reportSent ? (
<div className="report-modal-success">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#6CC24A" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="8 12.5 11 15.5 16 9.5" />
</svg>
<p>Reporte enviado</p>
</div>
) : (
<>
<div className="report-modal-header">
<h3>Reportar error / Pedir ayuda</h3>
<button className="report-modal-close" onClick={() => { if (!reportSending) setShowReportModal(false); }} aria-label="Cerrar" disabled={reportSending}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="report-modal-body">
<p className="report-modal-hint" style={{ marginBottom: '8px' }}>Correo electrónico</p>
<input
type="email"
placeholder="Ej: correo@ejemplo.com"
value={reportEmail}
onChange={(e) => setReportEmail(e.target.value)}
disabled={reportSending}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #cbd5e1', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box' }}
/>
<p className="report-modal-hint">Describe el problema o tu solicitud:</p>
<textarea
className="report-modal-textarea"
value={reportMessage}
onChange={(e) => setReportMessage(e.target.value)}
placeholder="Ej: La foto no se procesa correctamente, el QR no se genera, necesito agregar un cliente nuevo, etc."
rows="5"
disabled={reportSending}
/>
</div>
<div className="report-modal-footer">
<button className="report-modal-cancel" onClick={() => { setShowReportModal(false); setReportMessage(''); setReportEmail(''); }} disabled={reportSending}>
Cancelar
</button>
<button className="report-modal-send" onClick={handleSendReport} disabled={!reportMessage.trim() || !reportEmail.trim() || reportSending}>
{reportSending ? '🔄 Enviando...' : 'Enviar'}
</button>
</div>
</>
)}
</div>
</div>
)}
</>
);
}
@@ -36,7 +36,8 @@ export default function Login() {
});
if (authError) {
setError(authError.message);
console.error('Error de autenticación:', authError);
setError('No pudimos iniciar sesión. Verifica tu conexión e inténtalo de nuevo.');
setLoading(false);
}
};
@@ -1,6 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
@@ -57,7 +57,7 @@ export function convertDriveUrlToDirect(url) {
return `https://drive.google.com/thumbnail?id=${match[1]}&sz=w1000`;
}
} catch (e) {
console.error("Error convirtiendo la URL de Drive:", e);
// ignore
}
return url;
}
@@ -70,7 +70,7 @@ export async function fetchImageAsBlob(url) {
const response = await fetch(url);
if (response.ok) return await response.blob();
} catch (e) {
console.error("Error al obtener Blob local de URL blob/data:", e);
// ignore
}
return null;
}
@@ -80,7 +80,7 @@ export async function fetchImageAsBlob(url) {
const response = await fetch(url);
if (response.ok) return await response.blob();
} catch {
console.warn("No se pudo obtener el Blob directo de Drive debido a restricciones CORS.");
// CORS blocked
}
return null;
}
@@ -91,7 +91,6 @@ export async function fetchImageAsBlob(url) {
if (!response.ok) throw new Error("Error en respuesta de red proxy");
return await response.blob();
} catch (e) {
console.error("Error haciendo fetch con bypass CORS a la imagen externa:", e);
try {
const response = await fetch(url, { mode: 'cors' });
return await response.blob();
@@ -132,7 +131,7 @@ export async function renderQRCode(employeeId, canvasElement) {
document.body.appendChild(tmp);
new QRCodeLib(tmp, {
text: `http://localhost:5173/empleado-id/${cleanId}`,
text: `https://digitalcompass.agency/empleado?id=${cleanId}`,
width: 55,
height: 55,
colorDark: '#000000',
@@ -269,7 +268,7 @@ export async function downloadIDCards(config) {
const backCanvas = await html2canvasLib(vBack, renderOpts);
executeFileDownload(backCanvas.toDataURL('image/png'), `${cleanName}Reverso.png`);
} catch (error) {
console.error('Error generando las vistas en alta resolución:', error);
// silent
} finally {
if (sandbox.parentNode) document.body.removeChild(sandbox);
if (onStateChange) onStateChange({ processing: false, text: '⬇ Descargar' });
@@ -334,7 +333,7 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, o
}
}
} catch (err) {
console.error(`Error guardando foto en Supabase para Cédula: ${strictCleanId}`, err);
// silent
}
}
@@ -1,40 +1,34 @@
import { supabase } from "./supabase";
/**
* Registra el cliente/proyecto de un carnet descargado en la tabla
* Registra los datos de un carnet descargado en la tabla
* "carnet_empleados_creados_glm".
*
* @param {string} employeeNumber - Número de cédula limpio (sin guiones/espacios)
* @param {string} clienteProyecto - Nombre del cliente/proyecto seleccionado
* @param {string} division - Cliente/Proyecto seleccionado
* @param {string} departamento - Departamento del empleado
* @param {string} posicion - Puesto del empleado
* @param {string} pais - País del empleado
* @returns {Promise<void>}
*/
export async function saveClienteProyecto(employeeNumber, clienteProyecto) {
export async function saveClienteProyecto(employeeNumber, division = '', departamento = '', posicion = '', pais = '') {
const cleanCedula = String(employeeNumber || '').replace(/[-\s]/g, '');
const valorCliente = clienteProyecto || 'Generico';
console.log(`[Supabase] → saveClienteProyecto llamada con: cedula="${cleanCedula}", cliente_proyecto="${valorCliente}"`);
if (!cleanCedula) {
console.warn('[Supabase] saveClienteProyecto abortada: cédula vacía.');
return;
throw new Error('Cédula vacía, no se puede guardar.');
}
// CORRECCIÓN: El objeto debe llevar la cédula para que upsert funcione por conflicto
const { data, error } = await supabase
.from('carnet_empleados_creados_glm')
.upsert(
{
cedula: cleanCedula,
cliente_proyecto: valorCliente
},
{ onConflict: 'cedula' }
)
.select();
const { error } = await supabase.rpc('upsert_carnet_cliente', {
p_cedula: cleanCedula,
p_cliente_proyecto: division || 'Generico',
p_division: division,
p_departamento: departamento,
p_posicion: posicion,
p_pais: pais
});
if (error) {
console.error(`[Supabase] ❌ Error guardando cliente_proyecto para cédula ${cleanCedula}:`, error);
} else {
console.log(`[Supabase] ✅ Guardado exitoso carnet_empleados_creados_glm:`, data);
throw error;
}
}
+2
View File
@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=https://tudominio
VITE_SUPABASE_ANON_KEY=J5JS7HG...
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
body{background:#f5f5f5;margin:0;font-family:Arial,sans-serif}.page{justify-content:center;align-items:center;min-height:10vh;padding:40px;display:flex}.page.loading,.page.not-found{text-align:center;min-height:10vh}.page.loading h2,.page.not-found h1{color:#333;margin:0}.page.not-found h1{color:#ef4444}.employee-card{background:#fff;border-radius:12px;width:350px;max-width:100%;margin:auto;overflow:hidden;box-shadow:0 10px 30px #0000001a}.header{color:#fff;text-align:center;padding:20px;font-size:28px;font-weight:700}.content{text-align:center;padding:30px}.label{color:#666;margin-top:16px;font-size:14px}.value{font-size:18px;font-weight:700}.employee-photo{object-fit:cover;border:4px solid #e5e5e5;border-radius:50%;width:170px;height:170px;margin:0 auto 25px;display:block}.employee-photo-placeholder{color:#777;background:#f0f0f0;border:4px solid #e5e5e5;border-radius:50%;justify-content:center;align-items:center;width:170px;height:170px;margin:0 auto 25px;font-weight:700;display:flex}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>glm-card-qr</title>
<script type="module" crossorigin src="/empleado/assets/index-VXZlFSJm.js"></script>
<link rel="stylesheet" crossorigin href="/empleado/assets/index-YseJQpQ5.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>glm-card-generator</title>
<title>glm-card-qr</title>
</head>
<body>
<div id="root"></div>
+1520
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "glm-card-qr",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.45.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"vite": "^8.1.1"
}
}
+20
View File
@@ -0,0 +1,20 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
import EmployeeCard from "./screen/EmployeeCard";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route
path="/empleado"
element={
<EmployeeCard />
}
/>
<Route path="*" element={<Navigate to="/empleado" replace />} />
</Routes>
</BrowserRouter>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -5,9 +5,29 @@ body {
}
.page {
min-height: 10vh;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.page.loading,
.page.not-found {
min-height: 10vh;
text-align: center;
}
.page.loading h2,
.page.not-found h1 {
margin: 0;
color: #333;
}
.page.not-found h1 {
color: #ef4444;
}
.employee-card {
width: 350px;
max-width: 100%;
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { supabase } from "../services/supabase";
import "./EmployeeCard.css";
function toDriveDirectUrl(url) {
if (!url) return url;
// Detectamos si es una URL de Google Drive
if (url.includes("drive.google.com")) {
// Expresión regular para extraer el ID
const match = url.match(/(?:file\/d\/|id=)([a-zA-Z0-9_-]+)/);
if (match) {
const fileId = match[1];
// Usamos el endpoint de thumbnail que no tiene problemas de CORS
return `https://drive.google.com/thumbnail?id=${fileId}&sz=w1000`;
}
}
// Si es de BambooHR u otra URL, la devolvemos tal cual
return url;
}
export default function EmployeeCard() {
const [searchParams] = useSearchParams();
const employeeNumber = searchParams.get("id");
const [employee, setEmployee] = useState(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
async function loadEmployee() {
const { data, error } = await supabase
.rpc("get_carnet_publico", { p_cedula: employeeNumber });
if (error || !data || data.length === 0) {
setNotFound(true);
} else {
setEmployee(data[0]);
}
setLoading(false);
}
loadEmployee();
}, [employeeNumber]);
if (loading) {
return (
<div className="page loading">
<h2>Cargando...</h2>
</div>
);
}
if (notFound) {
return (
<div className="page not-found">
<h1>Empleado no encontrado</h1>
</div>
);
}
console.log("1. URL original de Supabase:", employee.photo_url);
// Procesamos la URL final directamente aquí
const finalPhotoSrc = toDriveDirectUrl(employee.photo_url);
console.log("2. URL final a renderizar:", finalPhotoSrc);
const active = employee.status?.toLowerCase() === "active";
return (
<div className="page">
<div className="employee-card">
<div
className="header"
style={{
backgroundColor: active ? "#22c55e" : "#ef4444"
}}
>
{active ? "Empleado Activo" : "Empleado No Activo"}
</div>
<div className="content">
{employee.photo_url ? (
<img
src={finalPhotoSrc} // <-- CORREGIDO: Usamos la variable ya procesada directamente
alt={employee.name}
style={{
width: "150px",
height: "150px",
objectFit: "cover",
borderRadius: "50%",
display: "block",
margin: "0 auto"
}}
className="employee-photo"
/>
) : (
<div className="employee-photo-placeholder">
Sin foto
</div>
)}
<div className="label">Nombre</div>
<div className="value">{employee.name}</div>
<div className="label">Puesto</div>
<div className="value">{employee.job_title}</div>
<div className="label">País</div>
<div className="value">{employee.country}</div>
<div className="label">Estado</div>
<div className="value">{employee.status}</div>
</div>
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = supabaseUrl && supabaseAnonKey
? createClient(supabaseUrl, supabaseAnonKey)
: null;
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/empleado'
})
File diff suppressed because it is too large Load Diff
-127
View File
@@ -1,127 +0,0 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { supabase } from "../services/supabase";
import "./EmployeeCard.css";
export default function EmployeeCard() {
const { employeeNumber } = useParams();
const [employee, setEmployee] = useState(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
async function loadEmployee() {
const { data, error } = await supabase
.from("empleados_glm")
.select("photo_url, name, job_title, country, status")
.eq("cedula", employeeNumber)
.single();
if (error || !data) {
setNotFound(true);
} else {
setEmployee(data);
}
setLoading(false);
}
loadEmployee();
}, [employeeNumber]);
if (loading) {
return (
<div className="page">
<h2>Cargando...</h2>
</div>
);
}
if (notFound) {
return (
<div className="page">
<h1 style={{ color: "red" }}>
Empleado no encontrado
</h1>
</div>
);
}
const active = employee.status?.toLowerCase() === "active";
return (
<div className="page">
<div className="employee-card">
<div
className="header"
style={{
backgroundColor: active
? "#22c55e"
: "#ef4444"
}}
>
{active
? "Empleado Activo"
: "Empleado No Activo"}
</div>
<div className="content">
{employee.photo_url ? (
<img
src={employee.photo_url}
alt={employee.name}
className="employee-photo"
/>
) : (
<div className="employee-photo-placeholder">
Sin foto
</div>
)}
<div className="label">
Nombre
</div>
<div className="value">
{employee.name}
</div>
<div className="label">
Puesto
</div>
<div className="value">
{employee.job_title}
</div>
<div className="label">
País
</div>
<div className="value">
{employee.country}
</div>
<div className="label">
Estado
</div>
<div className="value">
{employee.status}
</div>
</div>
</div>
</div>
);
}
View File