// Employees / HR directory — staff records: designation, department, salary, // join date, status. Owner/Admin can add/edit/remove; others view. (() => { const { Card, Badge, Avatar } = window.TechyFuelOSDesignSystem_be0222; const STATUS_TONE = { active: 'success', on_leave: 'warning', terminated: 'danger' }; const STATUS_LABEL = { active: 'Active', on_leave: 'On leave', terminated: 'Terminated' }; const TYPE_LABEL = { 'full-time': 'Full-time', 'part-time': 'Part-time', contract: 'Contract', intern: 'Intern' }; const WORK_MODE_LABEL = { office: 'Office', remote: 'Remote', hybrid: 'Hybrid', freelance: 'Freelance' }; function fmtMoney(n, cur) { if (n === null || n === undefined || n === '') return '—'; const v = Number(n); if (isNaN(v)) return '—'; const sym = { PKR: 'Rs', INR: '₹', USD: '$', EUR: '€', GBP: '£', AED: 'AED ' }; const c = cur || 'PKR'; return (sym[c] || (c + ' ')) + v.toLocaleString(); } function fmtDate(d) { if (!d) return '—'; const dt = new Date(String(d).replace(' ', 'T')); return isNaN(dt) ? '—' : dt.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); } const EMPTY = { name: '', email: '', phone: '', designation: '', department: '', employment_type: 'full-time', work_mode: 'office', salary_type: 'monthly', salary: '', currency: 'PKR', project_rates: [], join_date: '', status: 'active', address: '', notes: '', cnic_number: '', cnic_front_url: '', cnic_back_url: '', employee_code: '', profile_photo_url: '', id_type: 'CNIC', date_of_birth: '', gender: '', emergency_contact: '', shift_start: '', shift_end: '', shift_grace_minutes: '', default_output_unit: '', default_output_target: '', output_work_days: '1,2,3,4,5' }; // Attachable HR documents (multiple files, stored in employee_documents). const DOC_TYPES = [ ['cv', 'CV / Resume'], ['offer_letter', 'Offer Letter'], ['contract', 'Contract'], ['certificate', 'Certificates'], ['experience_letter', 'Experience Letter'], ['other', 'Other Files'], ]; const DOC_LABEL = Object.fromEntries(DOC_TYPES); // Capitalize the first letter of each word as the user types a name. // Only the leading letter of each word is forced up — the rest is left as // typed, so mid-word caps (e.g. "McD") aren't clobbered. function titleCase(s) { return String(s).replace(/(^|\s)([a-z])/g, (m, p, c) => p + c.toUpperCase()); } function Employees() { useLucide(); const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [search, setSearch] = React.useState(''); const [statusFilter, setStatusFilter] = React.useState('all'); const [modalOpen, setModalOpen] = React.useState(false); const [editing, setEditing] = React.useState(null); const [form, setForm] = React.useState(EMPTY); const [saving, setSaving] = React.useState(false); // The active sub-tab lives in the URL hash, not just component state: a reload // (or a PWA relaunch) on Payroll used to drop the user back on Directory. // Hash also makes a tab linkable — #hr=payroll opens straight there. const HR_TABS = ['directory', 'payroll', 'attendance', 'leave', 'output']; function readHrTab() { try { const m = /(?:^|[#&])hr=([a-z]+)/.exec(window.location.hash || ''); if (m && HR_TABS.includes(m[1])) return m[1]; } catch (e) {} return 'directory'; } const [hrTab, _setHrTab] = React.useState(readHrTab); function setHrTab(t) { _setHrTab(t); // replaceState, not push: switching tabs shouldn't stack up Back-button steps. try { window.history.replaceState(null, '', t === 'directory' ? window.location.pathname : '#hr=' + t); } catch (e) {} } // Keep up with the Back button / a pasted link. React.useEffect(() => { const onHash = () => _setHrTab(readHrTab()); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); const canManage = ['owner', 'admin'].includes(window.TFMyRole); // Day-to-day people ops a manager runs too: attendance, leave and output. // Money (payroll/salary) stays owner/admin via canManage. const canManageStaffOps = ['owner', 'admin', 'manager'].includes(window.TFMyRole); const load = React.useCallback(() => { if (!window.API || !window.API.getEmployees) { setLoading(false); return; } setLoading(true); window.API.getEmployees().then(r => { if (r.data) setRows(r.data); }).catch(() => {}).finally(() => setLoading(false)); }, []); React.useEffect(() => { load(); }, [load]); function set(k, v) { setForm(f => ({ ...f, [k]: v })); } const [cnicUploading, setCnicUploading] = React.useState(null); // 'front' | 'back' | null async function uploadCnic(side, file) { if (!file || !window.API || !window.API.uploadFile) return; if (!file.type.startsWith('image')) { alert('Please pick an image (JPG/PNG).'); return; } setCnicUploading(side); try { const rec = await window.API.uploadFile('files', `cnic/${side}_${Date.now()}`, file); const url = rec && (rec.url || rec.file_url); if (url) set(side === 'front' ? 'cnic_front_url' : 'cnic_back_url', url); else alert('Upload failed. Please try again.'); } catch (e) { alert('Could not upload the ID card image. Please try again.'); } setCnicUploading(null); } // Profile photo upload const [photoUploading, setPhotoUploading] = React.useState(false); async function uploadPhoto(file) { if (!file || !window.API || !window.API.uploadFile) return; if (!file.type.startsWith('image')) { alert('Please pick an image (JPG/PNG).'); return; } setPhotoUploading(true); try { const rec = await window.API.uploadFile('files', `employee-photo/${Date.now()}`, file); const url = rec && (rec.url || rec.file_url); if (url) set('profile_photo_url', url); else alert('Upload failed. Please try again.'); } catch (e) { alert('Could not upload the photo. Please try again.'); } setPhotoUploading(false); } // Employee documents (only when editing an existing employee) const [docs, setDocs] = React.useState([]); const [docUploading, setDocUploading] = React.useState(null); // doc_type being uploaded async function uploadDoc(docType, file) { if (!file || !window.API || !editing) return; setDocUploading(docType); try { const rec = await window.API.uploadFile('files', `employee-doc/${docType}_${Date.now()}`, file); const url = rec && (rec.url || rec.file_url); if (!url) { alert('Upload failed.'); setDocUploading(null); return; } const { data } = await window.API.addEmployeeDocument(editing.id, { doc_type: docType, name: file.name, file_url: url, file_type: file.type }); if (data) setDocs(prev => [...prev, data]); } catch (e) { alert('Could not attach the document. Please try again.'); } setDocUploading(null); } async function removeDoc(doc) { if (!window.confirm('Remove this document?')) return; setDocs(prev => prev.filter(d => d.id !== doc.id)); try { await window.API.deleteEmployeeDocument(doc.id); } catch {} } // Next employee code = highest trailing number + 1, keeping prefix + padding // (TF-011 -> TF-012). Defaults to TF-001 when none exist yet. function nextEmployeeCode() { let best = null; (rows || []).forEach(r => { const s = String(r.employee_code || ''); const m = s.match(/^(.*?)(\d+)\s*$/); if (!m) return; const num = parseInt(m[2], 10); if (best === null || num > best.num) best = { prefix: m[1], num, width: m[2].length }; }); if (!best) return 'TF-001'; return best.prefix + String(best.num + 1).padStart(best.width, '0'); } function openAdd() { setEditing(null); setForm({ ...EMPTY, employee_code: nextEmployeeCode() }); setDocs([]); setModalOpen(true); } function openEdit(e) { setEditing(e); setForm({ ...EMPTY, ...e, salary: e.salary ?? '', project_rates: Array.isArray(e.project_rates) ? e.project_rates : [], join_date: e.join_date ? String(e.join_date).slice(0, 10) : '', date_of_birth: e.date_of_birth ? String(e.date_of_birth).slice(0, 10) : '' }); setDocs(e.documents || []); setModalOpen(true); } async function save() { if (!form.name.trim() || !window.API) return; setSaving(true); const payload = { ...form, salary: form.salary === '' ? null : Number(form.salary) }; // Empty inputs must go over as null, not '' — the backend validates these as // integers / HH:MM strings and a blank string fails the rule. payload.shift_start = form.shift_start || null; payload.shift_end = form.shift_end || null; payload.shift_grace_minutes = form.shift_grace_minutes === '' || form.shift_grace_minutes == null ? null : Number(form.shift_grace_minutes); payload.default_output_unit = form.default_output_unit || null; payload.default_output_target = form.default_output_target === '' || form.default_output_target == null ? null : Number(form.default_output_target); payload.output_work_days = form.output_work_days || null; // Only keep per-project rates for project/task pay types; drop empty rows. if (form.salary_type === 'project' || form.salary_type === 'task') { payload.project_rates = (form.project_rates || []) .filter(r => (r.label || '').trim() || r.rate !== '' && r.rate != null) .map(r => ({ label: (r.label || '').trim(), rate: r.rate === '' || r.rate == null ? null : Number(r.rate) })); } else { payload.project_rates = null; } try { if (editing) { const { data } = await window.API.updateEmployee(editing.id, payload); if (data) setRows(prev => prev.map(r => r.id === editing.id ? data : r)); setModalOpen(false); } else { const { data } = await window.API.createEmployee(payload); if (data) { setRows(prev => [...prev, data]); // Switch into edit mode on the new record so documents can be attached // without leaving the modal. setEditing(data); setDocs(data.documents || []); } else { setModalOpen(false); } } } catch (e) { alert('Could not save employee. Please try again.'); } setSaving(false); } async function remove(e) { if (!window.confirm(`Remove ${e.name} from employees? This cannot be undone.`)) return; setRows(prev => prev.filter(r => r.id !== e.id)); try { await window.API.deleteEmployee(e.id); } catch {} } const filtered = rows.filter(r => { if (statusFilter !== 'all' && (r.status || 'active') !== statusFilter) return false; if (!search) return true; const q = search.toLowerCase(); return [r.name, r.email, r.designation, r.department].some(v => (v || '').toLowerCase().includes(q)); }); const activeCount = rows.filter(r => r.status === 'active').length; const onLeave = rows.filter(r => r.status === 'on_leave').length; const payroll = rows.filter(r => r.status !== 'terminated').reduce((s, r) => s + (Number(r.salary) || 0), 0); const stats = [ { label: 'Employees', val: rows.length, tone: 'var(--text-strong)' }, { label: 'Active', val: activeCount, tone: 'var(--green-600)' }, { label: 'On leave', val: onLeave, tone: 'var(--amber-600)' }, // Total salary is payroll data — owner/admin only (hidden from managers). ...(canManage ? [{ label: 'Monthly payroll', val: fmtMoney(payroll, 'PKR'), tone: 'var(--blue-600)' }] : []), ]; return (

