// fichaje.eu — Screen: Organización (admin: employees + permissions) const ScreenOrganizacion = ({ role }) => { const [view, setView] = React.useState('list'); const [createOpen, setCreateOpen] = React.useState(false); const [editTarget, setEditTarget] = React.useState(null); return (
}/> }/> }/> }/>
} placeholder="Buscar empleado, equipo…" style={{ width: 240, background: 'var(--slate-50)' }}/> }>Equipo {role === 'admin' && } onClick={() => setCreateOpen(true)}>Nuevo empleado}
{view === 'list' && } {view === 'org' && } {view === 'perms' && } {createOpen && setCreateOpen(false)}/>} {editTarget && setEditTarget(null)}/>}
); }; const DirList = ({ role, onEdit }) => (
Nombre
Puesto
Equipo
Antigüedad
Permisos
Estado
{TEAM.map((p, i) => { const status = { fichado: { tone: 'success', label: 'Fichado' }, pausa: { tone: 'warning', label: 'En pausa' }, vacaciones: { tone: 'brand', label: 'Vacaciones' }, fuera: { tone: 'slate', label: 'Fuera' }, }[p.status]; const isAdmin = ['CTO', 'People Ops'].includes(p.role); const isManager = ['CTO', 'Lead Engineer', 'Marketing Lead', 'People Ops'].includes(p.role); return (
{p.name}
{p.email}
{p.role}
{p.team}
{p.tenure}
{isAdmin && Admin} {isManager && !isAdmin && Manager} {!isAdmin && !isManager && Empleado}
{status.label} {role === 'admin' ? ( onEdit(p)} employee={p}/> ) :
}
); })}
); const RowMenu = ({ onEdit, employee }) => { const [open, setOpen] = React.useState(false); const ref = React.useRef(null); const onDelete = async () => { if (!employee || !employee.id || !window.api) return; if (!window.confirm(`¿Dar de baja a ${employee.name}? Esta acción no se puede deshacer.`)) return; await window.api.deleteEmployee(employee.id); await window.api.reloadBootstrap(); window.location.reload(); }; React.useEffect(() => { if (!open) return; const handle = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', handle); return () => document.removeEventListener('mousedown', handle); }, [open]); return (
{open && (
{[ { i: , l: 'Editar empleado', a: () => { setOpen(false); onEdit(); } }, { i: , l: 'Cambiar permisos' }, { i: , l: 'Ajustar vacaciones', a: () => { setOpen(false); onEdit(); } }, { i: , l: 'Subir documento' }, { i: , l: 'Reenviar invitación' }, { divider: true }, { i: , l: 'Dar de baja', danger: true, a: () => { setOpen(false); onDelete(); } }, ].map((it, i) => it.divider ? (
) : ( ))}
)}
); }; const OrgChart = () => (
{[ { name: 'Núria Castells', role: 'CTO', team: 'Dirección' }, { name: 'Berta Folch', role: 'Marketing Lead', team: 'Marketing' }, { name: 'Helena Prats', role: 'People Ops', team: 'RRHH' }, { name: 'Joel Vidal', role: 'Sales Engineer', team: 'Comercial' }, ].map(p => (
))}
+ 19 personas en niveles inferiores
); const OrgNode = ({ person, level }) => { const isCeo = level === 'ceo'; return (
{person.name}
{person.role}
); }; const PermissionsMatrix = () => { const roles = ['Empleado', 'Manager', 'RRHH', 'Admin']; const perms = [ { area: 'Fichaje', perms: ['Fichar propio', 'Ver registros propios', 'Ver registros del equipo', 'Editar registros'] }, { area: 'Ausencias', perms: ['Solicitar ausencia', 'Aprobar del equipo', 'Aprobar de todos', 'Configurar políticas'] }, { area: 'Documentos', perms: ['Ver propios', 'Subir a su perfil', 'Subir a otros', 'Gestionar plantillas'] }, { area: 'Organización',perms: ['Ver directorio', 'Editar su equipo', 'Editar cualquier persona', 'Crear usuarios'] }, ]; const matrix = { 'Empleado': [true, false, false, false], 'Manager': [true, true, false, false], 'RRHH': [true, true, true, false], 'Admin': [true, true, true, true], }; return (
{roles.map(r => (
{r}
{r === 'Empleado' ? '21 personas' : r === 'Manager' ? '5 personas' : r === 'RRHH' ? '1 persona' : '2 personas'}
))} {perms.map((g, gi) => (
{g.area}
{g.perms.map((p, pi) => (
{p}
{roles.map(r => (
{matrix[r][pi] ? ( ) : ( )}
))}
))}
))}
); }; const CreateEmployeeModal = ({ onClose }) => { const [step, setStep] = React.useState(1); const [form, setForm] = React.useState({ first_name: '', last_name: '', email: '', dni: '', phone: '', address: '', birth: '', role_title: '', team: 'Implementación', manager: '', center: 'Oficina Valencia', contract: 'Indefinido · Jornada completa', start: '2026-06-01', weekly_hours: 40, schedule: 'L–V · 09:00–18:00 (40h)', vacation_days: 23, personal_days: 2, permission_role: 'Empleado', }); const set = (k, v) => setForm(s => ({ ...s, [k]: v })); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(''); const submit = async () => { if (!window.api) return; if (!form.first_name || !form.last_name || !form.email) { setErr('Nombre, apellidos y email son obligatorios.'); setStep(1); return; } setBusy(true); setErr(''); try { const payload = { full_name: `${form.first_name} ${form.last_name}`.trim(), email: form.email, role: form.role_title || 'Empleado', team: form.team, manager: form.manager, location: form.center, contract: form.contract, start_date: form.start, schedule: form.schedule, vacation_total: Number(form.vacation_days) || 23, permission: form.permission_role, phone: form.phone, birthday: form.birth, }; const r = await window.api.createEmployee(payload); if (r && r.error) { setErr(r.error); setBusy(false); return; } await window.api.reloadBootstrap(); window.location.reload(); } catch (e) { setErr(String(e)); setBusy(false); } }; return (
e.stopPropagation()} style={{ background: '#fff', borderRadius: 'var(--r-xl)', width: 760, maxHeight: '92vh', display: 'flex', flexDirection: 'column', boxShadow: 'var(--shadow-lg)' }}>
Crear nuevo empleado
Se enviará un email de bienvenida con sus accesos cuando termines.
{[ { n: 1, l: 'Datos básicos' }, { n: 2, l: 'Contrato y horario' }, { n: 3, l: 'Vacaciones' }, { n: 4, l: 'Permisos' }, ].map((s, i, arr) => (
= s.n ? 'var(--brand-600)' : 'var(--slate-150)', color: step >= s.n ? '#fff' : 'var(--slate-500)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, }}>{step > s.n ? : s.n}
{s.l}
{i < arr.length - 1 &&
s.n ? 'var(--brand-300)' : 'var(--slate-150)', alignSelf: 'center' }}/>} ))}
{step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && }
Paso {step} de 4
{err &&
{err}
}
Cancelar {step > 1 && setStep(step - 1)} icon={} disabled={busy}>Atrás} {step < 4 ? ( } onClick={() => setStep(step + 1)}>Siguiente ) : ( } onClick={submit} disabled={busy}>{busy ? 'Creando…' : 'Crear empleado'} )}
); }; const Step1Basics = ({ form, set }) => (
set('first_name', e.target.value)}/> set('last_name', e.target.value)}/> } value={form.email} onChange={e => set('email', e.target.value)}/> } value={form.phone} onChange={e => set('phone', e.target.value)}/> set('dni', e.target.value)}/> set('birth', e.target.value)}/> set('address', e.target.value)}/>
); const Step2Contract = ({ form, set }) => (
set('role_title', e.target.value)}/> set('manager', e.target.value)}/> set('start', e.target.value)}/>
Horario y jornada
set('weekly_hours', e.target.value)} style={{ fontFamily: 'var(--font-mono)' }}/>
); const Step3Vacation = ({ form, set }) => { const vacationDays = form.vacation_days; const personalDays = form.personal_days; const setVacationDays = (v) => set('vacation_days', v); const setPersonalDays = (v) => set('personal_days', v); const [proRata, setProRata] = React.useState(true); return (
Por convenio Consultoría TIC, lo habitual son 23 días laborables de vacaciones + 2 días de asuntos propios al año. Aplicaremos el prorrateo según fecha de inicio.
} tone="brand" title="Vacaciones anuales" subtitle="Días laborables que le pertenecen al año natural" value={vacationDays} onChange={setVacationDays} unit="días" presets={[22, 23, 25, 30]} /> } tone="violet" title="Asuntos propios" subtitle="Días retribuidos para gestiones personales" value={personalDays} onChange={setPersonalDays} unit="días" presets={[0, 1, 2, 4]} />
Otros permisos retribuidos (configurables)
Prorratear si entra a mitad de año
Empieza el 1 jun → recibe {Math.round(vacationDays * 7 / 12)} días de vacaciones y {Math.round(personalDays * 7 / 12)} de asuntos propios para 2026.
Permitir arrastrar días al año siguiente
Hasta 5 días no consumidos pueden usarse antes del 31 marzo.
); }; const Step4Permissions = ({ form, set }) => (
{[ { r: 'Empleado', d: 'Acceso a sus propios datos y fichaje.', i: }, { r: 'Manager', d: 'Aprueba ausencias del equipo y ve registros.', i: }, { r: 'RRHH', d: 'Gestiona personal, documentos y políticas.', i: }, { r: 'Admin', d: 'Control total del workspace.', i: }, ].map(o => { const active = form.permission_role === o.r; return ( );})}
Onboarding automático
{[ { l: 'Crear cuenta corporativa', s: 'anna.boixader@iabusiness.es', on: true }, { l: 'Firmar contrato base de Consultoría TIC', s: 'Plantilla "Indefinido · Jornada completa"', on: true }, { l: 'Asignar equipo informático', s: 'Solicitud automática a IT', on: true }, { l: 'Añadir a Slack: #general, #implementacion', s: '', on: true }, { l: 'Notificar incorporación al equipo', s: 'Email al equipo de Implementación y tablón', on: false }, ].map((x, i) => (
{x.l}
{x.s &&
{x.s}
}
))}
); const selectStyle = { width: '100%', padding: '10px 12px', fontSize: 14, border: '1px solid var(--slate-200)', borderRadius: 'var(--r-md)', background: '#fff' }; const PolicyCounter = ({ icon, tone, title, subtitle, value, onChange, unit, presets }) => { const tones = { brand: ['var(--brand-50)', 'var(--brand-600)', 'var(--brand-100)'], violet: ['var(--violet-100)', '#6D28D9', 'var(--violet-100)'], }[tone]; return (
{icon}
{title}
{subtitle}
{value}{unit}
{presets.map(p => ( ))}
); }; const MiniField = ({ label, unit, defaultValue, disabled }) => (
{label}
{unit}
); const EditEmployeeModal = ({ employee, onClose }) => { const [tab, setTab] = React.useState('datos'); const initialFirst = (employee.name || '').split(' ')[0] || ''; const initialLast = (employee.name || '').split(' ').slice(1).join(' '); const [form, setForm] = React.useState({ first_name: initialFirst, last_name: initialLast, email: employee.email || '', phone: '', location: 'Valencia, ES', birth: '', role_title: employee.role || '', team: employee.team || 'Implementación', contract: 'Indefinido · Jornada completa', start: '2024-03-04', manager: 'Núria Castells', schedule: 'L–V · 09:00–18:00 (40h)', }); const set = (k, v) => setForm(s => ({ ...s, [k]: v })); const [busy, setBusy] = React.useState(false); const save = async () => { if (!employee.id || !window.api) return; setBusy(true); await window.api.updateEmployee(employee.id, { full_name: `${form.first_name} ${form.last_name}`.trim(), email: form.email, phone: form.phone, location: form.location, birthday: form.birth, role: form.role_title, team: form.team, manager: form.manager, contract: form.contract, start_date: form.start, schedule: form.schedule, }); await window.api.reloadBootstrap(); window.location.reload(); }; const remove = async () => { if (!employee.id || !window.api) return; if (!window.confirm(`¿Dar de baja a ${employee.name}? Esta acción no se puede deshacer.`)) return; setBusy(true); await window.api.deleteEmployee(employee.id); await window.api.reloadBootstrap(); window.location.reload(); }; return (
e.stopPropagation()} style={{ background: '#fff', borderRadius: 'var(--r-xl)', width: 780, maxHeight: '92vh', display: 'flex', flexDirection: 'column', boxShadow: 'var(--shadow-lg)' }}>
Editar {employee.name}
{employee.role} · {employee.team} · {employee.email}
}>Ver perfil
{[ { v: 'datos', l: 'Datos' }, { v: 'contrato', l: 'Contrato' }, { v: 'vacaciones', l: 'Vacaciones y permisos' }, { v: 'permisos', l: 'Rol y accesos' }, ].map(t => ( ))}
{tab === 'vacaciones' && } {tab === 'datos' && (
set('first_name', e.target.value)}/> set('last_name', e.target.value)}/> set('email', e.target.value)} icon={}/> set('phone', e.target.value)}/> set('birth', e.target.value)}/> set('location', e.target.value)}/>
)} {tab === 'contrato' && (
set('role_title', e.target.value)}/> set('start', e.target.value)}/> set('manager', e.target.value)}/>
)} {tab === 'permisos' && ( )}
} style={{ color: 'var(--danger-600)' }} onClick={remove} disabled={busy}>Dar de baja
Cancelar } onClick={save} disabled={busy}>{busy ? 'Guardando…' : 'Guardar cambios'}
); }; const EditVacationTab = ({ employee }) => { const [vacationTotal, setVacationTotal] = React.useState(23); const [personalTotal, setPersonalTotal] = React.useState(2); const used = 7, usedPersonal = 1; return (
Vacaciones — saldo actual 2026
{vacationTotal - used} / {vacationTotal} días
{used} usados · {vacationTotal - used} disponibles
Asuntos propios — saldo actual
{personalTotal - usedPersonal} / {personalTotal} días
{usedPersonal} usado · {personalTotal - usedPersonal} disponible
Asignación anual
} tone="brand" title="Vacaciones anuales" subtitle="Días laborables por año natural" value={vacationTotal} onChange={setVacationTotal} unit="días" presets={[22, 23, 25, 30]}/> } tone="violet" title="Asuntos propios" subtitle="Días retribuidos según convenio" value={personalTotal} onChange={setPersonalTotal} unit="días" presets={[0, 1, 2, 4]}/>
Si cambias la asignación a mitad de año, el saldo actual se recalculará. Si reduces por debajo de los días ya consumidos ({used} usados), aparecerá saldo negativo y deberás justificar el ajuste.
Ajuste manual del saldo
}>Añadir ajuste
{[ { d: '01 ene 2026', t: 'Saldo inicial 2026', v: '+23 días', by: 'Automático', tone: 'slate' }, { d: '12 feb 2026', t: 'Arrastre días 2025', v: '+3 días', by: 'Helena Prats', tone: 'success' }, { d: '20 abr 2026', t: 'Día compensación trabajo finde', v: '+1 día', by: 'Helena Prats', tone: 'success' }, { d: 'mar–may', t: 'Vacaciones consumidas', v: '−7 días', by: 'Solicitudes aprobadas', tone: 'danger' }, ].map((r, i, arr) => (
{r.d}
{r.t}
{r.v}
{r.by}
))}
); }; const EmployeeAccessPanel = ({ employee }) => { const [loading, setLoading] = React.useState(true); const [hasUser, setHasUser] = React.useState(false); const [username, setUsername] = React.useState(''); const [role, setRole] = React.useState('empleado'); const [password, setPassword] = React.useState(''); const [confirm, setConfirm] = React.useState(''); const [busy, setBusy] = React.useState(false); const [msg, setMsg] = React.useState(null); React.useEffect(() => { let cancel = false; (async () => { if (!employee || !employee.id || !window.api) { setLoading(false); return; } const r = await window.api.getEmployeeAccess(employee.id); if (cancel) return; if (r && !r.error) { setHasUser(!!r.has_user); setUsername(r.username || r.suggested_username || ''); setRole(r.role || 'empleado'); } setLoading(false); })(); return () => { cancel = true; }; }, [employee && employee.id]); const save = async () => { setMsg(null); if (!password || password.length < 6) { setMsg({ type: 'error', text: 'La contraseña debe tener al menos 6 caracteres.' }); return; } if (password !== confirm) { setMsg({ type: 'error', text: 'Las contraseñas no coinciden.' }); return; } setBusy(true); const r = await window.api.setEmployeeAccess(employee.id, { username, password, role }); setBusy(false); if (r && r.error) { setMsg({ type: 'error', text: r.error }); } else { setHasUser(true); setUsername(r.username || username); setPassword(''); setConfirm(''); setMsg({ type: 'ok', text: r.created ? `Acceso creado. Login: empresa + «${r.username}» + nueva contraseña.` : `Contraseña actualizada para «${r.username}».` }); } }; if (loading) return
Cargando acceso…
; return (
{hasUser ? <>Este empleado ya tiene acceso. Usuario actual: {username}. Puedes cambiar su contraseña o rol abajo. : <>Este empleado todavía no tiene usuario. Al guardar se le creará uno y podrá entrar al sistema.}
setUsername(e.target.value.toLowerCase())} placeholder="ej. marc.vives" autoComplete="off"/> setPassword(e.target.value)} placeholder="••••••••" autoComplete="new-password"/> setConfirm(e.target.value)} placeholder="••••••••" autoComplete="new-password"/>
{msg && (
{msg.text}
)}
} onClick={save} disabled={busy}> {busy ? 'Guardando…' : (hasUser ? 'Actualizar acceso' : 'Crear acceso')}
); }; Object.assign(window, { ScreenOrganizacion });