// ============================================================
// ЛИНИЯ ЖИЗНИ — 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 => (
{s.initials}
{s.name.split(' ')[0]}
{s.roleLabel}
|
))}
{TIME_SLOTS.map(slot => {
const isHour = slot.endsWith(':00');
return (
|
{isHour ? slot : {slot}}
|
{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 (
{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 (
);
}
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 => {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'; }}>
|
{isOpen?'▼':'▶'}{dateLabel}
|
{allEmps.map(name => {
const emp = (day.employees||[]).find(e => e.employee_name === name);
return {emp ? : —} | ;
})}
{isOpen && (day.employees||[]).flatMap(emp =>
(emp.slots||[]).map((slot, si) => (
| {slot.start_time}–{slot.end_time} |
{allEmps.map(name => {
if (name !== emp.employee_name) return · | ;
return (
{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 => {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'; }}>
|
{isOpen?'▼':'▶'}{dateLabel}
|
{allEq.map(name => {
const eq = (day.equipment||[]).find(e => e.equipment_name === name);
return {eq ? : —} | ;
})}
{isOpen && (day.equipment||[]).flatMap(eq =>
(eq.slots||[]).map((slot, si) => (
| {slot.start_time}–{slot.end_time} |
{allEq.map(name => {
if (name !== eq.equipment_name) return · | ;
return (
{slot.step_name}
{slot.procedure_name}
|
);
})}
))
)}
);
})}
)}
);
}
Object.assign(window, { StaffView, StaffReport, EquipmentReport });