4e1d5f221e
Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
14 KiB
JavaScript
243 lines
14 KiB
JavaScript
var TIMETRACK_EMPLOYEES = [
|
||
{id:1, name:'Алексей Петров', initials:'АП', dept:'Розробка', position:'Frontend розробник', type:'office'},
|
||
{id:2, name:'Мария Иванова', initials:'МІ', dept:'HR', position:'HR менеджер', type:'office'},
|
||
{id:3, name:'Дмитрий Козлов', initials:'ДК', dept:'Дизайн', position:'Дизайнер', type:'office'},
|
||
{id:4, name:'Елена Смирнова', initials:'ЄС', dept:'Фінанси', position:'Бухгалтер', type:'office'},
|
||
{id:5, name:'Игорь Волков', initials:'ІВ', dept:'Розробка', position:'Тімлід', type:'office'},
|
||
{id:6, name:'Ольга Новикова', initials:'ОН', dept:'Маркетинг', position:'Маркетолог', type:'office'},
|
||
{id:7, name:'Оксана Мельник', initials:'ОМ', dept:'А01', position:'Фармацевт', type:'pharma'},
|
||
{id:8, name:'Катерина Андріїв', initials:'КА', dept:'А07', position:'Завідуючий', type:'pharma'},
|
||
{id:9, name:'Василь Нечипор', initials:'ВН', dept:'А07', position:'Фармацевт', type:'pharma'},
|
||
{id:10,name:'Людмила Приходько',initials:'ЛП', dept:'А01', position:'Касир', type:'pharma'},
|
||
];
|
||
|
||
// Генерируем записи за текущий месяц
|
||
var TIMETRACK_RECORDS = (function() {
|
||
var records = [];
|
||
var now = new Date();
|
||
var year = now.getFullYear();
|
||
var month = now.getMonth();
|
||
var daysInMonth = new Date(year, month + 1, 0).getDate();
|
||
var today = now.getDate();
|
||
|
||
TIMETRACK_EMPLOYEES.forEach(function(emp) {
|
||
for (var d = 1; d <= Math.min(today, daysInMonth); d++) {
|
||
var date = new Date(year, month, d);
|
||
var dow = date.getDay();
|
||
if (dow === 0 || dow === 6) continue; // пропускаем выходные
|
||
|
||
var rand = Math.random();
|
||
var status, checkIn, checkOut, hours;
|
||
|
||
if (rand < 0.05) {
|
||
status = 'absent'; checkIn = null; checkOut = null; hours = 0;
|
||
} else if (rand < 0.12) {
|
||
status = 'sick'; checkIn = null; checkOut = null; hours = 0;
|
||
} else if (rand < 0.18) {
|
||
status = 'vacation'; checkIn = null; checkOut = null; hours = 0;
|
||
} else if (rand < 0.25) {
|
||
// опоздание
|
||
var lateMin = 10 + Math.floor(Math.random() * 50);
|
||
var inH = 9, inM = lateMin;
|
||
checkIn = pad(inH) + ':' + pad(inM);
|
||
checkOut = '18:' + pad(Math.floor(Math.random() * 30));
|
||
hours = Math.round(((18 * 60 + 15) - (inH * 60 + inM)) / 60 * 10) / 10;
|
||
status = 'late';
|
||
} else {
|
||
var inM2 = Math.floor(Math.random() * 15);
|
||
checkIn = '09:' + pad(inM2);
|
||
var outM = 0 + Math.floor(Math.random() * 30);
|
||
checkOut = '18:' + pad(outM);
|
||
hours = Math.round(((18 * 60 + outM) - (9 * 60 + inM2)) / 60 * 10) / 10;
|
||
status = 'present';
|
||
}
|
||
|
||
records.push({
|
||
empId: emp.id,
|
||
date: year + '-' + pad(month + 1) + '-' + pad(d),
|
||
dayLabel: pad(d) + '.' + pad(month + 1),
|
||
dow: dow,
|
||
status: status,
|
||
checkIn: checkIn,
|
||
checkOut: checkOut,
|
||
hours: hours
|
||
});
|
||
}
|
||
});
|
||
return records;
|
||
})();
|
||
|
||
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||
|
||
var TTSTATUS = {
|
||
present: {label:'Присутній', cls:'badge-success', color:'#22c55e'},
|
||
late: {label:'Запізнення',cls:'badge-warning', color:'#f59e0b'},
|
||
absent: {label:'Відсутній', cls:'badge-danger', color:'#ef4444'},
|
||
sick: {label:'Хворіє', cls:'badge-info', color:'#3b82f6'},
|
||
vacation:{label:'Відпустка', cls:'badge-muted', color:'#94a3b8'},
|
||
};
|
||
|
||
var ttState = {
|
||
view: 'table', // table | calendar
|
||
filter: 'all', // all | office | pharma
|
||
empId: null, // выбранный сотрудник для детали
|
||
};
|
||
|
||
function ttInit() {
|
||
if (!document.getElementById('tt-tbody')) return;
|
||
ttRenderSummary();
|
||
ttRenderTable();
|
||
}
|
||
|
||
function ttRenderSummary() {
|
||
var now = new Date();
|
||
var year = now.getFullYear(), month = now.getMonth();
|
||
var workdays = 0;
|
||
for (var d = 1; d <= now.getDate(); d++) {
|
||
var dow = new Date(year, month, d).getDay();
|
||
if (dow > 0 && dow < 6) workdays++;
|
||
}
|
||
|
||
var recs = TIMETRACK_RECORDS;
|
||
var present = recs.filter(function(r){ return r.status === 'present'; }).length;
|
||
var late = recs.filter(function(r){ return r.status === 'late'; }).length;
|
||
var absent = recs.filter(function(r){ return r.status === 'absent'; }).length;
|
||
var sick = recs.filter(function(r){ return r.status === 'sick'; }).length;
|
||
var vacation = recs.filter(function(r){ return r.status === 'vacation'; }).length;
|
||
var totalHours = recs.reduce(function(s,r){ return s + r.hours; }, 0);
|
||
|
||
setText('tt-stat-workdays', workdays + ' дн.');
|
||
setText('tt-stat-present', present);
|
||
setText('tt-stat-late', late);
|
||
setText('tt-stat-absent', absent + sick);
|
||
setText('tt-stat-hours', Math.round(totalHours) + ' год');
|
||
}
|
||
|
||
function setText(id, val) {
|
||
var el = document.getElementById(id);
|
||
if (el) el.textContent = val;
|
||
}
|
||
|
||
function ttGetFiltered() {
|
||
var emps = TIMETRACK_EMPLOYEES.filter(function(e) {
|
||
if (ttState.filter === 'office') return e.type === 'office';
|
||
if (ttState.filter === 'pharma') return e.type === 'pharma';
|
||
return true;
|
||
});
|
||
return emps;
|
||
}
|
||
|
||
function ttRenderTable() {
|
||
var tbody = document.getElementById('tt-tbody');
|
||
if (!tbody) return;
|
||
|
||
var emps = ttGetFiltered();
|
||
var now = new Date();
|
||
var year = now.getFullYear(), month = now.getMonth();
|
||
|
||
tbody.innerHTML = emps.map(function(emp) {
|
||
var empRecs = TIMETRACK_RECORDS.filter(function(r){ return r.empId === emp.id; });
|
||
var totalH = empRecs.reduce(function(s,r){ return s + r.hours; }, 0);
|
||
var days = empRecs.length;
|
||
var lates = empRecs.filter(function(r){ return r.status === 'late'; }).length;
|
||
var absents = empRecs.filter(function(r){ return r.status === 'absent'; }).length;
|
||
var sicks = empRecs.filter(function(r){ return r.status === 'sick'; }).length;
|
||
|
||
// Мини-тепловая карта последних 7 рабочих дней
|
||
var lastRecs = empRecs.slice(-7);
|
||
var heatmap = lastRecs.map(function(r) {
|
||
var c = TTSTATUS[r.status] || TTSTATUS.present;
|
||
return '<span title="' + r.dayLabel + ': ' + (TTSTATUS[r.status] || {label:''}).label + '" style="display:inline-block;width:10px;height:18px;border-radius:2px;background:' + c.color + ';opacity:0.8;margin:0 1px;"></span>';
|
||
}).join('');
|
||
|
||
var typeBadge = emp.type === 'pharma'
|
||
? '<span style="font-size:0.625rem;padding:1px 5px;border-radius:10px;background:#f0fdf4;color:#15803d;font-weight:500;">' + emp.dept + '</span>'
|
||
: '<span style="font-size:0.625rem;padding:1px 5px;border-radius:10px;background:#eff6ff;color:#1d4ed8;font-weight:500;">' + emp.dept + '</span>';
|
||
|
||
return '<tr class="tr-hover" style="border-bottom:1px solid var(--color-border);cursor:pointer;" onclick="ttShowDetail(' + emp.id + ')">' +
|
||
'<td style="padding:0.875rem 1rem;vertical-align:middle;">' +
|
||
'<div style="display:flex;align-items:center;gap:0.625rem;">' +
|
||
'<div style="width:2rem;height:2rem;border-radius:50%;background:color-mix(in srgb,var(--color-primary) 10%,transparent);color:var(--color-primary);display:flex;align-items:center;justify-content:center;font-size:0.6875rem;font-weight:500;flex-shrink:0;">' + emp.initials + '</div>' +
|
||
'<div><div style="font-weight:500;color:var(--color-card-foreground);font-size:0.875rem;">' + emp.name + '</div>' +
|
||
'<div style="font-size:0.75rem;color:var(--color-muted-foreground);margin-top:1px;">' + emp.position + '</div></div>' +
|
||
'</div>' +
|
||
'</td>' +
|
||
'<td style="padding:0.875rem 1rem;vertical-align:middle;">' + typeBadge + '</td>' +
|
||
'<td style="padding:0.875rem 1rem;vertical-align:middle;color:var(--color-foreground);font-weight:500;">' + Math.round(totalH) + ' год</td>' +
|
||
'<td style="padding:0.875rem 1rem;vertical-align:middle;color:var(--color-foreground);">' + days + '</td>' +
|
||
'<td style="padding:0.875rem 1rem;vertical-align:middle;color:' + (lates > 0 ? 'var(--color-warning)' : 'var(--color-muted-foreground)') + ';">' + lates + '</td>' +
|
||
'<td style="padding:0.875rem 1rem;vertical-align:middle;color:' + (absents + sicks > 0 ? 'var(--color-destructive)' : 'var(--color-muted-foreground)') + ';">' + (absents + sicks) + '</td>' +
|
||
'<td style="padding:0.875rem 1rem;vertical-align:middle;">' + heatmap + '</td>' +
|
||
'</tr>';
|
||
}).join('');
|
||
|
||
var countEl = document.getElementById('tt-count');
|
||
if (countEl) countEl.textContent = 'Показано ' + emps.length + ' з ' + TIMETRACK_EMPLOYEES.length + ' співробітників';
|
||
}
|
||
|
||
function ttShowDetail(empId) {
|
||
ttState.empId = empId;
|
||
var emp = TIMETRACK_EMPLOYEES.find(function(e){ return e.id === empId; });
|
||
var empRecs = TIMETRACK_RECORDS.filter(function(r){ return r.empId === empId; });
|
||
var panel = document.getElementById('tt-detail');
|
||
var overlay = document.getElementById('tt-detail-overlay');
|
||
if (!panel || !emp) return;
|
||
|
||
var rows = empRecs.map(function(r) {
|
||
var sc = TTSTATUS[r.status] || TTSTATUS.present;
|
||
return '<tr class="tr-hover" style="border-bottom:1px solid var(--color-border);">' +
|
||
'<td style="padding:0.625rem 1rem;color:var(--color-muted-foreground);font-size:0.8125rem;">' + r.dayLabel + '</td>' +
|
||
'<td style="padding:0.625rem 1rem;font-size:0.8125rem;color:var(--color-foreground);">' + (r.checkIn || '—') + '</td>' +
|
||
'<td style="padding:0.625rem 1rem;font-size:0.8125rem;color:var(--color-foreground);">' + (r.checkOut || '—') + '</td>' +
|
||
'<td style="padding:0.625rem 1rem;font-size:0.8125rem;color:var(--color-foreground);">' + (r.hours > 0 ? r.hours + ' год' : '—') + '</td>' +
|
||
'<td style="padding:0.625rem 1rem;"><span class="badge ' + sc.cls + '">' + sc.label + '</span></td>' +
|
||
'</tr>';
|
||
}).join('');
|
||
|
||
panel.innerHTML =
|
||
'<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid var(--color-border);">' +
|
||
'<div style="display:flex;align-items:center;gap:0.75rem;">' +
|
||
'<div style="width:2.25rem;height:2.25rem;border-radius:50%;background:color-mix(in srgb,var(--color-primary) 10%,transparent);color:var(--color-primary);display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:500;">' + emp.initials + '</div>' +
|
||
'<div><div style="font-weight:600;font-size:0.9375rem;color:var(--color-foreground);">' + emp.name + '</div>' +
|
||
'<div style="font-size:0.75rem;color:var(--color-muted-foreground);">' + emp.position + ' · ' + emp.dept + '</div></div>' +
|
||
'</div>' +
|
||
'<button onclick="ttCloseDetail()" class="btn btn-ghost btn-icon">' +
|
||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' +
|
||
'</button>' +
|
||
'</div>' +
|
||
'<div style="overflow-y:auto;max-height:calc(100% - 60px);">' +
|
||
'<table style="width:100%;border-collapse:collapse;">' +
|
||
'<thead><tr style="border-bottom:1px solid var(--color-border);">' +
|
||
'<th style="padding:0.625rem 1rem;text-align:left;font-size:0.75rem;color:var(--color-muted-foreground);font-weight:600;">Дата</th>' +
|
||
'<th style="padding:0.625rem 1rem;text-align:left;font-size:0.75rem;color:var(--color-muted-foreground);font-weight:600;">Прихід</th>' +
|
||
'<th style="padding:0.625rem 1rem;text-align:left;font-size:0.75rem;color:var(--color-muted-foreground);font-weight:600;">Відхід</th>' +
|
||
'<th style="padding:0.625rem 1rem;text-align:left;font-size:0.75rem;color:var(--color-muted-foreground);font-weight:600;">Годин</th>' +
|
||
'<th style="padding:0.625rem 1rem;text-align:left;font-size:0.75rem;color:var(--color-muted-foreground);font-weight:600;">Статус</th>' +
|
||
'</tr></thead>' +
|
||
'<tbody>' + rows + '</tbody>' +
|
||
'</table>' +
|
||
'</div>';
|
||
|
||
if (overlay) overlay.style.display = 'none';
|
||
panel.style.display = 'flex';
|
||
}
|
||
|
||
function ttCloseDetail() {
|
||
var panel = document.getElementById('tt-detail');
|
||
var overlay = document.getElementById('tt-detail-overlay');
|
||
if (panel) panel.style.display = 'none';
|
||
if (overlay) overlay.style.display = 'none';
|
||
}
|
||
|
||
function ttSetFilter(f) {
|
||
ttState.filter = f;
|
||
['tt-filter-all','tt-filter-office','tt-filter-pharma'].forEach(function(id) {
|
||
var el = document.getElementById(id);
|
||
if (!el) return;
|
||
var active = id === 'tt-filter-' + f;
|
||
el.style.background = active ? 'var(--color-card)' : 'transparent';
|
||
el.style.color = active ? 'var(--color-foreground)' : 'var(--color-muted-foreground)';
|
||
});
|
||
ttRenderTable();
|
||
}
|