Primer commit

This commit is contained in:
2026-06-30 12:24:04 -04:00
commit 510ebe8058
39 changed files with 4232 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
+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_
+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>
+2607
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"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",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"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

+359
View File
@@ -0,0 +1,359 @@
* {
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;
}
/* ── 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: 99px;
left: 50%;
transform: translateX(-50%);
width: 145px;
height: 145px;
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: none;
}
.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;
}
+29
View File
@@ -0,0 +1,29 @@
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="/employee"
element={<IdCardGenerator />}
/>
<Route
path="/employee/:employeeNumber"
element={<EmployeeCard />}
/>
</Routes>
</BrowserRouter>
);
}
+344
View File
@@ -0,0 +1,344 @@
import React, { useState, useRef, useEffect } from 'react';
import './App.css';
// Importamos las funciones lógicas nativas desde el archivo portado script.jsx
import { renderQRCode, downloadIDCards } from './script/script.jsx';
import { uploadEmployeePhoto } from './services/storage.js';
import { supabase } from "./services/supabase";
export default function IdCardGenerator() {
// --- Estados para manejar la lógica en tiempo real ───
const [name, setName] = useState('');
const [role, setRole] = useState('');
const [language, setLanguage] = useState('Esp'); // 'Esp' o 'Ing'
const [selectedClient, setSelectedClient] = useState('Generico'); // Sin tildes de forma interna
const [employeeId, setEmployeeId] = useState('');
const [photoSrc, setPhotoSrc] = useState('');
const [photoFile, setPhotoFile] = useState(null);
// Estado para controlar la UI del botón de descarga mientras procesa
const [downloadStatus, setDownloadStatus] = useState({ processing: false, text: '⬇ Descargar' });
// Referencias del DOM controladas por React
const fileInputRef = useRef(null);
const qrCanvasRef = useRef(null);
// EFECTO AUTOMÁTICO: Renderiza el QR en tiempo real mientras escribes mediante referencias
useEffect(() => {
if (qrCanvasRef.current) {
renderQRCode(employeeId, qrCanvasRef.current);
}
}, [employeeId]);
const handlePhotoUpload = (e) => {
const file = e.target.files[0];
if (file) {
setPhotoFile(file);
const localUrl = URL.createObjectURL(file);
setPhotoSrc(localUrl);
}
};
const handleLanguageChange = (lang) => {
setLanguage(lang);
};
const handleClientChange = (e) => {
setSelectedClient(e.target.value);
};
// Forzar generación del QR manualmente si se desea mediante el botón verde
const triggerGenerateQR = () => {
if (qrCanvasRef.current) {
renderQRCode(employeeId, qrCanvasRef.current);
}
};
// Disparador del motor de renderizado ULTRA MAX QUALITY en sandbox
const triggerDownload = async () => {
if (!validateForm()) {
return;
}
try {
// Solo si el usuario seleccionó una foto
if (photoFile) {
const photoUrl = await uploadEmployeePhoto(
employeeId,
photoFile
);
console.log("Foto subida:", photoUrl);
const { data, error } = await supabase.rpc(
"save_employee_photo",
{
p_employee_number: employeeId,
p_photo_url: photoUrl
}
);
if (error) {
throw error;
}
if (!data.success) {
console.warn(data.message);
} else {
console.log(data.message);
}
}
await downloadIDCards({
name,
role,
selectedClient,
language,
photoSrc,
baseUrl: import.meta.env.BASE_URL.endsWith('/')
? import.meta.env.BASE_URL
: `${import.meta.env.BASE_URL}/`,
qrCanvas: qrCanvasRef.current,
onStateChange: setDownloadStatus
});
} catch (err) {
console.error(err);
alert(err.message);
}
};
const baseUrl = import.meta.env.BASE_URL.endsWith('/')
? import.meta.env.BASE_URL
: `${import.meta.env.BASE_URL}/`;
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">
<div className="panel">
<div className="preview-badge">
<div className="dot"></div>
<span>Vista previa en tiempo real</span>
</div>
{/* Sección Colaborador */}
<div className="panel-section">
<div className="panel-section-title">Colaborador</div>
<div className="field">
<label>Nombre</label>
<input
type="text"
id="inputName"
placeholder="Ej: Alexi Zabala"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="field">
<label>Puesto</label>
<input
type="text"
id="inputRole"
placeholder="Ej: Mercaderista"
value={role}
onChange={(e) => setRole(e.target.value)}
/>
</div>
<div className="upload-btn" onClick={() => fileInputRef.current.click()}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="5" r="3" stroke="currentColor" strokeWidth="1.2" />
<path d="M1 12c0-3.5 12-3.5 12 0" stroke="currentColor" strokeWidth="1.2" />
</svg>
Subir foto del colaborador
</div>
<input
type="file"
id="photoInput"
accept="image/*"
style={{ display: 'none' }}
ref={fileInputRef}
onChange={handlePhotoUpload}
/>
</div>
<div className="divider"></div>
{/* Sección Cliente / Proyecto */}
<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={() => handleLanguageChange('Esp')}
>
Español
</button>
<button
type="button"
className={`lang-btn ${language === 'Ing' ? 'active' : ''}`}
onClick={() => handleLanguageChange('Ing')}
>
Inglés
</button>
</div>
<div className="field">
<label>Seleccionar Cliente</label>
<select id="selectClient" value={selectedClient} onChange={handleClientChange}>
<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>
{/* Sección Código QR */}
<div className="panel-section">
<div className="panel-section-title">Código QR</div>
<div className="field">
<label>ID del empleado</label>
<input
type="text"
id="inputQR"
placeholder="Ej: 12345"
value={employeeId}
onChange={(e) => setEmployeeId(e.target.value)}
/>
</div>
<button
className="export-btn"
style={{ background: '#6CC24A', marginTop: '2px' }}
onClick={triggerGenerateQR}
>
Generar QR
</button>
</div>
<div className="divider"></div>
{/* Sección Exportar */}
<div className="panel-section">
<div className="panel-section-title">Exportar</div>
<button
className="export-btn blue"
id="btnDownload"
disabled={downloadStatus.processing}
onClick={triggerDownload}
>
{downloadStatus.text}
</button>
</div>
</div>
{/* --- STAGE: Vista previa de las tarjetas ─── */}
<div className="stage">
{/* Frente */}
<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" id="photoFrame" style={{ position: 'relative', overflow: 'hidden' }}>
{photoSrc ? (
<img
id="photoImg"
src={photoSrc}
alt="Foto Colaborador"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block'
}}
/>
) : (
<svg className="placeholder-svg" id="photoPlaceholder" 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" id="displayName">
{name ? name.toUpperCase() : 'NOMBRE APELLIDO'}
</div>
<div className="front-dynamic-role" id="displayRole">
{role ? role.toUpperCase() : 'PUESTO'}
</div>
</div>
</div>
{/* Reverso */}
<div className="card-wrap">
<div className="card-label">Reverso</div>
<div
className="card"
id="cardBack"
style={{
backgroundImage: `url('${baseUrl}${selectedClient === 'Colgate' ? 'images/GLM Colgate' : 'images/AF ' + selectedClient} ${language}.png')`
}}
>
<div className="back-qr-zone">
{/* Asignamos la referencia de React para un control de pixeles seguro */}
<canvas ref={qrCanvasRef} width="55" height="55"></canvas>
</div>
</div>
</div>
</div>
</div>
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
body {
margin: 0;
background: #f5f5f5;
font-family: Arial, sans-serif;
}
.page {
padding: 40px;
}
.employee-card {
width: 350px;
max-width: 100%;
margin: auto;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, .1);
}
.header {
color: white;
text-align: center;
padding: 20px;
font-size: 28px;
font-weight: bold;
}
.content {
padding: 30px;
text-align: center;
}
.label {
margin-top: 16px;
color: #666;
font-size: 14px;
}
.value {
font-size: 18px;
font-weight: bold;
}
.employee-photo {
width: 170px;
height: 170px;
border-radius: 50%;
object-fit: cover;
display: block;
margin: 0 auto 25px;
border: 4px solid #e5e5e5;
}
.employee-photo-placeholder {
width: 170px;
height: 170px;
border-radius: 50%;
margin: 0 auto 25px;
background: #f0f0f0;
color: #777;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
border: 4px solid #e5e5e5;
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { supabase } from "../services/supabase";
import "./EmployeeCard.css";
export default function EmployeeCard() {
const { employeeNumber } = useParams();
const [employee, setEmployee] = useState(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
async function loadEmployee() {
const { data, error } = await supabase
.from("empleados_glm")
.select("photo_url, name, job_title, country, status")
.eq("cedula", employeeNumber)
.single();
if (error || !data) {
setNotFound(true);
} else {
setEmployee(data);
}
setLoading(false);
}
loadEmployee();
}, [employeeNumber]);
if (loading) {
return (
<div className="page">
<h2>Cargando...</h2>
</div>
);
}
if (notFound) {
return (
<div className="page">
<h1 style={{ color: "red" }}>
Empleado no encontrado
</h1>
</div>
);
}
const active = employee.status?.toLowerCase() === "active";
return (
<div className="page">
<div className="employee-card">
<div
className="header"
style={{
backgroundColor: active
? "#22c55e"
: "#ef4444"
}}
>
{active
? "Empleado Activo"
: "Empleado No Activo"}
</div>
<div className="content">
{employee.photo_url ? (
<img
src={employee.photo_url}
alt={employee.name}
className="employee-photo"
/>
) : (
<div className="employee-photo-placeholder">
Sin foto
</div>
)}
<div className="label">
Nombre
</div>
<div className="value">
{employee.name}
</div>
<div className="label">
Puesto
</div>
<div className="value">
{employee.job_title}
</div>
<div className="label">
País
</div>
<div className="value">
{employee.country}
</div>
<div className="label">
Estado
</div>
<div className="value">
{employee.status}
</div>
</div>
</div>
</div>
);
}
View File
+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>,
)
+225
View File
@@ -0,0 +1,225 @@
// ── CONFIGURACIÓN DE PLANTILLAS DE CLIENTES ──
export const clientTemplates = {
'Esp': {
'Generico': 'images/AF Generico Esp.png',
'Claro': 'images/AF Claro Esp.png',
'Colgate': 'images/GLM 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/GLM 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);
});
}
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');
// ── GENERADOR DE QR NATIVO (REACT RENDERING) ──
export async function renderQRCode(employeeId, canvasElement) {
if (!canvasElement) return;
const text = (employeeId || '').trim();
const ctx = canvasElement.getContext('2d');
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
if (text.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://localhost:5173/employee/${text}`,
width: 55,
height: 55,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCodeLib.CorrectLevel.M
});
setTimeout(() => {
const img = tmp.querySelector('img') || tmp.querySelector('canvas');
const drawIt = (src) => {
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);
};
i.src = src;
};
if (img && img.tagName === 'CANVAS') {
drawIt(img.toDataURL());
} else if (img) {
if (img.complete) drawIt(img.src);
else img.onload = () => drawIt(img.src);
} else {
if (tmp.parentNode) document.body.removeChild(tmp);
}
}, 50);
}
// ── ENGINE DE PROCESAMIENTO ULTRA MAX QUALITY (650x1004) ──
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';
// Resolver rutas absolutas
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}`;
// Sandbox virtual aislado
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;
`;
// 1. Frente Virtual en tamaño real
const vFront = document.createElement('div');
vFront.style.cssText = cardStyleBase;
vFront.style.backgroundImage = `url('${frontBgUrl}')`;
let photoStyleHtml = `background: #e1e9ee; display: flex; align-items: center; justify-content: center;`;
let innerPhotoContent = `<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>`;
if (photoSrc) {
photoStyleHtml = `
background-image: url('${photoSrc}') !important;
background-size: cover !important;
background-position: center center !important;
background-repeat: no-repeat !important;
`;
innerPhotoContent = '';
}
vFront.innerHTML = `
<div style="position:absolute; top:260.5px; left:131.5px; width:384px; height:384px; border-radius:50%; overflow:hidden; z-index:5; ${photoStyleHtml}">
${innerPhotoContent}
</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>
`;
// 2. Reverso Virtual en tamaño real
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) {
console.error('Error generando las vistas en alta resolución:', error);
} finally {
if (sandbox.parentNode) document.body.removeChild(sandbox);
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);
}
+13
View File
@@ -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;
}
+22
View File
@@ -0,0 +1,22 @@
import { supabase } from "./supabase";
export async function uploadEmployeePhoto(employeeNumber, file) {
const extension = file.name.split(".").pop();
const fileName = `${employeeNumber}.${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;
}
+6
View File
@@ -0,0 +1,6 @@
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);
+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: '/employee'
})