Primer commit
@@ -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
|
||||
@@ -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_
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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'
|
||||
})
|
||||