Compare commits
9 Commits
dc2fa3e681
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ffe62f919c | |||
| d866a261d1 | |||
| 2b74335e03 | |||
| 9e10699faa | |||
| ee7a673d02 | |||
| e3980615b6 | |||
| 85ffe635cd | |||
| c736e43730 | |||
| de83f8984a |
@@ -1,326 +1,150 @@
|
|||||||
# GLM ID Card Generator
|
# 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 |
|
### Requisitos
|
||||||
|---|---|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
- Node.js >= 18
|
||||||
|
- npm
|
||||||
|
|
||||||
## Objetivo
|
### 1. Clonar el repositorio
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clonar el repo
|
git clone <repo-url>
|
||||||
git clone https://gitea.glm.com/glm-it/glm-id-card-generator
|
cd 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Distribucion del generador HTML
|
### 2. Configurar variables de entorno
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test unitario del endpoint
|
# Para el generador de IDs
|
||||||
cd endpoint/
|
cp glm-card-generator/.env.example glm-card-generator/.env
|
||||||
python -m pytest tests/
|
|
||||||
|
|
||||||
# Test de integracion — verificacion real contra BambooHR
|
# Para el visor QR
|
||||||
python tests/test_bamboohr_integration.py
|
cp glm-card-qr/.env.example glm-card-qr/.env
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Casos de prueba minimos
|
Editar los archivos `.env` con los valores correspondientes.
|
||||||
|
|
||||||
| Caso | Input | Output esperado | Estado |
|
### 3. Instalar dependencias y ejecutar
|
||||||
|---|---|---|---|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
**Generador de IDs:**
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd glm-card-generator
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
/glm-id-card-generator
|
|
||||||
├── README.md <- Este archivo
|
Abre en: `http://localhost:5173/empleado-id/`
|
||||||
├── glm_id_card.html <- Generador de carnets (distribuir a RRHH)
|
|
||||||
├── /assets
|
**Visor QR:**
|
||||||
│ ├── /logos-clientes <- Logos PNG de clientes precargados (aprobados por RRHH)
|
|
||||||
│ └── glm-logo.png <- Logo GLM para el generador
|
```bash
|
||||||
├── /endpoint
|
cd glm-card-qr
|
||||||
│ ├── main.py <- Punto de entrada del servicio de verificacion
|
npm install
|
||||||
│ ├── requirements.txt <- Dependencias Python del endpoint
|
npm run dev
|
||||||
│ ├── .env.example <- Variables de entorno de ejemplo (sin valores reales)
|
```
|
||||||
│ ├── Dockerfile <- Para deploy en Google Cloud Run
|
|
||||||
│ └── /tests
|
Abre en: `http://localhost:5173/empleado`
|
||||||
│ ├── test_endpoint.py <- Tests unitarios del endpoint
|
|
||||||
│ └── test_bamboohr_integration.py <- Tests de integracion con BambooHR API
|
### 4. Build para producción
|
||||||
├── /batch
|
|
||||||
│ └── ejemplo_colaboradores.csv <- CSV de ejemplo con el formato requerido para batch
|
```bash
|
||||||
├── /docs
|
# Generador
|
||||||
│ ├── guia-uso-rrhh.pdf <- Guia de uso para operadores de RRHH
|
cd glm-card-generator
|
||||||
│ └── architecture.png <- Diagrama de arquitectura del sistema
|
npm run build
|
||||||
├── CHANGELOG.md <- Historial de cambios
|
# El output está en glm-card-generator/dist/
|
||||||
└── DECISIONS.md <- Log de decisiones tecnicas
|
|
||||||
|
# Visor QR
|
||||||
|
cd glm-card-qr
|
||||||
|
npm run build
|
||||||
|
# El output está en glm-card-qr/dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CHANGELOG
|
## Variables de entorno
|
||||||
|
|
||||||
```
|
### `glm-card-generator/.env.example`
|
||||||
[2026-07-28] v1.0 — Launch inicial en produccion
|
|
||||||
|
```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
|
| Variable | Descripción |
|
||||||
- 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).
|
| `VITE_SUPABASE_URL` | URL del proyecto Supabase |
|
||||||
- Decision: HTML local distribuido a RRHH en Fase 1.
|
| `VITE_SUPABASE_ANON_KEY` | Clave anónima de Supabase |
|
||||||
- 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.
|
| `VITE_WEBHOOK_TOKEN` | Token de autenticación para el webhook de n8n |
|
||||||
|
| `VITE_N8N_WEBHOOK_URL` | URL del webhook en n8n (`/webhook/carnet`) |
|
||||||
### 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contactos del proyecto
|
## Explicación del workflow de n8n
|
||||||
|
|
||||||
| Rol | Nombre | Contacto |
|
**Archivo**: `n8n-workflow/GLM ID Card Generator.json`
|
||||||
|---|---|---|
|
|
||||||
| Product Owner | Maximo Gomez | [email/WhatsApp] |
|
|
||||||
| IT Manager | Luis Matos | [email/WhatsApp] |
|
|
||||||
| Developer Principal | Eidan Then | [email/WhatsApp] |
|
|
||||||
|
|
||||||
---
|
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
|
### Credenciales usadas
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
---
|
| Tipo | Nombre |
|
||||||
|
|---|---|
|
||||||
_Documento mantenido por el equipo GLM IT · Ultima actualizacion: 2026-06-05_
|
| 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
|
||||||
@@ -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*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
@@ -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 |
|
||||||
|
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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,164 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { supabase } from "./services/supabase";
|
||||||
|
import { useEffect, useState, createContext, useContext } from "react";
|
||||||
|
import Login from "./components/Login";
|
||||||
|
import IdCardGenerator from "./Generator";
|
||||||
|
import "./components/Login.css";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
||||||
|
const ALLOWED_DOMAIN = "@gomezleemarketing.com";
|
||||||
|
const GLM_LOGO = "https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png";
|
||||||
|
|
||||||
|
function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supabase) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
|
setUser(session?.user ?? null);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
setUser(session?.user ?? null);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
if (supabase) await supabase.auth.signOut();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#EEF6E8',
|
||||||
|
fontFamily: 'Arial, sans-serif'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, border: '3px solid #D0D0D0',
|
||||||
|
borderTopColor: '#6CC24A', borderRadius: '50%',
|
||||||
|
animation: 'spin 0.8s linear infinite'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, signOut, loading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
const { user, signOut, loading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/empleado-id/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email?.endsWith(ALLOWED_DOMAIN)) {
|
||||||
|
signOut();
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#EEF6E8',
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<img src={GLM_LOGO} alt="GLM" style={{ maxWidth: 220, marginBottom: 24 }} />
|
||||||
|
<h1 style={{ color: '#4F758B', fontSize: 24, fontWeight: 700, marginBottom: 16 }}>
|
||||||
|
Acceso no autorizado
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#6B8FA3', fontSize: 16, marginBottom: 24, maxWidth: 400 }}>
|
||||||
|
Solo se permite acceso con cuentas corporativas <strong>@gomezleemarketing.com</strong>.
|
||||||
|
Tu cuenta <strong>{user.email}</strong> no tiene permisos.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={signOut}
|
||||||
|
style={{
|
||||||
|
padding: '14px 32px',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#fff',
|
||||||
|
background: '#4F758B',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 10,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cerrar sesión y volver a intentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicRoute({ children }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
if (user?.email?.endsWith(ALLOWED_DOMAIN)) {
|
||||||
|
const from = location.state?.from?.pathname || '/empleado-id/';
|
||||||
|
return <Navigate to={from} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/empleado-id/login"
|
||||||
|
element={
|
||||||
|
<PublicRoute>
|
||||||
|
<Login />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/empleado-id/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<IdCardGenerator />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/empleado-id/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -358,3 +358,266 @@ body {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
border-color: #4F758B;
|
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 { useState, useRef, useEffect } from 'react';
|
||||||
import './Generator.css';
|
import './Generator.css';
|
||||||
import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib, clientTemplates } from './script/script.jsx';
|
import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib, clientTemplates } from './script/script.jsx';
|
||||||
import { uploadEmployeePhoto, saveClienteProyecto } from './services/storage.js';
|
import { saveClienteProyecto } from './services/storage.js';
|
||||||
import { supabase } from "./services/supabase";
|
|
||||||
|
|
||||||
// Convierte datos binarios recibidos (base64, buffer, array de bytes) en un Blob URL local de forma robusta
|
// Convierte datos binarios recibidos (base64, buffer, array de bytes) en un Blob URL local de forma robusta
|
||||||
const convertBinaryToBlobUrl = (data) => {
|
const convertBinaryToBlobUrl = (data) => {
|
||||||
@@ -32,32 +31,101 @@ const convertBinaryToBlobUrl = (data) => {
|
|||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error convirtiendo formato binario a Blob URL:", e);
|
// ignore
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const friendlyError = (message) => {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.friendly = true;
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
export default function IdCardGenerator() {
|
export default function IdCardGenerator() {
|
||||||
// --- Estados formulario individual ───
|
// --- Estados formulario individual ───
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [role, setRole] = useState('');
|
const [role, setRole] = useState('');
|
||||||
|
const [department, setDepartment] = useState('');
|
||||||
|
const [country, setCountry] = useState('');
|
||||||
const [language, setLanguage] = useState('Esp');
|
const [language, setLanguage] = useState('Esp');
|
||||||
const [selectedClient, setSelectedClient] = useState('Generico');
|
const [selectedClient, setSelectedClient] = useState('Generico');
|
||||||
const [employeeId, setEmployeeId] = useState('');
|
const [employeeId, setEmployeeId] = useState('');
|
||||||
const [photoSrc, setPhotoSrc] = useState('');
|
const [photoSrc, setPhotoSrc] = useState('');
|
||||||
const [photoFile, setPhotoFile] = useState(null);
|
const [photoFile, setPhotoFile] = useState(null);
|
||||||
|
const [pendingPhotoFile, setPendingPhotoFile] = useState(null);
|
||||||
const [photoProcessing, setPhotoProcessing] = useState(false);
|
const [photoProcessing, setPhotoProcessing] = useState(false);
|
||||||
const [downloadStatus, setDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
|
const [downloadStatus, setDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
|
||||||
|
|
||||||
// Bloquea el botón Descargar hasta que nombre, puesto e ID estén llenos
|
// --- Estados Reporte de Errores/Ayuda ───
|
||||||
const isFormComplete = name.trim() && role.trim() && employeeId.trim();
|
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 ───
|
// --- Estados Lote / Excel Masivo ───
|
||||||
const [bulkEmployees, setBulkEmployees] = useState([]);
|
const [bulkEmployees, setBulkEmployees] = useState([]);
|
||||||
const [bulkStatusText, setBulkStatusText] = useState('');
|
const [bulkStatusText, setBulkStatusText] = useState('');
|
||||||
const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
|
const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
|
||||||
|
const [bulkCountry, setBulkCountry] = useState('');
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
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 fileInputRef = useRef(null);
|
||||||
const excelInputRef = useRef(null);
|
const excelInputRef = useRef(null);
|
||||||
const qrCanvasRef = useRef(null);
|
const qrCanvasRef = useRef(null);
|
||||||
@@ -72,27 +140,42 @@ export default function IdCardGenerator() {
|
|||||||
}
|
}
|
||||||
}, [employeeId]);
|
}, [employeeId]);
|
||||||
|
|
||||||
const handlePhotoUpload = async (e) => {
|
const processAndSetPhoto = async (file) => {
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setPhotoFile(file);
|
|
||||||
setPhotoSrc(URL.createObjectURL(file));
|
|
||||||
setPhotoProcessing(true);
|
setPhotoProcessing(true);
|
||||||
|
setPendingPhotoFile(file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const editedBlob = await processPhotoWithAI(file);
|
const editedBlob = await processPhotoWithAI(file);
|
||||||
const editedUrl = URL.createObjectURL(editedBlob);
|
const editedUrl = URL.createObjectURL(editedBlob);
|
||||||
setPhotoSrc(editedUrl);
|
setPhotoSrc(editedUrl);
|
||||||
setPhotoFile(new File([editedBlob], file.name, { type: editedBlob.type || 'image/jpeg' }));
|
setPhotoFile(new File([editedBlob], file.name, { type: editedBlob.type || 'image/jpeg' }));
|
||||||
|
setPendingPhotoFile(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error al procesar la foto con IA en n8n:', error);
|
console.error('Error al procesar la foto con IA:', error);
|
||||||
alert('No se pudo procesar la foto con IA. Se usará la foto original para la vista previa.');
|
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 {
|
} finally {
|
||||||
setPhotoProcessing(false);
|
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 processPhotoWithAI = async (file) => {
|
||||||
const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL;
|
const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL;
|
||||||
const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN;
|
const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN;
|
||||||
@@ -113,7 +196,8 @@ export default function IdCardGenerator() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
@@ -121,13 +205,13 @@ export default function IdCardGenerator() {
|
|||||||
const data = await response.json();
|
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 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);
|
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();
|
const blob = await (await fetch(blobUrl)).blob();
|
||||||
return blob;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.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;
|
return blob;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,13 +240,13 @@ export default function IdCardGenerator() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const resData = await response.json();
|
||||||
return Array.isArray(resData) ? resData : (resData.empleados || resData.data || []);
|
return Array.isArray(resData) ? resData : (resData.empleados || resData.data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error de comunicación con n8n:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -172,22 +256,15 @@ export default function IdCardGenerator() {
|
|||||||
if (!cleanCedula) return;
|
if (!cleanCedula) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Se usa el cliente extraído directamente del array renderizado
|
const divisionValue = currentEmployee?.division || '';
|
||||||
const clienteProyectoValue = currentEmployee?.selectedClient || 'Generico';
|
const departamentoValue = currentEmployee?.department || '';
|
||||||
await saveClienteProyecto(cleanCedula, clienteProyectoValue);
|
const posicionValue = currentEmployee?.position || '';
|
||||||
console.log(`[Supabase DB] Guardado: ${cleanCedula} → ${clienteProyectoValue}`);
|
const paisValue = bulkCountry || '';
|
||||||
|
|
||||||
const customFile = new File([blobData], `${cleanCedula}.jpg`, { type: 'image/jpeg' });
|
await saveClienteProyecto(cleanCedula, divisionValue, departamentoValue, posicionValue, paisValue);
|
||||||
const remoteStorageUrl = await uploadEmployeePhoto(cleanCedula, customFile);
|
|
||||||
|
|
||||||
if (remoteStorageUrl) {
|
|
||||||
await supabase.rpc("save_employee_photo", {
|
|
||||||
p_employee_number: cleanCedula,
|
|
||||||
p_photo_url: remoteStorageUrl
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} 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);
|
const jsonRows = XLSXLib.utils.sheet_to_json(ws);
|
||||||
|
|
||||||
if (jsonRows.length === 0) {
|
if (jsonRows.length === 0) {
|
||||||
setBulkStatusText('❌ El archivo está vacío.');
|
setBulkStatusText('❌ El archivo no tiene datos. Descarga la plantilla y agrega los colaboradores.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,26 +322,32 @@ export default function IdCardGenerator() {
|
|||||||
return {
|
return {
|
||||||
name: String(row.nombre || '').trim(),
|
name: String(row.nombre || '').trim(),
|
||||||
role: String(row.puesto || '').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,
|
selectedClient: rawClient,
|
||||||
language: rawLang,
|
language: rawLang,
|
||||||
employeeId: cleanedCedula,
|
employeeId: cleanedCedula,
|
||||||
fotoUrl: String(row.foto_url || '').trim()
|
fotoUrl: String(row.foto_url || '').trim()
|
||||||
};
|
};
|
||||||
}).filter(emp => emp.name && emp.role && emp.employeeId);
|
}).filter(emp => emp.name && emp.employeeId);
|
||||||
|
|
||||||
setBulkEmployees(formatted);
|
setBulkEmployees(formatted);
|
||||||
setBulkStatusText(`✅ ¡Cargados ${formatted.length} colaboradores! Listo para procesar.`);
|
setBulkStatusText(`✅ ¡Cargados ${formatted.length} colaboradores! Listo para procesar.`);
|
||||||
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
|
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
|
||||||
|
|
||||||
} catch (innerErr) {
|
} catch (innerErr) {
|
||||||
console.error(innerErr);
|
console.error('Error leyendo el Excel:', innerErr);
|
||||||
setBulkStatusText('❌ Error de lectura. Revisa el formato de columnas.');
|
triggerFabAttention();
|
||||||
|
setBulkStatusText('❌ No pudimos leer el archivo. Descarga la plantilla oficial y verifica las columnas.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsBinaryString(file);
|
reader.readAsBinaryString(file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Error cargando la librería XLSX:', err);
|
||||||
setBulkStatusText('❌ Error cargando el motor XLSX.');
|
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 {
|
return {
|
||||||
name: n8nEmp.name || originalEmp.name,
|
name: n8nEmp.name || originalEmp.name,
|
||||||
role: n8nEmp.role || originalEmp.role,
|
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,
|
selectedClient: validClient,
|
||||||
language: validLang,
|
language: validLang,
|
||||||
employeeId: n8nEmp.employeeId || originalEmp.employeeId,
|
employeeId: n8nEmp.employeeId || originalEmp.employeeId,
|
||||||
@@ -301,19 +388,19 @@ export default function IdCardGenerator() {
|
|||||||
employees: employeesReadyForRender,
|
employees: employeesReadyForRender,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
onStateChange: null,
|
onStateChange: null,
|
||||||
// CORRECCIÓN AQUÍ: Buscamos al empleado en nuestro arreglo usando la cédula que nos devuelve script.jsx
|
|
||||||
onProcessEmployeePhoto: (cedula, blobData) => {
|
onProcessEmployeePhoto: (cedula, blobData) => {
|
||||||
const currentEmp = employeesReadyForRender.find(emp => emp.employeeId === cedula);
|
const currentEmp = employeesReadyForRender.find(emp => emp.employeeId === cedula);
|
||||||
return handleBulkSupabaseSync(cedula, blobData, currentEmp);
|
return handleBulkSupabaseSync(cedula, blobData, currentEmp);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setBulkStatusText('🎉 ¡Lote de carnets guardado correctamente!');
|
setBulkStatusText(`✅ ¡Lote completado! ${employeesReadyForRender.length} carnets procesados.`);
|
||||||
setBulkEmployees([]);
|
setBulkEmployees([]);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error('Error al procesar el lote:', error);
|
||||||
setBulkStatusText(`❌ Error al procesar: ${error.message || 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 {
|
} finally {
|
||||||
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
|
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
|
||||||
}
|
}
|
||||||
@@ -321,17 +408,45 @@ export default function IdCardGenerator() {
|
|||||||
|
|
||||||
const triggerDownload = async () => {
|
const triggerDownload = async () => {
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
setDownloadStatus({ processing: true, text: '🔄 Descargando...' });
|
|
||||||
try {
|
|
||||||
await saveClienteProyecto(employeeId, selectedClient);
|
|
||||||
|
|
||||||
if (photoFile) {
|
setDownloadStatus({ processing: true, text: '🔄 Descargando...' });
|
||||||
const photoUrl = await uploadEmployeePhoto(employeeId, photoFile);
|
|
||||||
await supabase.rpc("save_employee_photo", {
|
try {
|
||||||
p_employee_number: employeeId,
|
// Enviar binario (base64) + employeeId como JSON al webhook n8n
|
||||||
p_photo_url: photoUrl
|
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({
|
await downloadIDCards({
|
||||||
name, role, selectedClient, language, photoSrc,
|
name, role, selectedClient, language, photoSrc,
|
||||||
@@ -339,18 +454,40 @@ export default function IdCardGenerator() {
|
|||||||
qrCanvas: qrCanvasRef.current,
|
qrCanvas: qrCanvasRef.current,
|
||||||
onStateChange: setDownloadStatus
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Error al generar el carnet:', err);
|
||||||
alert(err.message);
|
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' });
|
setDownloadStatus({ processing: false, text: '⬇ Descargar' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
if (!name.trim()) { alert("Debe ingresar el nombre."); return false; }
|
if (!name.trim()) { alert("Escribe el nombre del colaborador para continuar."); return false; }
|
||||||
if (!role.trim()) { alert("Debe ingresar el puesto."); return false; }
|
if (!department.trim()) { alert("Escribe el departamento del colaborador para continuar."); return false; }
|
||||||
if (!employeeId.trim()) { alert("Debe ingresar el ID del empleado."); return false; }
|
if (!role.trim()) { alert("Escribe el puesto del colaborador para continuar."); return false; }
|
||||||
if (!photoFile) { alert("Debe subir la foto del colaborador."); 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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,12 +531,30 @@ export default function IdCardGenerator() {
|
|||||||
fontWeight: 'normal', textTransform: 'none'
|
fontWeight: 'normal', textTransform: 'none'
|
||||||
}}>
|
}}>
|
||||||
Formato de columnas requeridas en el Excel: <br />
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="upload-btn" style={{ background: '#475569', marginTop: '12px' }} onClick={() => excelInputRef.current.click()}>
|
<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">
|
<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" />
|
<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" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
@@ -409,12 +564,31 @@ export default function IdCardGenerator() {
|
|||||||
Seleccionar Archivo Excel
|
Seleccionar Archivo Excel
|
||||||
</div>
|
</div>
|
||||||
<input type="file" accept=".xlsx, .xls, .csv" style={{ display: 'none' }} ref={excelInputRef} onChange={handleExcelUpload} />
|
<input type="file" accept=".xlsx, .xls, .csv" style={{ display: 'none' }} ref={excelInputRef} onChange={handleExcelUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{bulkStatusText && (
|
{bulkStatusText && (
|
||||||
<div style={{ fontSize: '12px', marginTop: '8px', fontWeight: '500', color: '#475569' }}>
|
<div style={{ fontSize: '12px', marginTop: '8px', fontWeight: '500', color: '#475569' }}>
|
||||||
{bulkStatusText}
|
{bulkStatusText}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="divider"></div>
|
<div className="divider"></div>
|
||||||
@@ -423,9 +597,9 @@ export default function IdCardGenerator() {
|
|||||||
<div className="panel-section-title">Exportar Lote</div>
|
<div className="panel-section-title">Exportar Lote</div>
|
||||||
<button
|
<button
|
||||||
className="export-btn blue"
|
className="export-btn blue"
|
||||||
disabled={bulkDownloadStatus.processing || bulkEmployees.length === 0}
|
disabled={bulkDownloadStatus.processing || bulkEmployees.length === 0 || !bulkCountry.trim()}
|
||||||
onClick={triggerBulkDownload}
|
onClick={triggerBulkDownload}
|
||||||
style={{ opacity: bulkEmployees.length === 0 ? 0.6 : 1 }}
|
style={{ opacity: (bulkEmployees.length === 0 || !bulkCountry.trim()) ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
{bulkDownloadStatus.text}
|
{bulkDownloadStatus.text}
|
||||||
</button>
|
</button>
|
||||||
@@ -445,14 +619,53 @@ export default function IdCardGenerator() {
|
|||||||
<label>Nombre</label>
|
<label>Nombre</label>
|
||||||
<input type="text" placeholder="Ej: Alexi Zabala" value={name} onChange={(e) => setName(e.target.value)} />
|
<input type="text" placeholder="Ej: Alexi Zabala" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</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">
|
<div className="field">
|
||||||
<label>Puesto</label>
|
<label>Puesto</label>
|
||||||
<input type="text" placeholder="Ej: Mercaderista" value={role} onChange={(e) => setRole(e.target.value)} />
|
<input type="text" placeholder="Ej: Mercaderista" value={role} onChange={(e) => setRole(e.target.value)} />
|
||||||
</div>
|
</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' }}>
|
<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'}
|
{photoProcessing ? '⏳ Procesando foto con IA...' : 'Subir foto del colaborador'}
|
||||||
</div>
|
</div>
|
||||||
<input type="file" accept="image/*" style={{ display: 'none' }} ref={fileInputRef} onChange={handlePhotoUpload} />
|
<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>
|
||||||
|
|
||||||
<div className="divider"></div>
|
<div className="divider"></div>
|
||||||
@@ -555,6 +768,84 @@ export default function IdCardGenerator() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--azul-glm: #4F758B;
|
||||||
|
--azul-medio: #5B7F95;
|
||||||
|
--acero-claro: #6B8FA3;
|
||||||
|
--verde-glm: #6CC24A;
|
||||||
|
--verde-claro: #A4D65E;
|
||||||
|
--verde-bg: #EEF6E8;
|
||||||
|
--azul-seccion: #D6E8F4;
|
||||||
|
--gris-oscuro: #4A4A4A;
|
||||||
|
--gris-medio: #D0D0D0;
|
||||||
|
--gris-claro: #F5F5F5;
|
||||||
|
--blanco: #FFFFFF;
|
||||||
|
--naranja: #FF6A13;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page.loading .login-card {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--blanco);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(79, 117, 139, 0.12);
|
||||||
|
padding: 48px 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid var(--gris-medio);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 180px;
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--azul-glm);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider {
|
||||||
|
height: 2px;
|
||||||
|
background: var(--verde-glm);
|
||||||
|
margin: 24px 0;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: var(--blanco);
|
||||||
|
border: 2px solid var(--gris-medio);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gris-oscuro);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google:hover:not(:disabled) {
|
||||||
|
border-color: var(--verde-glm);
|
||||||
|
background: var(--verde-bg);
|
||||||
|
color: var(--azul-glm);
|
||||||
|
box-shadow: 0 4px 16px rgba(108, 194, 74, 0.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-hint {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--acero-claro);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #FDECEA;
|
||||||
|
border: 1px solid #F5C6CB;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #C0392B;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--gris-medio);
|
||||||
|
border-top-color: var(--verde-glm);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 16px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-card {
|
||||||
|
padding: 36px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { supabase } from '../services/supabase';
|
||||||
|
import './Login.css';
|
||||||
|
|
||||||
|
const ALLOWED_DOMAIN = '@gomezleemarketing.com';
|
||||||
|
const GLM_LOGO = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
|
if (session?.user?.email?.endsWith(ALLOWED_DOMAIN)) {
|
||||||
|
navigate('/empleado-id/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleGoogleLogin = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const redirectUrl = `${window.location.origin}/empleado-id/`;
|
||||||
|
|
||||||
|
const { error: authError } = await supabase.auth.signInWithOAuth({
|
||||||
|
provider: 'google',
|
||||||
|
options: {
|
||||||
|
redirectTo: redirectUrl,
|
||||||
|
queryParams: {
|
||||||
|
hd: 'gomezleemarketing.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
console.error('Error de autenticación:', authError);
|
||||||
|
setError('No pudimos iniciar sesión. Verifica tu conexión e inténtalo de nuevo.');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="login-page loading">
|
||||||
|
<div className="login-card">
|
||||||
|
<img src={GLM_LOGO} alt="GLM" className="logo" />
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<img src={GLM_LOGO} alt="GomezLee Marketing" className="logo" />
|
||||||
|
|
||||||
|
<h1 className="title">GLM ID Card Generator</h1>
|
||||||
|
|
||||||
|
<div className="login-divider" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-google"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<svg className="google-icon" viewBox="0 0 24 24" width="20" height="20">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Continuar con Google</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="domain-hint">Solo cuentas @gomezleemarketing.com</p>
|
||||||
|
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
@@ -57,7 +57,7 @@ export function convertDriveUrlToDirect(url) {
|
|||||||
return `https://drive.google.com/thumbnail?id=${match[1]}&sz=w1000`;
|
return `https://drive.google.com/thumbnail?id=${match[1]}&sz=w1000`;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error convirtiendo la URL de Drive:", e);
|
// ignore
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ export async function fetchImageAsBlob(url) {
|
|||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (response.ok) return await response.blob();
|
if (response.ok) return await response.blob();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error al obtener Blob local de URL blob/data:", e);
|
// ignore
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ export async function fetchImageAsBlob(url) {
|
|||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (response.ok) return await response.blob();
|
if (response.ok) return await response.blob();
|
||||||
} catch {
|
} catch {
|
||||||
console.warn("No se pudo obtener el Blob directo de Drive debido a restricciones CORS.");
|
// CORS blocked
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,6 @@ export async function fetchImageAsBlob(url) {
|
|||||||
if (!response.ok) throw new Error("Error en respuesta de red proxy");
|
if (!response.ok) throw new Error("Error en respuesta de red proxy");
|
||||||
return await response.blob();
|
return await response.blob();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error haciendo fetch con bypass CORS a la imagen externa:", e);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { mode: 'cors' });
|
const response = await fetch(url, { mode: 'cors' });
|
||||||
return await response.blob();
|
return await response.blob();
|
||||||
@@ -132,7 +131,7 @@ export async function renderQRCode(employeeId, canvasElement) {
|
|||||||
document.body.appendChild(tmp);
|
document.body.appendChild(tmp);
|
||||||
|
|
||||||
new QRCodeLib(tmp, {
|
new QRCodeLib(tmp, {
|
||||||
text: `http://localhost:5173/empleado-id/${cleanId}`,
|
text: `https://digitalcompass.agency/empleado?id=${cleanId}`,
|
||||||
width: 55,
|
width: 55,
|
||||||
height: 55,
|
height: 55,
|
||||||
colorDark: '#000000',
|
colorDark: '#000000',
|
||||||
@@ -269,7 +268,7 @@ export async function downloadIDCards(config) {
|
|||||||
const backCanvas = await html2canvasLib(vBack, renderOpts);
|
const backCanvas = await html2canvasLib(vBack, renderOpts);
|
||||||
executeFileDownload(backCanvas.toDataURL('image/png'), `${cleanName}Reverso.png`);
|
executeFileDownload(backCanvas.toDataURL('image/png'), `${cleanName}Reverso.png`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generando las vistas en alta resolución:', error);
|
// silent
|
||||||
} finally {
|
} finally {
|
||||||
if (sandbox.parentNode) document.body.removeChild(sandbox);
|
if (sandbox.parentNode) document.body.removeChild(sandbox);
|
||||||
if (onStateChange) onStateChange({ processing: false, text: '⬇ Descargar' });
|
if (onStateChange) onStateChange({ processing: false, text: '⬇ Descargar' });
|
||||||
@@ -334,7 +333,7 @@ export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error guardando foto en Supabase para Cédula: ${strictCleanId}`, err);
|
// silent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,40 +1,34 @@
|
|||||||
import { supabase } from "./supabase";
|
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".
|
* "carnet_empleados_creados_glm".
|
||||||
*
|
*
|
||||||
* @param {string} employeeNumber - Número de cédula limpio (sin guiones/espacios)
|
* @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>}
|
* @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 cleanCedula = String(employeeNumber || '').replace(/[-\s]/g, '');
|
||||||
const valorCliente = clienteProyecto || 'Generico';
|
|
||||||
|
|
||||||
console.log(`[Supabase] → saveClienteProyecto llamada con: cedula="${cleanCedula}", cliente_proyecto="${valorCliente}"`);
|
|
||||||
|
|
||||||
if (!cleanCedula) {
|
if (!cleanCedula) {
|
||||||
console.warn('[Supabase] saveClienteProyecto abortada: cédula vacía.');
|
throw new Error('Cédula vacía, no se puede guardar.');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORRECCIÓN: El objeto debe llevar la cédula para que upsert funcione por conflicto
|
const { error } = await supabase.rpc('upsert_carnet_cliente', {
|
||||||
const { data, error } = await supabase
|
p_cedula: cleanCedula,
|
||||||
.from('carnet_empleados_creados_glm')
|
p_cliente_proyecto: division || 'Generico',
|
||||||
.upsert(
|
p_division: division,
|
||||||
{
|
p_departamento: departamento,
|
||||||
cedula: cleanCedula,
|
p_posicion: posicion,
|
||||||
cliente_proyecto: valorCliente
|
p_pais: pais
|
||||||
},
|
});
|
||||||
{ onConflict: 'cedula' }
|
|
||||||
)
|
|
||||||
.select();
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(`[Supabase] ❌ Error guardando cliente_proyecto para cédula ${cleanCedula}:`, error);
|
throw error;
|
||||||
} else {
|
|
||||||
console.log(`[Supabase] ✅ Guardado exitoso carnet_empleados_creados_glm:`, data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
// Single client for auth (uses anon key)
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_SUPABASE_URL=https://tudominio
|
||||||
|
VITE_SUPABASE_ANON_KEY=J5JS7HG...
|
||||||
@@ -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
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>glm-card-generator</title>
|
<title>glm-card-qr</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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 {
|
.page {
|
||||||
|
min-height: 10vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
padding: 40px;
|
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 {
|
.employee-card {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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'
|
||||||
|
})
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|
||||||
import EmployeeCard from "./components/EmployeeCard";
|
|
||||||
import IdCardGenerator from "./Generator";
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
|
|
||||||
return (
|
|
||||||
|
|
||||||
<BrowserRouter>
|
|
||||||
|
|
||||||
<Routes>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/empleado-id/"
|
|
||||||
element={<IdCardGenerator />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/empleado-id/:employeeNumber"
|
|
||||||
element={<EmployeeCard />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</Routes>
|
|
||||||
|
|
||||||
</BrowserRouter>
|
|
||||||
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createClient } from "@supabase/supabase-js";
|
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
|
||||||
const supabaseKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
|
||||||