Estructura correcta: subida desde la raíz

This commit is contained in:
2026-07-16 16:09:23 -04:00
commit de83f8984a
85 changed files with 7301 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
+130
View File
@@ -0,0 +1,130 @@
# Manual de Uso — GLM ID Card Generator
## ¿Qué hace esta aplicación?
Genera carnets corporativos **CR80** (tamaño tarjeta de crédito) para los colaboradores de GomezLee Marketing. El sistema crea dos imágenes PNG por cada carnet: el **Frente** (con foto, nombre y puesto del colaborador) y el **Reverso** (con el logo del cliente y un código QR único). Al escanear el QR, cualquier persona puede verificar en tiempo real si el colaborador está activo en BambooHR.
### Capacidades principales
- **Modo Individual**: Ingresa los datos de un colaborador y descarga su carnet.
- **Modo Lote**: Sube un archivo Excel/CSV con varios colaboradores y descarga un ZIP con todos los carnets.
- **Verificación QR**: Escanea el código QR del reverso para consultar el estado actual del colaborador.
---
## Requisitos
- Navegador **Google Chrome** o **Microsoft Edge** (actualizado).
- Cuenta de Google corporativa **@gomezleemarketing.com**.
- Conexión a Internet.
---
## Paso a paso — Inicio de sesión
1. Abre la URL de la aplicación en Chrome o Edge.
2. Haz clic en **"Continuar con Google"**.
3. Inicia sesión con tu correo **@gomezleemarketing.com**.
4. La aplicación te redirigirá automáticamente al generador de carnets.
> Si tu cuenta no es del dominio `@gomezleemarketing.com`, la aplicación no permitirá el acceso.
---
## Paso a paso — Modo Individual
1. **Nombre**: Escribe el nombre completo del colaborador (ej. "Alexi Zabala").
2. **Puesto**: Escribe el cargo del colaborador (ej. "Mercaderista").
3. **Subir foto**: Haz clic en "Subir foto del colaborador" y selecciona una foto desde tu computadora. La foto se procesará con IA automáticamente.
4. **Idioma**: Selecciona **Español** o **Inglés** — esto cambia el fondo del reverso del carnet.
5. **Cliente / Proyecto**: Selecciona el cliente asignado de la lista desplegable.
6. **ID del empleado / Cédula**: Ingresa el número de cédula o ID del colaborador **sin guiones ni espacios**. El código QR se genera automáticamente al escribir.
7. Revisa la **vista previa en tiempo real** que muestra el frente y el reverso del carnet.
8. Haz clic en **"⬇ Descargar"**.
9. Se descargarán dos archivos PNG:
- `{Nombre}Frente.png`
- `{Nombre}Reverso.png`
> El botón "Descargar" solo se habilita cuando los campos Nombre, Puesto e ID están llenos.
---
## Paso a paso — Modo Lote (carga masiva)
1. Prepara un archivo **Excel (.xlsx)** o **CSV** con las siguientes columnas exactas:
| Columna | Requerido | Descripción |
|---|---|---|
| `nombre` | Sí | Nombre completo del colaborador |
| `puesto` | Sí | Cargo del colaborador |
| `cliente/proyecto` | Sí | Cliente asignado (ver lista más abajo) |
| `cedula` | Sí | Cédula o ID del colaborador (sin guiones ni espacios) |
| `lenguaje` | No | `Esp` para español (default), `Ing` para inglés |
| `foto_url` | No | URL pública de la foto (Google Drive o enlace directo) |
2. En la aplicación, en el panel **"Carga Masiva de Colaboradores"**, haz clic en **"Seleccionar Archivo Excel"**.
3. Selecciona tu archivo. El sistema mostrará cuántos colaboradores se cargaron.
4. Haz clic en **"⬇ Descargar"** en la sección de lote.
5. El sistema procesará cada colaborador: obtendrá las fotos, generará los carnets y empaquetará todo.
6. Se descargará un archivo **`Lote_Carnets_GLM.zip`**.
7. Dentro del ZIP, cada colaborador tiene una carpeta con:
- `Frente.png`
- `Reverso.png`
### Clientes disponibles
La columna `cliente/proyecto` debe coincidir con uno de estos (no importa mayúsculas/minúsculas):
- Generico (por defecto)
- Claro
- Colgate
- KitchenAid
- Kraft
- Motorola
- Nestle
- P&G
- Philip Morris
- Whirlpool
### Formato de URLs de foto
Si usas Google Drive como `foto_url`, puedes pegar el enlace de compartir normal (ej. `https://drive.google.com/file/d/.../view`). El sistema lo convertirá automáticamente para poderlo descargar.
---
## Paso a paso — Verificación QR
Cualquier persona con un teléfono puede verificar un carnet:
1. Escanea el código QR que aparece en el **Reverso** del carnet.
2. Se abrirá una página web con los datos del colaborador:
- **Nombre**
- **Puesto**
- **País**
- **Estado** (Activo = verde / No Activo = rojo)
3. Si el colaborador está **Activo** en BambooHR, la página se muestra en verde.
4. Si el colaborador está **Inactivo o Terminado**, la página se muestra en rojo con la leyenda "Empleado No Activo".
> Esta verificación es en tiempo real contra BambooHR — no requiere que RRHH haga ningún paso adicional.
---
## Consejos útiles
- Los archivos PNG se generan a **650 x 1004 px** (equivalente a 300 dpi en CR80), listos para impresión.
- El ID del empleado se limpia automáticamente de guiones y espacios.
- Durante el procesamiento por lote, las fotos se guardan automáticamente en Supabase y se asocian al colaborador.
- Si una foto falla (Drive con permisos restringidos, URL caída), el carnet se genera igual con el círculo de foto vacío. El resto de colaboradores no se ven afectados.
---
## Solución de problemas
| Problema | Causa probable | Solución |
|---|---|---|
| No puedo iniciar sesión | La cuenta no es @gomezleemarketing.com | Usa tu correo corporativo de GLM |
| La foto no se procesa | La IA no responde | La foto original se usará igual — el carnet se genera sin edición IA |
| El QR no se genera | El ID del empleado tiene menos de 3 caracteres | Ingresa un ID de al menos 3 dígitos |
| Error al leer el Excel | El archivo no tiene las columnas correctas | Revisa que tenga: nombre, puesto, cliente/proyecto, cedula |
| El ZIP no se descarga | Demasiados colaboradores o fotos muy pesadas | Reduce el lote a máximo 50 colaboradores por archivo |
| La descarga no inicia | El navegador bloqueó las descargas múltiples | Permite descargas en la configuración del navegador |
+326
View File
@@ -0,0 +1,326 @@
# 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.
---
## Informacion General
| Campo | Detalle |
|---|---|
| Proyecto | GLM ID Card Generator |
| Area | RRHH — 14 paises |
| Estado | En Progreso |
| Developer Principal | Eidan Then |
| IT Manager | Luis Matos |
| Fecha de Inicio | 2026-06-16 |
| Fecha de Cierre Estimada | 2026-07-28 |
| Ciclo Shape Up | Ciclo 2 — Semana 1 de 6 |
| Board de Ejecucion | https://pmit.digitalcompass.agency/boards/v8iyihdd7u2v |
| PRD del Proyecto | https://docs.google.com/document/d/1SZMEcx5BZGf6CtDk5ZEb08Nx1xMBeyVd9zoqMDFhHgI/edit?usp=sharing |
---
## Objetivo
### Problema que resuelve
Hoy RRHH depende del area de diseno para generar cada carnet corporativo, lo que toma entre 1 y 3 dias habiles por colaborador. No existe estandar centralizado entre paises ni mecanismo para verificar la autenticidad o el estado activo de un carnet en campo.
### Solucion implementada
Un archivo HTML que RRHH abre en cualquier navegador. El operador selecciona el cliente de un desplegable precargado, ingresa los datos del colaborador, sube su foto y descarga dos PNG — frente y reverso — listos para imprimir en CR80 o enviar por WhatsApp. El reverso incluye un QR unico que al escanearse consulta BambooHR en tiempo real y devuelve el estado del colaborador. Para produccion masiva, el operador sube un CSV y el sistema genera todos los carnets en lote dentro de un ZIP.
### Usuarios y beneficiarios
- Operador: Equipo de RRHH en los 14 paises de GLM
- Verificador: Supervisores, clientes y auditores que escanean el QR en campo
- Beneficiario: Colaboradores que reciben su carnet impreso o digital
---
## Arquitectura
### Diagrama de flujo
```
MODO INDIVIDUAL
[RRHH abre glm_id_card.html] → [Selecciona cliente / sube logo] → [Ingresa datos + foto]
→ [Sistema genera QR unico con URL de verificacion] → [Vista previa en tiempo real]
→ [Descarga Frente PNG + Reverso PNG con QR]
MODO BATCH
[RRHH sube CSV] → [Sistema lee cada fila] → [Genera carnet completo por colaborador]
→ [Descarga ZIP con todos los PNG]
VERIFICACION QR
[Escaneo del QR en campo] → [verify.gomezlee.com/carnet/{ID}]
→ [Endpoint Cloud Run consulta BambooHR API]
→ [Devuelve pagina: Nombre, Puesto, Cliente, Estado (Activo / Inactivo / Terminado)]
```
### Stack tecnologico
| Componente | Tecnologia | Proposito |
|---|---|---|
| Frontend / Generador | HTML5 + CSS3 + JavaScript | Interfaz interactiva del generador — sin frameworks externos |
| Exportacion PNG | html2canvas (libreria JS) | Convierte el DOM del carnet a PNG de alta resolucion para impresion CR80 |
| Generacion de QR | QRCode.js (libreria JS) | Genera el QR unico por colaborador con la URL del endpoint de verificacion |
| Procesamiento batch | JavaScript + FileReader API | Lee el CSV y genera carnets en lote sin intervencion manual por fila |
| Endpoint de verificacion | Python + Google Cloud Run | Recibe el ID del QR, consulta BambooHR y devuelve pagina de estado del colaborador |
| Integracion BambooHR | BambooHR API REST | Fuente de verdad para nombre, puesto, pais, cliente y estado del colaborador |
| Version control | Gitea | Repositorio del generador y del endpoint |
### Integraciones externas
| Sistema | Tipo de integracion | Datos que fluyen |
|---|---|---|
| BambooHR | API REST | Nombre, puesto, pais, cliente asignado, estado del empleado (activo / inactivo / terminado) |
| Google Cloud Run | Endpoint HTTP publico | Recibe ID del colaborador desde el QR y devuelve pagina de verificacion |
| CSV local | Archivo de entrada | Nombre, puesto, pais, cliente, id_colaborador, ruta_foto (opcional) |
---
## Configuracion y Setup
### Prerequisitos
- [ ] Acceso a BambooHR API (API Key del subdominio GLM)
- [ ] Proyecto configurado en Google Cloud con Cloud Run habilitado
- [ ] Variable de entorno `BAMBOOHR_API_KEY` configurada en Cloud Run
- [ ] Variable de entorno `BAMBOOHR_SUBDOMAIN` configurada en Cloud Run
- [ ] Dominio `verify.gomezlee.com` apuntando al servicio de Cloud Run
- [ ] Lista de clientes precargados definida y aprobada por RRHH (nombre + logo PNG)
### Variables de entorno
| Variable | Descripcion | Donde se obtiene |
|---|---|---|
| `BAMBOOHR_API_KEY` | API Key de BambooHR | BambooHR → Admin → API Keys |
| `BAMBOOHR_SUBDOMAIN` | Subdominio de la cuenta GLM en BambooHR | URL de BambooHR: {subdominio}.bamboohr.com |
| `VERIFY_BASE_URL` | URL base del endpoint de verificacion | Ej: https://verify.gomezlee.com |
> Las credenciales nunca se commitean al repo. Todas las keys van en el vault de credenciales GLM o en los secrets de Google Cloud Run.
### Instalacion — Endpoint de verificacion
```bash
# Clonar el repo
git clone https://gitea.glm.com/glm-it/glm-id-card-generator
# Instalar dependencias del endpoint
cd endpoint/
pip install -r requirements.txt --break-system-packages
# Configurar variables de entorno locales para pruebas
cp .env.example .env
# Editar .env con las credenciales reales
# Correr el endpoint en local
python main.py
# Deploy a Google Cloud Run
gcloud run deploy glm-id-card-verify \
--source . \
--region us-central1 \
--allow-unauthenticated
```
### Distribucion del generador HTML
El archivo `glm_id_card.html` se distribuye directamente a RRHH via email o carpeta compartida. No requiere instalacion. El operador lo abre en Chrome o Edge.
---
## Como funciona
### Flujo paso a paso — Modo individual
1. El operador abre `glm_id_card.html` en Chrome o Edge
2. Selecciona el cliente del desplegable precargado (o sube un logo nuevo)
3. Ingresa nombre completo, puesto, pais e ID del colaborador
4. Sube la foto del colaborador — aparece recortada en circulo en el frente
5. El sistema genera automaticamente el QR con la URL `verify.gomezlee.com/carnet/{ID}`
6. El operador visualiza la vista previa en tiempo real del frente y el reverso
7. Descarga el Frente PNG y el Reverso PNG por separado
8. Envia los PNG al colaborador o los imprime en formato CR80
### Flujo paso a paso — Modo batch
1. El operador prepara el CSV segun el formato requerido (ver seccion Formato CSV)
2. Sube el CSV al generador
3. El sistema procesa cada fila, genera el carnet completo y produce un ZIP
4. El ZIP contiene los archivos nombrados como `{nombre_colaborador}_frente.png` y `{nombre_colaborador}_reverso.png`
### Flujo de verificacion QR
1. Supervisor o cliente escanea el QR del reverso del carnet con cualquier telefono
2. El QR abre `verify.gomezlee.com/carnet/{ID_COLABORADOR}` en el navegador del telefono
3. El endpoint consulta BambooHR con el ID del colaborador
4. Se devuelve una pagina web con: nombre, puesto, cliente, pais y estado del colaborador
5. Si el colaborador esta activo: pagina verde con sus datos completos
6. Si el colaborador esta inactivo o terminado: pagina roja con mensaje "Carnet no vigente"
### Formato CSV para batch
| Columna | Requerida | Descripcion |
|---|---|---|
| `nombre_completo` | Si | Nombre completo del colaborador |
| `puesto` | Si | Puesto o cargo |
| `pais` | Si | Pais de operacion |
| `cliente` | Si | Nombre del cliente (debe coincidir exactamente con la lista precargada) |
| `id_colaborador` | Si | ID del colaborador en BambooHR |
| `ruta_foto` | No | URL publica de la foto. Si se omite, el circulo de foto queda vacio |
### Schedules y triggers
| Trigger | Descripcion |
|---|---|
| Manual — abre el HTML | El operador genera carnets cuando RRHH lo necesita |
| Escaneo del QR | El endpoint se dispara on-demand cada vez que alguien escanea un carnet en campo |
---
## Testing
### Como probar el sistema
```bash
# Test unitario del endpoint
cd endpoint/
python -m pytest tests/
# Test de integracion — verificacion real contra BambooHR
python tests/test_bamboohr_integration.py
# Test manual del generador HTML
# 1. Abrir glm_id_card.html en Chrome
# 2. Llenar todos los campos con datos de prueba
# 3. Exportar PNG y verificar dimensiones: 1012 x 638 px a 96 dpi display (equivalente a 300 dpi en CR80)
# 4. Imprimir en hoja CR80 y verificar calidad
# 5. Escanear el QR generado y confirmar que abre el endpoint correctamente
```
### Casos de prueba minimos
| Caso | Input | Output esperado | Estado |
|---|---|---|---|
| Carnet individual completo | Todos los campos + foto + cliente seleccionado | PNG frente y reverso descargados correctamente | Pendiente |
| Carnet sin foto | Todos los campos sin foto | PNG generado con circulo vacio en frente | Pendiente |
| Batch CSV valido | CSV con 20 colaboradores | ZIP con 40 PNG (frente + reverso por colaborador) en menos de 2 minutos | Pendiente |
| QR colaborador activo | ID valido en BambooHR, estado activo | Pagina verde con datos completos del colaborador | Pendiente |
| QR colaborador inactivo | ID valido en BambooHR, estado inactivo | Pagina roja con mensaje "Carnet no vigente" | Pendiente |
| QR ID inexistente | ID que no existe en BambooHR | Mensaje de error claro, sin crash del endpoint | Pendiente |
| Cliente nuevo con logo manual | Logo subido manualmente sin seleccionar del desplegable | Logo aparece correctamente en el reverso del PNG | Pendiente |
| BambooHR API caida | Timeout del endpoint al consultar BambooHR | Mensaje de error legible, endpoint no crashea | Pendiente |
---
## 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
```
/glm-id-card-generator
├── README.md <- Este archivo
├── glm_id_card.html <- Generador de carnets (distribuir a RRHH)
├── /assets
│ ├── /logos-clientes <- Logos PNG de clientes precargados (aprobados por RRHH)
│ └── glm-logo.png <- Logo GLM para el generador
├── /endpoint
│ ├── main.py <- Punto de entrada del servicio de verificacion
│ ├── requirements.txt <- Dependencias Python del endpoint
│ ├── .env.example <- Variables de entorno de ejemplo (sin valores reales)
│ ├── Dockerfile <- Para deploy en Google Cloud Run
│ └── /tests
│ ├── test_endpoint.py <- Tests unitarios del endpoint
│ └── test_bamboohr_integration.py <- Tests de integracion con BambooHR API
├── /batch
│ └── ejemplo_colaboradores.csv <- CSV de ejemplo con el formato requerido para batch
├── /docs
│ ├── guia-uso-rrhh.pdf <- Guia de uso para operadores de RRHH
│ └── architecture.png <- Diagrama de arquitectura del sistema
├── CHANGELOG.md <- Historial de cambios
└── DECISIONS.md <- Log de decisiones tecnicas
```
---
## CHANGELOG
```
[2026-07-28] v1.0 — Launch inicial en produccion
```
---
## DECISIONS LOG
### DEC-001 — Generador como HTML local vs. app web hosteada
- Fecha: 2026-06-16
- Contexto: Se evaluo si el generador debia ser una app web hosteada en Google Cloud o un archivo HTML distribuido localmente a RRHH.
- Opciones consideradas: App web hosteada (requiere autenticacion, servidor, mantenimiento) vs. HTML local (distribucion simple, sin dependencias de red para generar el carnet).
- Decision: HTML local distribuido a RRHH en Fase 1.
- Razon: El equipo de RRHH opera en 14 paises con distintos niveles de conectividad. El HTML local garantiza que el generador funciona sin internet. Solo el endpoint de verificacion QR requiere conexion.
### DEC-002 — Verificacion QR via BambooHR en tiempo real vs. base de datos propia
- Fecha: 2026-06-16
- Contexto: Se evaluo mantener una base de datos propia en Supabase con el estado de cada carnet vs. consultar BambooHR directamente al momento del escaneo.
- Opciones consideradas: Base de datos propia (requiere sincronizacion manual o automatica con BambooHR) vs. BambooHR como fuente de verdad (estado siempre actualizado, sin base de datos adicional).
- Decision: BambooHR como fuente de verdad con consulta en tiempo real.
- Razon: Elimina el riesgo de datos desincronizados. Cuando RRHH da de baja a un colaborador en BambooHR, el QR lo refleja de inmediato sin ningun paso adicional.
---
## Contactos del proyecto
| Rol | Nombre | Contacto |
|---|---|---|
| Product Owner | Maximo Gomez | [email/WhatsApp] |
| IT Manager | Luis Matos | [email/WhatsApp] |
| Developer Principal | Eidan Then | [email/WhatsApp] |
---
## Definition of Done
Checklist antes de mover a Completado en Kan.bn:
- [ ] Todos los criterios de exito cumplidos y verificados
- [ ] PNG exportado validado fisicamente en impresion CR80
- [ ] Modo batch probado con CSV de al menos 20 colaboradores
- [ ] QR escaneado y verificacion confirmada contra BambooHR en ambiente real
- [ ] Endpoint de verificacion desplegado en Google Cloud Run y accesible publicamente
- [ ] Generador probado en Chrome y Edge en al menos 2 paises
- [ ] Lista de clientes precargados cargada y validada con RRHH
- [ ] Codigo commiteado y pusheado a Gitea
- [ ] README completo y actualizado
- [ ] Variables de entorno documentadas en `.env.example`
- [ ] Prueba de usuario real con al menos un miembro del equipo RRHH
- [ ] Luis Matos valido el output tecnico
- [ ] Maximo aprobo el resultado final
---
_Documento mantenido por el equipo GLM IT · Ultima actualizacion: 2026-06-05_
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>glm-card-generator</title>
<script type="module" crossorigin src="/empleado-id/assets/index-Ef45i7N1.js"></script>
<link rel="stylesheet" crossorigin href="/empleado-id/assets/index-OsvWWXE6.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!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-generator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "glm-card-generator",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.108.2",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.6.0",
"vite": "^8.1.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+175
View File
@@ -0,0 +1,175 @@
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 EmployeeCard from "./components/EmployeeCard";
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="/empleado-id/:employeeNumber"
element={
<ProtectedRoute>
<EmployeeCard />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/empleado-id/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
+360
View File
@@ -0,0 +1,360 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #f0f2f5;
font-family: Arial, Helvetica, sans-serif;
min-height: 100vh;
padding: 28px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
/* ── PAGE TITLE ── */
.page-title {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.page-title h1 {
font-size: 13px;
font-weight: 700;
color: #4F758B;
letter-spacing: 3px;
text-transform: uppercase;
}
.page-title p {
font-size: 10px;
color: #6B8FA3;
letter-spacing: 1px;
}
/* ── MAIN CONTENT CONTAINER ── */
.main-container {
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
flex-direction: row;
margin-top: 30px
}
/* ── CONTROLS PANEL ── */
.panel {
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 20px;
width: 280px;
display: flex;
flex-direction: column;
gap: 16px;
order: 1;
}
/* ── CARD STAGE (Vertical Grid) ── */
.stage {
display: flex;
flex-direction: column;
gap: 20px;
align-items: flex-start;
flex-wrap: wrap;
justify-content: center;
order: 2;
}
@media(min-width: 768px) {
.stage {
flex-direction: row;
}
}
.panel-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.panel-section-title {
font-size: 9px;
font-weight: 700;
color: #4F758B;
letter-spacing: 2.5px;
text-transform: uppercase;
padding-bottom: 6px;
border-bottom: 1.5px solid #EEF6E8;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 9px;
color: #6B8FA3;
letter-spacing: 1px;
text-transform: uppercase;
font-weight: 700;
}
.field input,
.field select {
background: #FAFAFA;
border: 1px solid #E0E0E0;
border-radius: 4px;
color: #2a2a2a;
font-family: Arial;
font-size: 12px;
padding: 7px 10px;
outline: none;
transition: border-color 0.15s;
}
.field input:focus,
.field select:focus {
border-color: #6CC24A;
background: #fff;
}
.field input::placeholder {
color: #C0C0C0;
}
.upload-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: #F5F5F5;
border: 1px dashed #D0D0D0;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
font-size: 11px;
color: #6B8FA3;
}
.upload-btn:hover {
border-color: #6CC24A;
color: #6CC24A;
background: #EEF6E8;
}
.divider {
height: 1px;
background: #F0F0F0;
}
.export-btn {
width: 100%;
padding: 11px;
background: #4F758B;
border: none;
border-radius: 5px;
color: #fff;
font-size: 11px;
font-family: Arial;
font-weight: 700;
cursor: pointer;
letter-spacing: 1.5px;
text-transform: uppercase;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.export-btn:hover {
background: #3d5d6e;
}
.export-btn.blue {
background: #4F758B;
}
.export-btn.blue:hover {
background: #385566;
}
.export-btn:disabled {
background: #A0B2BC;
cursor: not-allowed;
}
.hint-text {
font-size: 9px;
color: #B0B0B0;
line-height: 1.6;
text-align: center;
}
.preview-badge {
display: flex;
align-items: center;
gap: 6px;
background: #EEF6E8;
border-radius: 4px;
padding: 5px 10px;
}
.preview-badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #6CC24A;
}
.preview-badge span {
font-size: 9px;
color: #4F758B;
font-weight: 700;
letter-spacing: 1px;
}
/* CR80 Vertical Canvas Visual Container Setup */
.card-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.card-label {
font-size: 9px;
color: #6B8FA3;
letter-spacing: 2px;
text-transform: uppercase;
font-weight: 700;
}
/* Dimensiones Proporcionales CR80 Verticales */
.card {
position: relative;
width: 240px;
height: 380px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
flex-shrink: 0;
}
/* ── FRONT DYNAMIC OVERLAYS ── */
.front-photo-frame {
position: absolute;
top: 98.5px;
left: 49.9%;
transform: translateX(-50%);
width: 145.5px;
height: 145.5px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.front-photo-frame img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.front-photo-frame .placeholder-svg {
width: 40px;
height: 40px;
opacity: 0.3;
color: #4F758B;
}
.front-dynamic-name {
position: absolute;
top: 266px;
left: 10px;
right: 10px;
font-size: 14px;
font-weight: 900;
color: #FFFFFF;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.5px;
word-break: break-word;
z-index: 5;
}
.front-dynamic-role {
position: absolute;
bottom: 12px;
right: 5px;
font-size: 10px;
font-weight: 700;
color: #6B8499;
text-align: right;
text-transform: uppercase;
letter-spacing: 1px;
max-width: 200px;
word-break: break-word;
z-index: 5;
}
/* ── BACK DYNAMIC OVERLAYS ── */
.back-qr-zone {
position: absolute;
bottom: 10px;
right: 10px;
background: #ffffff;
padding: 4px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.back-qr-zone canvas {
display: block;
width: 40px;
height: 40px;
}
.lang-container {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.lang-btn {
flex: 1;
padding: 8px 12px;
font-size: 14px;
font-weight: bold;
border: 2px solid #e1e9ee;
background: #ffffff;
color: #4F758B;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.lang-btn.active {
background: #4F758B;
color: #ffffff;
border-color: #4F758B;
}
+555
View File
@@ -0,0 +1,555 @@
import { useState, useRef, useEffect } from 'react';
import './Generator.css';
import { renderQRCode, downloadIDCards, downloadBulkIDCards, getXLSXLib, clientTemplates } from './script/script.jsx';
import { uploadEmployeePhoto, saveClienteProyecto } from './services/storage.js';
import { supabase } from "./services/supabase";
// Convierte datos binarios recibidos (base64, buffer, array de bytes) en un Blob URL local de forma robusta
const convertBinaryToBlobUrl = (data) => {
if (!data) return null;
try {
if (typeof data === 'object' && data.type === 'Buffer' && Array.isArray(data.data)) {
const byteArray = new Uint8Array(data.data);
const blob = new Blob([byteArray], { type: 'image/jpeg' });
return URL.createObjectURL(blob);
}
if (Array.isArray(data)) {
const byteArray = new Uint8Array(data);
const blob = new Blob([byteArray], { type: 'image/jpeg' });
return URL.createObjectURL(blob);
}
if (typeof data === 'string') {
const cleanBase64 = data.replace(/^data:image\/\w+;base64,/, "");
const byteCharacters = atob(cleanBase64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'image/jpeg' });
return URL.createObjectURL(blob);
}
} catch (e) {
// ignore
}
return null;
};
export default function IdCardGenerator() {
// --- Estados formulario individual ───
const [name, setName] = useState('');
const [role, setRole] = useState('');
const [language, setLanguage] = useState('Esp');
const [selectedClient, setSelectedClient] = useState('Generico');
const [employeeId, setEmployeeId] = useState('');
const [photoSrc, setPhotoSrc] = useState('');
const [photoFile, setPhotoFile] = useState(null);
const [photoProcessing, setPhotoProcessing] = useState(false);
const [downloadStatus, setDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
// Bloquea el botón Descargar hasta que nombre, puesto e ID estén llenos
const isFormComplete = name.trim() && role.trim() && employeeId.trim();
// --- Estados Lote / Excel Masivo ───
const [bulkEmployees, setBulkEmployees] = useState([]);
const [bulkStatusText, setBulkStatusText] = useState('');
const [bulkDownloadStatus, setBulkDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
const [showTooltip, setShowTooltip] = useState(false);
const fileInputRef = useRef(null);
const excelInputRef = useRef(null);
const qrCanvasRef = useRef(null);
const baseUrl = import.meta.env.BASE_URL.endsWith('/')
? import.meta.env.BASE_URL
: `${import.meta.env.BASE_URL}/`;
useEffect(() => {
if (qrCanvasRef.current) {
renderQRCode(employeeId, qrCanvasRef.current);
}
}, [employeeId]);
const handlePhotoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
setPhotoFile(file);
setPhotoSrc(URL.createObjectURL(file));
setPhotoProcessing(true);
try {
const editedBlob = await processPhotoWithAI(file);
const editedUrl = URL.createObjectURL(editedBlob);
setPhotoSrc(editedUrl);
setPhotoFile(new File([editedBlob], file.name, { type: editedBlob.type || 'image/jpeg' }));
} catch (error) {
alert('No se pudo procesar la foto con IA. Se usará la foto original para la vista previa.');
} finally {
setPhotoProcessing(false);
}
};
const processPhotoWithAI = async (file) => {
const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL;
const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN;
const formData = new FormData();
formData.append('edited', file, file.name);
formData.append('name', name);
formData.append('role', role);
formData.append('employeeId', employeeId);
formData.append('fechaProcesado', new Date().toISOString());
const response = await fetch(N8N_WEBHOOK_URL, {
method: 'POST',
headers: {
'Authorization': TOKEN_SECRETO
},
body: formData
});
if (!response.ok) {
throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/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 blobUrl = convertBinaryToBlobUrl(bin);
if (!blobUrl) throw new Error('n8n no devolvió un binario válido.');
const blob = await (await fetch(blobUrl)).blob();
return blob;
}
const blob = await response.blob();
if (!blob || blob.size === 0) throw new Error('n8n devolvió un binario vacío.');
return blob;
};
const handleEmployeeIdChange = (e) => {
const rawValue = e.target.value;
const cleanValue = rawValue.replace(/[-\s]/g, '');
setEmployeeId(cleanValue);
};
const enviarDatosAn8n = async (datosEmpleados) => {
const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL;
const TOKEN_SECRETO = import.meta.env.VITE_WEBHOOK_TOKEN;
try {
const response = await fetch(N8N_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': TOKEN_SECRETO
},
body: JSON.stringify({
empleados: datosEmpleados,
total: datosEmpleados.length,
fechaProcesado: new Date().toISOString()
})
});
if (!response.ok) {
throw new Error(`El servidor de n8n rechazó la petición (Código: ${response.status})`);
}
const resData = await response.json();
return Array.isArray(resData) ? resData : (resData.empleados || resData.data || []);
} catch (error) {
throw error;
}
};
const handleBulkSupabaseSync = async (cedula, blobData, currentEmployee) => {
const cleanCedula = String(cedula).replace(/[-\s]/g, '');
if (!cleanCedula) return;
try {
const clienteProyectoValue = currentEmployee?.selectedClient || 'Generico';
// 1) Guardar cédula + cliente_proyecto en carnet_empleados_creados_glm
await saveClienteProyecto(cleanCedula, clienteProyectoValue);
// 2) Subir foto al storage
const customFile = new File([blobData], `${cleanCedula}.jpg`, { type: 'image/jpeg' });
const remoteStorageUrl = await uploadEmployeePhoto(cleanCedula, customFile);
// 3) Guardar URL de foto
if (remoteStorageUrl) {
await supabase.rpc("save_employee_photo", {
p_employee_number: cleanCedula,
p_photo_url: remoteStorageUrl
});
}
} catch (error) {
// silent
}
};
const handleExcelUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
setBulkStatusText('Leyendo documento...');
try {
const XLSXLib = await getXLSXLib();
const reader = new FileReader();
reader.onload = async (evt) => {
try {
const bstr = evt.target.result;
const wb = XLSXLib.read(bstr, { type: 'binary' });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
const jsonRows = XLSXLib.utils.sheet_to_json(ws);
if (jsonRows.length === 0) {
setBulkStatusText('❌ El archivo está vacío.');
return;
}
const formatted = jsonRows.map((row) => {
let rawLang = String(row.lenguaje || 'Esp').trim().toLowerCase();
if (rawLang.includes('es') || rawLang.includes('esp')) {
rawLang = 'Esp';
} else if (rawLang.includes('en') || rawLang.includes('ing')) {
rawLang = 'Ing';
} else {
rawLang = 'Esp';
}
let rawClient = String(row['cliente/proyecto'] || 'Generico')
.trim()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
const lowerClient = rawClient.toLowerCase();
if (lowerClient.includes('nestle')) rawClient = 'Nestle';
else if (lowerClient.includes('claro')) rawClient = 'Claro';
else if (lowerClient.includes('colgate')) rawClient = 'Colgate';
else if (lowerClient.includes('kitchenaid') || lowerClient.includes('kitchen')) rawClient = 'KitchenAid';
else if (lowerClient.includes('kraft')) rawClient = 'Kraft';
else if (lowerClient.includes('motorola')) rawClient = 'Motorola';
else if (lowerClient.includes('p&g') || lowerClient.includes('p y g')) rawClient = 'P&G';
else if (lowerClient.includes('philip') || lowerClient.includes('morris')) rawClient = 'Philip Morris';
else if (lowerClient.includes('whirlpool')) rawClient = 'Whirlpool';
else rawClient = 'Generico';
const cleanedCedula = String(row.cedula || '').replace(/[-\s]/g, '');
return {
name: String(row.nombre || '').trim(),
role: String(row.puesto || '').trim(),
selectedClient: rawClient,
language: rawLang,
employeeId: cleanedCedula,
fotoUrl: String(row.foto_url || '').trim()
};
}).filter(emp => emp.name && emp.role && emp.employeeId);
setBulkEmployees(formatted);
setBulkStatusText(`✅ ¡Cargados ${formatted.length} colaboradores! Listo para procesar.`);
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
} catch (innerErr) {
setBulkStatusText('❌ Error de lectura. Revisa el formato de columnas.');
}
};
reader.readAsBinaryString(file);
} catch (err) {
setBulkStatusText('❌ Error cargando el motor XLSX.');
}
};
const triggerBulkDownload = async () => {
if (bulkEmployees.length === 0) return;
setBulkDownloadStatus({ processing: true, text: '🔄 Descargando...' });
setBulkStatusText('Descargando retratos...');
try {
const resultadosN8n = await enviarDatosAn8n(bulkEmployees);
setBulkStatusText('Generando frentes, reversos y empaquetando en carpetas locales...');
const employeesReadyForRender = bulkEmployees.map((originalEmp, index) => {
const n8nEmp = resultadosN8n[index] || resultadosN8n.find(e => e.employeeId === originalEmp.employeeId) || {};
const base64Source = n8nEmp.data || n8nEmp.foto_processed_base64 || originalEmp.data;
let finalPhotoUrl = convertBinaryToBlobUrl(base64Source) || originalEmp.fotoUrl;
const validClient = n8nEmp.selectedClient || originalEmp.selectedClient || 'Generico';
const validLang = n8nEmp.language || originalEmp.language || 'Esp';
return {
name: n8nEmp.name || originalEmp.name,
role: n8nEmp.role || originalEmp.role,
selectedClient: validClient,
language: validLang,
employeeId: n8nEmp.employeeId || originalEmp.employeeId,
fotoUrl: finalPhotoUrl
};
});
await downloadBulkIDCards({
employees: employeesReadyForRender,
baseUrl,
onStateChange: null,
onProcessEmployeePhoto: (cedula, blobData) => {
const currentEmp = employeesReadyForRender.find(emp => emp.employeeId === cedula);
return handleBulkSupabaseSync(cedula, blobData, currentEmp);
}
});
setBulkStatusText(`✅ ¡Lote completado! ${employeesReadyForRender.length} carnets procesados.`);
setBulkEmployees([]);
} catch (error) {
setBulkStatusText(`❌ Error al procesar: ${error.message || error}`);
} finally {
setBulkDownloadStatus({ processing: false, text: '⬇ Descargar' });
}
};
const triggerDownload = async () => {
if (!validateForm()) return;
setDownloadStatus({ processing: true, text: '🔄 Descargando...' });
try {
await saveClienteProyecto(employeeId, selectedClient);
if (photoFile) {
const photoUrl = await uploadEmployeePhoto(employeeId, photoFile);
await supabase.rpc("save_employee_photo", {
p_employee_number: employeeId,
p_photo_url: photoUrl
});
}
await downloadIDCards({
name, role, selectedClient, language, photoSrc,
baseUrl,
qrCanvas: qrCanvasRef.current,
onStateChange: setDownloadStatus
});
} catch (err) {
alert(err.message);
setDownloadStatus({ processing: false, text: '⬇ Descargar' });
}
};
const validateForm = () => {
if (!name.trim()) { alert("Debe ingresar el nombre."); return false; }
if (!role.trim()) { alert("Debe ingresar el puesto."); return false; }
if (!employeeId.trim()) { alert("Debe ingresar el ID del empleado."); return false; }
if (!photoFile) { alert("Debe subir la foto del colaborador."); return false; }
return true;
};
return (
<>
<div className="page-title">
<h1>GLM ID Card Generator</h1>
<p>GomezLee Marketing · Carnet Corporativo CR80</p>
</div>
<div className="main-container" style={{ display: 'flex', gap: '20px', alignItems: 'flex-start' }}>
{/* FORMULARIO MASIVO EXCEL */}
<div className="panel" style={{ minWidth: '320px' }}>
<div className="preview-badge" style={{ background: '#FFF3E0', color: '#E65100' }}>
<div className="dot" style={{ background: '#E65100' }}></div>
<span>Módulo de Lotes Automáticos</span>
</div>
<div className="panel-section">
<div className="panel-section-title" style={{ display: 'flex', alignItems: 'center', gap: '6px', position: 'relative' }}>
<span>Carga Masiva de Colaboradores</span>
<div
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: '16px', height: '16px', borderRadius: '50%',
background: '#cbd5e1', color: '#334155', fontSize: '11px',
fontWeight: 'bold', cursor: 'help', userSelect: 'none'
}}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
?
</div>
{showTooltip && (
<div style={{
position: 'absolute', top: '24px', left: '0', right: '0',
background: '#1e293b', color: '#ffffff', padding: '10px',
borderRadius: '6px', fontSize: '11px', lineHeight: '1.4',
zIndex: '99', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
fontWeight: 'normal', textTransform: 'none'
}}>
Formato de columnas requeridas en el Excel: <br />
<b style={{ color: '#6CC24A' }}>nombre, puesto, cliente/proyecto, cedula, lenguaje, foto_url</b>.<br />
</div>
)}
</div>
<div className="upload-btn" style={{ background: '#475569', marginTop: '12px' }} onClick={() => excelInputRef.current.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
Seleccionar Archivo Excel
</div>
<input type="file" accept=".xlsx, .xls, .csv" style={{ display: 'none' }} ref={excelInputRef} onChange={handleExcelUpload} />
{bulkStatusText && (
<div style={{ fontSize: '12px', marginTop: '8px', fontWeight: '500', color: '#475569' }}>
{bulkStatusText}
</div>
)}
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Exportar Lote</div>
<button
className="export-btn blue"
disabled={bulkDownloadStatus.processing || bulkEmployees.length === 0}
onClick={triggerBulkDownload}
style={{ opacity: bulkEmployees.length === 0 ? 0.6 : 1 }}
>
{bulkDownloadStatus.text}
</button>
</div>
</div>
{/* FORMULARIO INDIVIDUAL */}
<div className="panel">
<div className="preview-badge">
<div className="dot"></div>
<span>Vista previa en tiempo real</span>
</div>
<div className="panel-section">
<div className="panel-section-title">Colaborador</div>
<div className="field">
<label>Nombre</label>
<input type="text" placeholder="Ej: Alexi Zabala" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="field">
<label>Puesto</label>
<input type="text" placeholder="Ej: Mercaderista" value={role} onChange={(e) => setRole(e.target.value)} />
</div>
<div className="upload-btn" onClick={() => !photoProcessing && fileInputRef.current.click()} style={{ opacity: photoProcessing ? 0.6 : 1, cursor: photoProcessing ? 'wait' : 'pointer' }}>
{photoProcessing ? '⏳ Procesando foto con IA...' : 'Subir foto del colaborador'}
</div>
<input type="file" accept="image/*" style={{ display: 'none' }} ref={fileInputRef} onChange={handlePhotoUpload} />
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Cliente / Proyecto</div>
<div className="lang-container">
<button type="button" className={`lang-btn ${language === 'Esp' ? 'active' : ''}`} onClick={() => setLanguage('Esp')}>Español</button>
<button type="button" className={`lang-btn ${language === 'Ing' ? 'active' : ''}`} onClick={() => setLanguage('Ing')}>Inglés</button>
</div>
<div className="field">
<label>Seleccionar Cliente</label>
<select value={selectedClient} onChange={(e) => setSelectedClient(e.target.value)}>
<option value="Generico">Genérico (Por defecto)</option>
<option value="Claro">Claro</option>
<option value="Colgate">Colgate</option>
<option value="KitchenAid">KitchenAid</option>
<option value="Kraft">Kraft</option>
<option value="Motorola">Motorola</option>
<option value="Nestle">Nestle</option>
<option value="P&G">P&G</option>
<option value="Philip Morris">Philip Morris International</option>
<option value="Whirlpool">Whirlpool</option>
</select>
</div>
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Código QR</div>
<div className="field">
<label>ID del empleado / Cédula</label>
<input
type="text"
placeholder="Ej: 123456789 (Sin guiones ni espacios)"
value={employeeId}
onChange={handleEmployeeIdChange}
/>
</div>
<button className="export-btn" style={{ background: '#6CC24A', marginTop: '2px' }} onClick={() => renderQRCode(employeeId, qrCanvasRef.current)}>
Generar QR
</button>
</div>
<div className="divider"></div>
<div className="panel-section">
<div className="panel-section-title">Exportar</div>
<button className="export-btn blue" disabled={downloadStatus.processing || !isFormComplete} onClick={triggerDownload} style={{ opacity: (!isFormComplete || downloadStatus.processing) ? 0.6 : 1 }}>
{downloadStatus.text}
</button>
</div>
</div>
{/* VISTA PREVIA */}
<div className="stage">
<div className="card-wrap">
<div className="card-label">Frente</div>
<div className="card" id="cardFront" style={{ backgroundImage: `url('${baseUrl}images/AF GLM Frente.png')` }}>
<div className="front-photo-frame" style={{ position: 'relative', overflow: 'hidden' }}>
{photoSrc ? (
<img src={photoSrc} alt="Foto Colaborador" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (null)}
{photoProcessing && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.45)', color: '#fff',
fontSize: '13px', fontWeight: '600', textAlign: 'center', padding: '8px'
}}>
Editando foto con IA...
</div>
)}
{!photoSrc && (
<svg className="placeholder-svg" viewBox="0 0 28 28" fill="none" style={{ width: '60%', height: '60%', opacity: 0.4 }}>
<circle cx="14" cy="10" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M3 24c0-6 22-6 22 0" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
</div>
<div className="front-dynamic-name">{name ? name.toUpperCase() : 'NOMBRE APELLIDO'}</div>
<div className="front-dynamic-role">{role ? role.toUpperCase() : 'PUESTO'}</div>
</div>
</div>
<div className="card-wrap">
<div className="card-label">Reverso</div>
<div
className="card"
id="cardBack"
style={{
backgroundImage: `url('${baseUrl}${(clientTemplates[language] || clientTemplates['Esp'])[selectedClient] || (clientTemplates[language] || clientTemplates['Esp'])['Generico']}')`
}}
>
<div className="back-qr-zone">
<canvas ref={qrCanvasRef} width={55} height={55}></canvas>
</div>
</div>
</div>
</div>
</div>
</>
);
}
+155
View File
@@ -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,96 @@
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) {
setError(authError.message);
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>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+440
View File
@@ -0,0 +1,440 @@
// ── CONFIGURACIÓN DE PLANTILLAS DE CLIENTES ──
export const clientTemplates = {
'Esp': {
'Generico': 'images/AF Generico Esp.png',
'Claro': 'images/AF Claro Esp.png',
'Colgate': 'images/AF Colgate Esp.png',
'KitchenAid': 'images/AF KitchenAid Esp.png',
'Kraft': 'images/AF Kraft Esp.png',
'Motorola': 'images/AF Motorola Esp.png',
'Nestle': 'images/AF Nestle Esp.png',
'P&G': 'images/AF P&G Esp.png',
'Philip Morris': 'images/AF Philip Morris Esp.png',
'Whirlpool': 'images/AF Whirlpool Esp.png'
},
'Ing': {
'Generico': 'images/AF Generico Ing.png',
'Claro': 'images/AF Claro Ing.png',
'Colgate': 'images/AF Colgate Ing.png',
'KitchenAid': 'images/AF KitchenAid Ing.png',
'Kraft': 'images/AF Kraft Ing.png',
'Motorola': 'images/AF Motorola Ing.png',
'Nestle': 'images/AF Nestle Ing.png',
'P&G': 'images/AF P&G Ing.png',
'Philip Morris': 'images/AF Philip Morris Ing.png',
'Whirlpool': 'images/AF Whirlpool Ing.png'
}
};
// ── INYECTORES DINÁMICOS DE DEPENDENCIAS ──
function loadScript(src, globalKey) {
return new Promise((resolve) => {
if (window[globalKey]) return resolve(window[globalKey]);
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve(window[globalKey]);
document.head.appendChild(s);
});
}
export const getQRLib = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js', 'QRCode');
const getHtml2Canvas = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js', 'html2canvas');
export const getJSZip = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', 'JSZip');
export const getXLSXLib = () => loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js', 'XLSX');
export function convertDriveUrlToDirect(url) {
if (!url) return '';
if (!url.includes('drive.google.com') && !url.includes('docs.google.com')) {
return url;
}
try {
const regExp = /(?:https?:\/\/)?(?:drive\.google\.com\/)(?:file\/d\/|open\?id=)([\w-]+)/;
const match = url.match(regExp);
if (match && match[1]) {
// Endpoint optimizado para saltar bloqueos pesados de descargas directas anónimas
return `https://drive.google.com/thumbnail?id=${match[1]}&sz=w1000`;
}
} catch (e) {
// ignore
}
return url;
}
export async function fetchImageAsBlob(url) {
if (!url) return null;
if (url.startsWith('blob:') || url.startsWith('data:')) {
try {
const response = await fetch(url);
if (response.ok) return await response.blob();
} catch (e) {
// ignore
}
return null;
}
if (url.includes('drive.google.com') || url.includes('docs.google.com')) {
try {
const response = await fetch(url);
if (response.ok) return await response.blob();
} catch {
// CORS blocked
}
return null;
}
try {
const proxyUrl = `https://images.weserv.nl/?url=${encodeURIComponent(url)}&default=${encodeURIComponent(url)}`;
const response = await fetch(proxyUrl);
if (!response.ok) throw new Error("Error en respuesta de red proxy");
return await response.blob();
} catch (e) {
try {
const response = await fetch(url, { mode: 'cors' });
return await response.blob();
} catch {
return null;
}
}
}
function preloadImage(src) {
return new Promise((resolve) => {
if (!src) return resolve(null);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(src);
img.onerror = () => {
const retryImg = new Image();
retryImg.onload = () => resolve(src);
retryImg.onerror = () => resolve(null);
retryImg.src = src;
};
img.src = src;
});
}
// ── GENERADOR DE QR NATIVO (Garantiza ID limpio en URL) ──
export async function renderQRCode(employeeId, canvasElement) {
if (!canvasElement) return;
const cleanId = (employeeId || '').replace(/[-\s]/g, '');
const ctx = canvasElement.getContext('2d');
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
if (cleanId.length <= 2) return;
const QRCodeLib = await getQRLib();
const tmp = document.createElement('div');
tmp.style.display = 'none';
document.body.appendChild(tmp);
new QRCodeLib(tmp, {
text: `http://digitalcompass.agency/empleado?id=${cleanId}`,
width: 55,
height: 55,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCodeLib.CorrectLevel.M
});
await new Promise((resolve) => setTimeout(resolve, 60));
const img = tmp.querySelector('img') || tmp.querySelector('canvas');
const drawIt = (src) => {
return new Promise((res) => {
const i = new Image();
i.onload = () => {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, 55, 55);
ctx.drawImage(i, 0, 0, 55, 55);
if (tmp.parentNode) document.body.removeChild(tmp);
res();
};
i.src = src;
});
};
if (img && img.tagName === 'CANVAS') {
await drawIt(img.toDataURL());
} else if (img) {
if (img.complete) await drawIt(img.src);
else {
await new Promise((res) => {
img.onload = async () => {
await drawIt(img.src);
res();
};
});
}
} else {
if (tmp.parentNode) document.body.removeChild(tmp);
}
}
// ── ENGINE DE PROCESAMIENTO INDIVIDUAL ──
export async function downloadIDCards(config) {
const { name, role, selectedClient, language, photoSrc, baseUrl, qrCanvas, onStateChange } = config;
if (onStateChange) onStateChange({ processing: true, text: 'Procesando...' });
const html2canvasLib = await getHtml2Canvas();
const cleanName = name.trim().replace(/\s+/g, '') || 'Empleado';
const frontBgUrl = `${baseUrl}images/AF GLM Frente.png`;
const currentGroup = clientTemplates[language] || clientTemplates['Esp'];
const backBgFile = currentGroup[selectedClient] || 'images/AF Generico Esp.png';
const backBgUrl = `${baseUrl}${backBgFile}`;
const sandbox = document.createElement('div');
sandbox.style.position = 'fixed';
sandbox.style.top = '-9999px';
sandbox.style.left = '-9999px';
sandbox.style.width = '1400px';
document.body.appendChild(sandbox);
const cardStyleBase = `
position: relative;
width: 650px;
height: 1004px;
border-radius: 16px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: inline-block;
margin-right: 50px;
overflow: hidden;
font-family: Arial, Helvetica, sans-serif;
`;
const vFront = document.createElement('div');
vFront.style.cssText = cardStyleBase;
vFront.style.backgroundImage = `url('${frontBgUrl}')`;
let photoTagHtml = `
<div style="width:100%; height:100%; background:#e1e9ee; display:flex; align-items:center; justify-content: center;">
<svg style="width:100px; height:100px; opacity:0.3; color:#4F758B;" viewBox="0 0 28 28" fill="none"><circle cx="14" cy="10" r="5.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 24c0-6 22-6 22 0" stroke="currentColor" stroke-width="1.5"/></svg>
</div>
`;
if (photoSrc) {
photoTagHtml = `<div style="width:100%; height:100%; background-image:url('${photoSrc}'); background-size:cover; background-position:center; background-repeat:no-repeat;"></div>`;
}
vFront.innerHTML = `
<div style="position:absolute; top:260.5px; left:131.5px; width:384px; height:384px; border-radius:50%; overflow:hidden; z-index:5;">
${photoTagHtml}
</div>
<div style="position:absolute; top:715px; left:20px; right:20px; font-size:38px; font-weight:900; color:#FFFFFF; text-align:center; text-transform:uppercase; letter-spacing:1px; word-break:break-word; z-index:5;">
${name.toUpperCase() || 'NOMBRE APELLIDO'}
</div>
<div style="position:absolute; bottom:32px; right:15px; font-size:26px; font-weight:700; color:#6B8499; text-align:right; text-transform:uppercase; letter-spacing:1px; max-width:550px; white-space:nowrap; z-index:5;">
${role.toUpperCase() || 'PUESTO'}
</div>
`;
const vBack = document.createElement('div');
vBack.style.cssText = cardStyleBase;
vBack.style.backgroundImage = `url('${backBgUrl}')`;
const vQRZone = document.createElement('div');
vQRZone.style.cssText = `position: absolute; bottom: 27px; right: 27px; background: #ffffff; padding: 11px; border-radius: 16px; display: flex; align-items: center; justify-content: center; z-index: 5;`;
const vQRCanvas = document.createElement('canvas');
vQRCanvas.width = 149;
vQRCanvas.height = 149;
vQRCanvas.style.width = '100px';
vQRCanvas.style.height = '100px';
const vQRContext = vQRCanvas.getContext('2d');
if (vQRContext && qrCanvas) {
vQRContext.imageSmoothingEnabled = false;
vQRContext.drawImage(qrCanvas, 0, 0, 149, 149);
}
vQRZone.appendChild(vQRCanvas);
vBack.appendChild(vQRZone);
sandbox.appendChild(vFront);
sandbox.appendChild(vBack);
const renderOpts = { scale: 1, useCORS: true, allowTaint: false, backgroundColor: null, logging: false, width: 650, height: 1004 };
try {
const frontCanvas = await html2canvasLib(vFront, renderOpts);
executeFileDownload(frontCanvas.toDataURL('image/png'), `${cleanName}Frente.png`);
const backCanvas = await html2canvasLib(vBack, renderOpts);
executeFileDownload(backCanvas.toDataURL('image/png'), `${cleanName}Reverso.png`);
} catch (error) {
// silent
} finally {
if (sandbox.parentNode) document.body.removeChild(sandbox);
if (onStateChange) onStateChange({ processing: false, text: '⬇ Descargar' });
}
}
// ── ENGINE MASIVO CON SOLUCIÓN TOTAL POR INYECCIÓN DE IMG CORS NATIVA ──
export async function downloadBulkIDCards({ employees, baseUrl, onStateChange, onProcessEmployeePhoto }) {
if (onStateChange) onStateChange({ processing: true, text: 'Descargando...' });
const html2canvasLib = await getHtml2Canvas();
const JSZipLib = await getJSZip();
const zip = new JSZipLib();
const hiddenQRCanvas = document.createElement('canvas');
hiddenQRCanvas.width = 55;
hiddenQRCanvas.height = 55;
const sandbox = document.createElement('div');
sandbox.style.position = 'fixed';
sandbox.style.top = '-9999px';
sandbox.style.left = '-9999px';
sandbox.style.width = '1400px';
document.body.appendChild(sandbox);
const cardStyleBase = `
position: relative;
width: 650px;
height: 1004px;
border-radius: 16px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: inline-block;
overflow: hidden;
font-family: Arial, Helvetica, sans-serif;
`;
const renderOpts = { scale: 1, useCORS: true, allowTaint: false, backgroundColor: null, logging: false, width: 650, height: 1004 };
for (let i = 0; i < employees.length; i++) {
const emp = employees[i];
const currentClient = emp.selectedClient || 'Generico';
const sanitizedEmpName = (emp.name || '').trim().replace(/[\\/:*?"<>|]/g, '-') || `Empleado_${i + 1}`;
const sanitizedClientName = String(currentClient).trim().replace(/[\\/:*?"<>|]/g, '-');
const folderName = `${sanitizedEmpName} - ${sanitizedClientName}`;
const strictCleanId = String(emp.employeeId || '').replace(/[-\s]/g, '');
await renderQRCode(strictCleanId, hiddenQRCanvas);
const directDriveUrl = convertDriveUrlToDirect(emp.fotoUrl);
let validatedPhotoUrl = null;
if (directDriveUrl && strictCleanId) {
try {
const blobData = await fetchImageAsBlob(directDriveUrl);
if (blobData) {
validatedPhotoUrl = URL.createObjectURL(blobData);
if (onProcessEmployeePhoto) {
await onProcessEmployeePhoto(strictCleanId, blobData, emp.selectedClient || '');
}
}
} catch (err) {
// silent
}
}
if (!validatedPhotoUrl && directDriveUrl) {
validatedPhotoUrl = await preloadImage(directDriveUrl);
}
// --- Renderizar Frente Virtual ---
const vFront = document.createElement('div');
vFront.style.cssText = cardStyleBase;
vFront.style.backgroundImage = `url('${baseUrl}images/AF GLM Frente.png')`;
// CAMBIO ESTRATÉGICO AQUÍ: Usamos una etiqueta <img> HTML real con crossorigin explícito,
// esto destruye el bloqueo "Anti-Taint" de los lienzos y obliga a html2canvas a procesar la foto de Drive.
let photoTagHtml = `
<div style="width:100%; height:100%; background:#e1e9ee; display:flex; align-items:center; justify-content: center;">
<svg style="width:100px; height:100px; opacity:0.3; color:#4F758B;" viewBox="0 0 28 28" fill="none"><circle cx="14" cy="10" r="5.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 24c0-6 22-6 22 0" stroke="currentColor" stroke-width="1.5"/></svg>
</div>
`;
if (validatedPhotoUrl) {
photoTagHtml = `<div style="width:100%; height:100%; background-image:url('${validatedPhotoUrl}'); background-size:cover; background-position:center; background-repeat:no-repeat;"></div>`;
}
vFront.innerHTML = `
<div style="position:absolute; top:260.5px; left:131.5px; width:384px; height:384px; border-radius:50%; overflow:hidden; z-index:5;">
${photoTagHtml}
</div>
<div style="position:absolute; top:715px; left:20px; right:20px; font-size:38px; font-weight:900; color:#FFFFFF; text-align:center; text-transform:uppercase; letter-spacing:1px; word-break:break-word; z-index:5;">
${emp.name.toUpperCase()}
</div>
<div style="position:absolute; bottom:32px; right:15px; font-size:26px; font-weight:700; color:#6B8499; text-align:right; text-transform:uppercase; letter-spacing:1px; max-width:550px; white-space:nowrap; z-index:5;">
${emp.role.toUpperCase()}
</div>
`;
sandbox.appendChild(vFront);
// --- Renderizar Reverso Virtual ---
const currentGroup = clientTemplates[emp.language] || clientTemplates['Esp'];
const backBgFile = currentGroup[currentClient] || currentGroup['Generico'];
const backBgUrl = `${baseUrl}${backBgFile}`;
const vBack = document.createElement('div');
vBack.style.cssText = cardStyleBase;
vBack.style.backgroundImage = `url('${backBgUrl}')`;
const vQRZone = document.createElement('div');
vQRZone.style.cssText = `position: absolute; bottom: 27px; right: 27px; background: #ffffff; padding: 11px; border-radius: 16px; display: flex; align-items: center; justify-content: center; z-index: 5;`;
const vQRCanvas = document.createElement('canvas');
vQRCanvas.width = 149;
vQRCanvas.height = 149;
vQRCanvas.style.width = '100px';
vQRCanvas.style.height = '100px';
const vQRContext = vQRCanvas.getContext('2d');
if (vQRContext) {
vQRContext.imageSmoothingEnabled = false;
vQRContext.drawImage(hiddenQRCanvas, 0, 0, 149, 149);
}
vQRZone.appendChild(vQRCanvas);
vBack.appendChild(vQRZone);
sandbox.appendChild(vBack);
// Forzar un retraso mínimo de microsegundos para asegurar el render interno
await new Promise((resolve) => setTimeout(resolve, 30));
// Capturas gráficas del ZIP
const frontCanvas = await html2canvasLib(vFront, renderOpts);
const frontData = frontCanvas.toDataURL('image/png').split(',')[1];
zip.file(`${folderName}/Frente.png`, frontData, { base64: true });
const backCanvas = await html2canvasLib(vBack, renderOpts);
const backData = backCanvas.toDataURL('image/png').split(',')[1];
zip.file(`${folderName}/Reverso.png`, backData, { base64: true });
if (validatedPhotoUrl && validatedPhotoUrl.startsWith('blob:')) {
URL.revokeObjectURL(validatedPhotoUrl);
}
sandbox.removeChild(vFront);
sandbox.removeChild(vBack);
}
if (sandbox.parentNode) document.body.removeChild(sandbox);
const content = await zip.generateAsync({ type: 'blob' });
const link = document.createElement('a');
link.href = URL.createObjectURL(content);
link.download = `Lote_Carnets_GLM.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (onStateChange) onStateChange({ processing: false, text: '⬇ Descargar' });
}
function executeFileDownload(dataUrl, fileName) {
const link = document.createElement('a');
link.href = dataUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
@@ -0,0 +1,13 @@
import { supabase } from "./supabase";
export async function getEmployee(employeeNumber) {
const { data, error } = await supabase
.from("empleados_glm")
.select("*")
.eq("cedula", employeeNumber)
.single();
if (error) throw error;
return data;
}
@@ -0,0 +1,86 @@
import { supabase } from "./supabase";
/**
* Registra el cliente/proyecto de un carnet descargado en la tabla
* "carnet_empleados_creados_glm".
*
* @param {string} employeeNumber - Número de cédula limpio (sin guiones/espacios)
* @param {string} clienteProyecto - Nombre del cliente/proyecto seleccionado
* @returns {Promise<void>}
*/
export async function saveClienteProyecto(employeeNumber, clienteProyecto) {
const cleanCedula = String(employeeNumber || '').replace(/[-\s]/g, '');
const valorCliente = clienteProyecto || 'Generico';
if (!cleanCedula) {
throw new Error('Cédula vacía, no se puede guardar.');
}
const { error } = await supabase.rpc('upsert_carnet_cliente', {
p_cedula: cleanCedula,
p_cliente_proyecto: valorCliente
});
if (error) {
throw error;
}
}
/**
* Sube la foto de un empleado al bucket "foto_empleados".
*
* El nombre del archivo sigue el patrón:
* <cedula>_<cliente>.jpg — primera foto
* <cedula>_<cliente>1.jpg — segunda foto del mismo empleado+cliente
* <cedula>_<cliente>2.jpg — tercera, etc.
*
* @param {string} employeeNumber - Número de cédula limpio (sin guiones/espacios)
* @param {File} file - Archivo de imagen a subir
* @param {string} [clientKey=''] - Nombre del cliente/proyecto (p.ej. "claro", "nestle")
* @returns {Promise<string>} - URL pública del archivo subido
*/
export async function uploadEmployeePhoto(employeeNumber, file, clientKey = '') {
const extension = file.name.split('.').pop() || 'jpg';
// Normalizar el nombre del cliente: minúsculas, sin espacios ni caracteres especiales
const safeClient = clientKey
? '_' + clientKey.toLowerCase().replace(/[^a-z0-9]/g, '')
: '';
const baseName = `${employeeNumber}${safeClient}`;
// Buscar archivos existentes con el mismo prefijo para elegir el sufijo correcto
const { data: existingFiles } = await supabase.storage
.from('foto_empleados')
.list('', { search: baseName });
// Filtrar exactamente los archivos que tengan el mismo baseName base
const pattern = new RegExp(`^${baseName}(\\d*)\\.${extension}$`);
const matches = (existingFiles || []).filter(f => pattern.test(f.name));
let fileName;
if (matches.length === 0) {
// No existe ninguno: usar el nombre base sin sufijo numérico
fileName = `${baseName}.${extension}`;
} else {
// Extraer los sufijos numéricos usados y elegir el siguiente
const usedSuffixes = matches.map(f => {
const m = f.name.match(pattern);
return m ? parseInt(m[1] || '0', 10) : 0;
});
const nextSuffix = Math.max(...usedSuffixes) + 1;
fileName = `${baseName}${nextSuffix}.${extension}`;
}
const { error } = await supabase.storage
.from('foto_empleados')
.upload(fileName, file, { upsert: false });
if (error) throw error;
const { data } = supabase.storage
.from('foto_empleados')
.getPublicUrl(fileName);
return data.publicUrl;
}
@@ -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);
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/empleado-id/'
})