// ============================================================ // ЛИНИЯ ЖИЗНИ — Staff Board + Reports // ============================================================ /* ── STAFF BOARD ────────────────────────────────────────────── */ function StaffView({ schedule, procedures }) { const staffList = window.STAFF_LIST || []; const blockedCells = React.useMemo(() => { const set = new Set(); schedule.forEach(entry => { const span = Math.max(1, Math.ceil((findStageForEntryV(entry)?.duration || 30) / 30)); const startIdx = getSlotIndex(entry.slot); for (let i = 1; i < span; i++) { if (startIdx + i < TIME_SLOTS.length) set.add(`${TIME_SLOTS[startIdx + i]}::${entry.staffId}`); } }); return set; }, [schedule]); function findStageForEntryV(entry) { for (const pt of (window.PROCEDURE_TYPES||[])) { const s = pt.stages.find(s => s.id === entry.stageId); if (s) return s; } return null; } const thBase = { padding:'8px 6px', fontSize:11, fontWeight:700, textAlign:'center', borderBottom:`2px solid ${T.border}`, position:'sticky', top:0, zIndex:5, background:'#F8FAFC', fontFamily:T.ff, color:T.text1, whiteSpace:'nowrap', border:`1px solid ${T.border}` }; return (
Только просмотр — данные из вкладки ОСНОВ.ДОСКА
{staffList.map(s => )} {staffList.map(s => ( ))} {TIME_SLOTS.map(slot => { const isHour = slot.endsWith(':00'); return ( {staffList.map(staff => { if (blockedCells.has(`${slot}::${staff.id}`)) return null; const entry = schedule.find(e => e.staffId === staff.id && e.slot === slot); if (!entry) return ; const proc = procedures.find(p => p.id === entry.procId); const pt = (window.PROCEDURE_TYPES||[]).find(t => t.id === proc?.typeId); const stage = pt?.stages?.find(s => s.id === entry.stageId); const eq = (window.EQUIPMENT_LIST||[]).find(e => e.id === entry.eqId); const span = Math.max(1, Math.ceil((stage?.duration||30)/30)); return ( ); })} ); })}
{s.initials}
{s.name.split(' ')[0]}
{s.roleLabel}
{isHour ? slot : {slot}}
{pt?.name||'—'}
{stage?.name||'—'}
{eq?.name||'—'}
); } /* ── Helpers for reports ────────────────────────────────────── */ function fmtH(min) { const h=Math.floor(min/60), m=min%60; return h>0 ? (m>0?`${h}ч ${m}м`:`${h}ч`) : `${m}м`; } function LoadBar({ value, max }) { const pct = max > 0 ? Math.min(100, Math.round(value/max*100)) : 0; const color = pct>70 ? T.red.dot : pct>40 ? T.amber.dot : T.teal; return (
{fmtH(value)}
); } function ReportDatePicker({ onLoad, label }) { const today = new Date(); const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; const weekAgo = new Date(today); weekAgo.setDate(weekAgo.getDate()-7); const [from, setFrom] = React.useState(fmt(weekAgo)); const [to, setTo] = React.useState(fmt(today)); const [groupBy, setGroupBy] = React.useState('day'); const [loading, setLoading] = React.useState(false); const handleLoad = async () => { setLoading(true); try { await onLoad(from, to, groupBy); } finally { setLoading(false); } }; const inp2 = { border:`1.5px solid ${T.border}`, borderRadius:8, padding:'7px 10px', fontSize:13, color:T.text1, outline:'none', fontFamily:'inherit', background:'#FAFBFC' }; const groupBtn = (val, lbl) => ({ padding:'5px 12px', borderRadius:7, border:`1.5px solid ${groupBy===val?T.navy:T.border}`, background: groupBy===val ? T.navy : 'transparent', color: groupBy===val ? '#fff' : T.text3, cursor:'pointer', fontSize:12, fontWeight:700, fontFamily:T.ff, }); return (
Период: Группировка: {loading?'Загрузка…':'Загрузить'}
); } function fmtPeriod(workDate, groupBy) { const d = new Date(workDate+'T12:00:00'); if (groupBy === 'month') { return d.toLocaleDateString('ru-RU', { year:'numeric', month:'long' }); } return d.toLocaleDateString('ru-RU', { day:'numeric', month:'short' }); } /* ── STAFF REPORT ───────────────────────────────────────────── */ function StaffReport() { const [days, setDays] = React.useState(null); const [expanded, setExpanded] = React.useState({}); const [groupBy, setGroupBy] = React.useState('day'); const load = async (from, to, gb='day') => { setGroupBy(gb); const data = await LL.loadReportEmployees(from, to, gb); setDays(data || []); setExpanded({}); }; const toggle = d => setExpanded(p => ({...p,[d]:!p[d]})); // Collect all unique employee names from loaded data const allEmps = React.useMemo(() => { if (!days) return []; const names = new Set(); days.forEach(day => (day.employees||[]).forEach(e => names.add(e.employee_name))); return [...names].sort(); }, [days]); const maxMin = TIME_SLOTS.length * 30; const thBase = { padding:'10px 12px', fontSize:11, fontWeight:700, color:T.text3, textAlign:'center', borderBottom:`2px solid ${T.border}`, whiteSpace:'nowrap', background:'#F8FAFC', fontFamily:T.ff }; const td = { padding:'9px 12px', fontSize:13, textAlign:'center', borderBottom:`1px solid #F1F5F9`, color:T.text1 }; return (

Загруженность сотрудников

Суммарные трудозатраты по дням · нажмите строку для детализации

{days === null &&
Выберите период и нажмите «Загрузить»
} {days !== null && days.length === 0 &&
Нет данных за выбранный период
} {days !== null && days.length > 0 && (
{allEmps.map(name => )} {days.map(day => { const isOpen = expanded[day.work_date]; const dateLabel = fmtPeriod(day.work_date, groupBy); return ( toggle(day.work_date)} style={{ cursor:'pointer', background:isOpen?'#F0F9FF':'#fff' }} onMouseEnter={e=>{ if(!isOpen) e.currentTarget.style.background='#F8FAFC'; }} onMouseLeave={e=>{ if(!isOpen) e.currentTarget.style.background='#fff'; }}> {allEmps.map(name => { const emp = (day.employees||[]).find(e => e.employee_name === name); return ; })} {isOpen && (day.employees||[]).flatMap(emp => (emp.slots||[]).map((slot, si) => ( {allEmps.map(name => { if (name !== emp.employee_name) return ; return ( ); })} )) )} ); })}
Дата
{name}
{isOpen?'▼':'▶'}{dateLabel} {emp ? : }
{slot.start_time}–{slot.end_time}· {slot.step_name}
{slot.procedure_name}
)}
); } /* ── EQUIPMENT REPORT ───────────────────────────────────────── */ function EquipmentReport() { const [days, setDays] = React.useState(null); const [expanded, setExpanded] = React.useState({}); const [groupBy, setGroupBy] = React.useState('day'); const load = async (from, to, gb='day') => { setGroupBy(gb); const data = await LL.loadReportEquipment(from, to, gb); setDays(data || []); setExpanded({}); }; const toggle = d => setExpanded(p => ({...p,[d]:!p[d]})); const allEq = React.useMemo(() => { if (!days) return []; const names = new Set(); days.forEach(day => (day.equipment||[]).forEach(e => names.add(e.equipment_name))); return [...names].sort(); }, [days]); const maxMin = TIME_SLOTS.length * 30; const thBase = { padding:'9px 8px', fontSize:10, fontWeight:700, color:T.text3, textAlign:'center', borderBottom:`2px solid ${T.border}`, background:'#F8FAFC', fontFamily:T.ff }; const td = { padding:'8px 10px', fontSize:12, textAlign:'center', borderBottom:`1px solid #F1F5F9` }; return (

Загруженность оборудования

Суммарная занятость по дням · нажмите строку для детализации

{days === null &&
Выберите период и нажмите «Загрузить»
} {days !== null && days.length === 0 &&
Нет данных за выбранный период
} {days !== null && days.length > 0 && (
{allEq.map(name => )} {days.map(day => { const isOpen = expanded[day.work_date]; const dateLabel = fmtPeriod(day.work_date, groupBy); return ( toggle(day.work_date)} style={{ cursor:'pointer', background:isOpen?'#F0F9FF':'#fff' }} onMouseEnter={e=>{ if(!isOpen) e.currentTarget.style.background='#F8FAFC'; }} onMouseLeave={e=>{ if(!isOpen) e.currentTarget.style.background='#fff'; }}> {allEq.map(name => { const eq = (day.equipment||[]).find(e => e.equipment_name === name); return ; })} {isOpen && (day.equipment||[]).flatMap(eq => (eq.slots||[]).map((slot, si) => ( {allEq.map(name => { if (name !== eq.equipment_name) return ; return ( ); })} )) )} ); })}
Дата
{name}
{isOpen?'▼':'▶'}{dateLabel} {eq ? : }
{slot.start_time}–{slot.end_time}· {slot.step_name}
{slot.procedure_name}
)}
); } Object.assign(window, { StaffView, StaffReport, EquipmentReport });