Employees

Your team's HR directory — roles, departments, salary & status.

{canManage && hrTab === 'directory' && ( )}
{/* HR tabs — scroll sideways on mobile so tabs never get cut off */}
{[['directory', 'Directory', 'users'], ['payroll', 'Payroll', 'wallet'], ['attendance', 'Attendance', 'clock'], ['leave', 'Leave', 'plane'], ['output', 'Output', 'target']] // Payroll is salary data — owner/admin only. A manager sees the rest. .filter(([id]) => id !== 'payroll' || canManage) .map(([id, label, icon]) => ( ))}
{/* Terminated staff are excluded from day-to-day HR tabs (attendance, payroll, output). Leave keeps everyone for historical records. */} {hrTab === 'payroll' && canManage && r.status !== 'terminated')} canManage={canManage} />} {hrTab === 'attendance' && r.status !== 'terminated')} canManage={canManageStaffOps} />} {hrTab === 'leave' && } {/* Output is delivery/performance data, not salary — managers enter it day to day, same as attendance. (Employee DEFAULTS still need owner/admin, since those live on the employee record.) */} {hrTab === 'output' && r.status !== 'terminated')} canManage={canManageStaffOps} />} {hrTab === 'directory' && <> {/* Stat cards */}
{stats.map(s => (
{s.label}
{s.val}
))}
{/* Search + status filter */}
setSearch(e.target.value)} placeholder="Search name / email / designation / department…" style={{ flex: 1, minWidth: 200, maxWidth: 360, height: 36, padding: '0 12px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', outline: 'none', boxSizing: 'border-box' }} />
{['Name', 'Designation', 'Department', 'Type', ...(canManage ? ['Salary'] : []), 'Joined', 'Status', ''].map((h, i) => ( ))} {loading && } {!loading && filtered.length === 0 && ( )} {!loading && filtered.map(e => ( {canManage && ( )} ))}
{h}
Loading…
{search ? 'No employees match your search.' : 'No employees yet. Add your first one.'}
{e.name}
{e.email &&
{e.email}
}
{e.designation || '—'} {e.department || '—'} {TYPE_LABEL[e.employment_type] || '—'} {e.work_mode && · {WORK_MODE_LABEL[e.work_mode] || e.work_mode}} {e.salary_type && e.salary_type !== 'monthly' ? (e.salary ? fmtMoney(e.salary, e.currency) + (e.salary_type === 'project' ? ' /project' : ' /task') : (e.salary_type === 'project' ? 'Project-based' : 'Per task')) : fmtMoney(e.salary, e.currency)} {fmtDate(e.join_date)} {STATUS_LABEL[e.status] || e.status} {canManage && (
)}
} {/* Add / Edit modal */} setModalOpen(false)} title={editing ? 'Edit employee' : 'Add employee'} onSubmit={save} loading={saving} submitLabel={editing ? 'Save changes' : 'Add employee'}> {/* Profile photo */}
{form.profile_photo_url ? (
Profile
) : (
)}
set('name', titleCase(e.target.value))} placeholder="Name…" /> set('employee_code', e.target.value)} placeholder="e.g. TF-001" />
set('email', e.target.value)} placeholder="email@agency.com" /> set('phone', e.target.value)} placeholder="+92…" />
set('date_of_birth', e.target.value)} />
set('emergency_contact', e.target.value)} placeholder="Name & phone" />
set('designation', e.target.value)} placeholder="Designer, Developer…" /> set('department', e.target.value)} placeholder="Design, Marketing…" />
set('salary', e.target.value)} placeholder={form.salary_type === 'monthly' ? '50000' : 'Optional — har project ka rate neeche alag daalo'} />
{/* Per-project / per-task rates — each project can have its own rate. */} {(form.salary_type === 'project' || form.salary_type === 'task') && (
{(form.project_rates || []).map((r, i) => (
set('project_rates', form.project_rates.map((x, xi) => xi === i ? { ...x, label: e.target.value } : x))} /> set('project_rates', form.project_rates.map((x, xi) => xi === i ? { ...x, rate: e.target.value } : x))} />
))}
)}
set('join_date', e.target.value)} />
{/* Shift — expected in/out time. Blank inherits the workspace default, so only staff whose hours differ need anything filled in here. */}
Shift & timing
set('shift_start', e.target.value)} /> set('shift_end', e.target.value)} />
set('shift_grace_minutes', e.target.value)} placeholder="Khali chhodo to workspace ka default (15 min)" /> {/* Recurring output — pre-fills the Output tab so a fixed daily target is not retyped every day. Any single day can still be changed. */}
Daily output target
set('default_output_target', e.target.value)} placeholder="e.g. 4" />
{[['1','Mon'],['2','Tue'],['3','Wed'],['4','Thu'],['5','Fri'],['6','Sat'],['7','Sun']].map(([num,lbl]) => { const days = String(form.output_work_days || '').split(',').filter(Boolean); const on = days.includes(num); return ( ); })}
set('address', e.target.value)} placeholder="Optional" />