Primer commit
This commit is contained in:
+359
@@ -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
@@ -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);
|
||||
Reference in New Issue
Block a user