// =============================================
// Recycle Object — Time Tracking
// Учет рабочего времени сотрудников + план/факт по этапам
// =============================================
const TT_STAGE_LABELS = {
casting: 'Выливание пластика',
trim: 'Срезание литника',
assembly: 'Сборка',
packaging: 'Упаковка',
other: 'Другое',
};
const TT_STAGE_ORDER = ['casting', 'trim', 'assembly', 'packaging', 'other'];
const TT_PRODUCTION_STATUSES = ['sample', 'production_casting', 'production_printing', 'production_hardware', 'production_packaging', 'in_production', 'delivery'];
const TT_LEGACY_IMPORT_MARKER = 'ro_tt_legacy_import_2026_03_first_half_v1';
const TT_LEGACY_FIRST_HALF_IMPORT = [
{ employee: 'Тая', date: '2026-03-02', project_name: 'московская неделя моды', hours: 4 },
{ employee: 'Тая', date: '2026-03-03', project_name: 'мтс воркшоп', hours: 14 },
{ employee: 'Тая', date: '2026-03-05', project_name: 'мтс воркшоп', hours: 13 },
{ employee: 'Тая', date: '2026-03-06', project_name: 'мтс воркшоп', hours: 2 },
{ employee: 'Тая', date: '2026-03-10', project_name: 'мтс воркшоп', hours: 2 },
{ employee: 'Тая', date: '2026-03-10', project_name: 'броши пушкинкий', hours: 8 },
{ employee: 'Тая', date: '2026-03-11', project_name: 'броши пушкинкий', hours: 3 },
{ employee: 'Тая', date: '2026-03-12', project_name: 'броши пушкинкий', hours: 3 },
{ employee: 'Тая', date: '2026-03-12', project_name: 'эндостар', hours: 10 },
{ employee: 'Тая', date: '2026-03-13', project_name: 'броши пушкинкий', hours: 2 },
{ employee: 'Женя Г', date: '2026-03-02', project_name: 'московская неделя моды', hours: 4 },
{ employee: 'Женя Г', date: '2026-03-02', project_name: 'мтс воркшоп', hours: 5 },
{ employee: 'Женя Г', date: '2026-03-03', project_name: 'мтс воркшоп', hours: 9 },
{ employee: 'Женя Г', date: '2026-03-04', project_name: 'мтс воркшоп', hours: 9 },
{ employee: 'Женя Г', date: '2026-03-05', project_name: 'мтс воркшоп', hours: 9 },
{ employee: 'Женя Г', date: '2026-03-06', project_name: 'мтс воркшоп', hours: 8 },
];
const TT_LEGACY_IMPORT_SKIP_DATES = {
'Женя Г': new Set(['2026-03-10', '2026-03-12', '2026-03-13']),
};
const TimeTrack = {
entries: [],
employees: [],
editingEntryId: null,
_periodNote: '',
async load() {
this.entries = (await loadTimeEntries()) || [];
this.employees = (await loadEmployees()) || [];
const importedLegacy = await this.backfillLegacyFirstHalfEntries();
if (importedLegacy > 0) {
this.entries = (await loadTimeEntries()) || [];
}
const repairedDates = await this.repairLegacyTimezoneShiftedEntries();
if (repairedDates > 0) {
this.entries = (await loadTimeEntries()) || [];
}
this.populateWorkerSelect();
this.populateFilters();
this.populatePayrollMonthSelect();
await this.populateProjectSelect();
this.ensureSensibleDefaultPeriod();
this.filterAndRender();
this.updateStats();
this.renderDailyStatus();
this.renderPayrollSummary();
this.onStageChange();
const dateInput = document.getElementById('tt-date');
if (dateInput && !dateInput.value) {
dateInput.value = this.getCurrentReportDate();
}
},
// === UI toggles ===
showBotSetup() {
document.getElementById('tt-bot-setup').style.display = '';
},
hideBotSetup() {
document.getElementById('tt-bot-setup').style.display = 'none';
},
showManualEntry() {
document.getElementById('tt-manual-form').style.display = '';
this.syncManualEntryFormState();
this.onStageChange();
},
hideManualEntry() {
this.resetManualEntryForm();
document.getElementById('tt-manual-form').style.display = 'none';
},
syncManualEntryFormState() {
const titleEl = document.getElementById('tt-manual-title');
const saveBtn = document.getElementById('tt-save-entry-btn');
const cancelBtn = document.getElementById('tt-cancel-entry-btn');
const isEditing = this.editingEntryId != null;
if (titleEl) titleEl.textContent = isEditing ? 'Редактировать запись' : 'Добавить запись вручную';
if (saveBtn) saveBtn.textContent = isEditing ? 'Сохранить изменения' : 'Сохранить';
if (cancelBtn) cancelBtn.textContent = isEditing ? 'Отмена' : 'Закрыть';
},
resetManualEntryForm() {
this.editingEntryId = null;
const workerEl = document.getElementById('tt-worker-name');
const projectEl = document.getElementById('tt-project-select');
const hoursEl = document.getElementById('tt-hours');
const dateEl = document.getElementById('tt-date');
const descEl = document.getElementById('tt-description');
const stageEl = document.getElementById('tt-stage');
const stageOtherEl = document.getElementById('tt-stage-other');
if (workerEl) workerEl.value = '';
if (projectEl) projectEl.value = '';
if (hoursEl) hoursEl.value = '';
if (descEl) descEl.value = '';
if (stageEl) stageEl.value = 'casting';
if (stageOtherEl) stageOtherEl.value = '';
if (dateEl && !dateEl.value) {
dateEl.value = this.getCurrentReportDate();
}
this.syncManualEntryFormState();
this.onStageChange();
},
onStageChange() {
const stage = (document.getElementById('tt-stage')?.value || 'casting');
const wrap = document.getElementById('tt-stage-other-wrap');
if (wrap) wrap.style.display = stage === 'other' ? '' : 'none';
},
// === Metadata parser for backward compatibility ===
parseMeta(entry) {
if (!entry) return { stage: '', stage_label: '', project: '' };
if (entry._parsedMeta) return entry._parsedMeta;
const desc = String(entry.description || entry.task_description || '');
const meta = { stage: '', stage_label: '', project: '' };
const markerMatch = desc.match(/^\[meta\](\{.*?\})\[\/meta\]\s*/);
if (markerMatch) {
try {
const parsed = JSON.parse(markerMatch[1]);
if (parsed && parsed.stage) {
meta.stage = parsed.stage;
meta.stage_label = parsed.stage_label || TT_STAGE_LABELS[parsed.stage] || parsed.stage;
}
if (parsed && parsed.project) meta.project = parsed.project;
} catch (e) {
// ignore
}
}
if (!meta.stage) {
const stageMatch = desc.match(/(?:^|\n)Этап:\s*([^\n]+)/i);
if (stageMatch) {
meta.stage_label = stageMatch[1].trim();
meta.stage = this.labelToStageKey(meta.stage_label);
}
}
if (!meta.stage && entry.stage) {
meta.stage = entry.stage;
meta.stage_label = TT_STAGE_LABELS[entry.stage] || entry.stage;
}
entry._parsedMeta = meta;
return meta;
},
stripMetaPrefix(description) {
const raw = String(description || '');
return raw
.replace(/^\[meta\]\{.*?\}\[\/meta\]\s*/s, '')
.replace(/^(?:Этап:\s*[^\n]+\n?)+/i, '')
.trim();
},
labelToStageKey(label) {
const normalized = String(label || '').trim().toLowerCase();
if (!normalized) return '';
if (normalized.includes('вылив')) return 'casting';
if (normalized.includes('литник') || normalized.includes('лейник') || normalized.includes('срез')) return 'trim';
if (normalized.includes('сбор')) return 'assembly';
if (normalized.includes('упаков')) return 'packaging';
return 'other';
},
stageKey(entry) {
const meta = this.parseMeta(entry);
return meta.stage || '';
},
stageLabel(entry) {
const meta = this.parseMeta(entry);
if (meta.stage_label) return meta.stage_label;
if (meta.stage) return TT_STAGE_LABELS[meta.stage] || meta.stage;
return '—';
},
buildDescriptionWithMeta(stage, stageLabel, description, projectName) {
const cleanDesc = String(description || '').trim();
const meta = { stage, stage_label: stageLabel };
if (projectName) meta.project = projectName;
const payload = JSON.stringify(meta);
return `[meta]${payload}[/meta] ${cleanDesc}`.trim();
},
// === Populate selects ===
/** Active production employees + recent production people from time entries */
_getProductionEmployees() {
const recentProductionIds = new Set((this.entries || [])
.map(entry => this.findEmployeeForEntry(entry))
.filter(emp => emp && emp.role === 'production')
.map(emp => String(emp.id)));
return (this.employees || [])
.filter(emp => emp.role === 'production')
.filter(emp => emp.is_active !== false || recentProductionIds.has(String(emp.id)))
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru'));
},
populateWorkerSelect() {
const select = document.getElementById('tt-worker-name');
if (!select) return;
select.innerHTML = '';
const workers = this._getProductionEmployees();
workers.forEach(e => {
const opt = document.createElement('option');
opt.value = e.name;
opt.textContent = e.name;
select.appendChild(opt);
});
},
async populateProjectSelect() {
const select = document.getElementById('tt-project-select');
if (!select) return;
while (select.options.length > 2) select.remove(2);
const orders = await loadOrders({});
const activeProduction = (orders || []).filter(o => TT_PRODUCTION_STATUSES.includes(o.status));
activeProduction.forEach(o => {
const opt = document.createElement('option');
opt.value = o.id;
opt.textContent = o.order_name + (o.client_name ? ` (${o.client_name})` : '');
select.appendChild(opt);
});
},
// === Daily Status ===
renderDailyStatus() {
const container = document.getElementById('tt-daily-status-content');
const dateEl = document.getElementById('tt-daily-date');
if (!container) return;
const today = this.getCurrentReportDate();
if (dateEl) dateEl.textContent = today;
const activeEmployees = this._getProductionEmployees();
if (activeEmployees.length === 0) {
container.innerHTML = '
Нет производственных сотрудников
';
return;
}
const todayEntries = this.entries.filter(e => e.date === today);
const byWorker = {};
todayEntries.forEach(e => {
const employee = this.findEmployeeForEntry(e);
if (!employee || employee.role !== 'production') return;
const key = String(employee.id);
if (!byWorker[key]) byWorker[key] = [];
byWorker[key].push({ ...e, worker_name: employee.name || e.worker_name });
});
const roleLabels = { production: 'Пр', office: 'Оф', management: 'Рук' };
const rows = activeEmployees.map(emp => {
const entries = byWorker[String(emp.id)] || [];
const totalHours = entries.reduce((s, e) => s + (parseFloat(e.hours) || 0), 0);
const totalPct = emp.daily_hours > 0 ? Math.round(totalHours / emp.daily_hours * 100) : 0;
let icon, statusColor, statusText;
if (totalPct >= 100) {
icon = '✔';
statusColor = 'var(--green)';
statusText = `${totalPct}%`;
} else if (totalPct > 0) {
icon = '⚠';
statusColor = 'var(--orange)';
statusText = `${totalPct}% — не закончил`;
} else {
icon = '❌';
statusColor = 'var(--text-muted)';
statusText = 'не отчитался';
}
const projectList = entries.length > 0
? entries.map(e => {
const stage = this.stageLabel(e);
return `${this.esc(e.project_name)} / ${this.esc(stage)} (${parseFloat(e.hours) || 0}ч)`;
}).join(', ')
: '';
const roleBadge = `${roleLabels[emp.role] || ''}`;
return `
${icon}
${this.esc(emp.name)}
${roleBadge}
${statusText}
${projectList ? `
${projectList}
` : ''}
${totalHours.toFixed(1)}ч / ${emp.daily_hours}ч
`;
});
container.innerHTML = rows.join('');
},
// === Filters ===
populateFilters() {
const productionNames = this._getProductionEmployees().map(e => e.name);
const entryWorkers = this.entries.map(e => e.worker_name).filter(Boolean);
const allWorkers = [...new Set([...productionNames, ...entryWorkers])].sort();
const wSelect = document.getElementById('tt-filter-worker');
while (wSelect.options.length > 1) wSelect.remove(1);
allWorkers.forEach(w => {
const opt = document.createElement('option');
opt.value = w;
opt.textContent = w;
wSelect.appendChild(opt);
});
const projects = [...new Set(this.entries.map(e => e.project_name).filter(Boolean))];
const pSelect = document.getElementById('tt-filter-project');
while (pSelect.options.length > 1) pSelect.remove(1);
projects.sort().forEach(p => {
const opt = document.createElement('option');
opt.value = p;
opt.textContent = p;
pSelect.appendChild(opt);
});
},
getCurrentMonthPrefix() {
return String(this.getCurrentReportDate() || '').slice(0, 7);
},
formatPayrollMonthLabel(monthPrefix) {
const match = String(monthPrefix || '').match(/^(\d{4})-(\d{2})$/);
if (!match) return String(monthPrefix || '');
const [, year, month] = match;
const monthIndex = parseInt(month, 10) - 1;
const monthNames = [
'январь', 'февраль', 'март', 'апрель', 'май', 'июнь',
'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь',
];
return `${monthNames[monthIndex] || month} ${year}`;
},
getAvailablePayrollMonths() {
const monthSet = new Set();
(this.entries || []).forEach(entry => {
const rawDate = String(entry?.date || '').trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(rawDate)) {
monthSet.add(rawDate.slice(0, 7));
}
});
monthSet.add(this.getCurrentMonthPrefix());
return [...monthSet].sort((a, b) => b.localeCompare(a));
},
populatePayrollMonthSelect() {
const select = document.getElementById('tt-payroll-month');
if (!select) return;
const previousValue = String(select.value || '');
const currentMonth = this.getCurrentMonthPrefix();
const months = this.getAvailablePayrollMonths();
select.innerHTML = '';
months.forEach(monthPrefix => {
const opt = document.createElement('option');
opt.value = monthPrefix;
opt.textContent = this.formatPayrollMonthLabel(monthPrefix);
select.appendChild(opt);
});
const hasCurrentMonthHours = (this.entries || []).some(entry => String(entry?.date || '').startsWith(`${currentMonth}-`));
if (previousValue && months.includes(previousValue)) {
select.value = previousValue;
return;
}
if (hasCurrentMonthHours) {
select.value = currentMonth;
return;
}
const latestWithHours = months.find(monthPrefix => monthPrefix !== currentMonth) || currentMonth;
select.value = latestWithHours;
},
getSelectedPayrollMonthPrefix() {
const select = document.getElementById('tt-payroll-month');
const selected = String(select?.value || '').trim();
if (/^\d{4}-\d{2}$/.test(selected)) return selected;
return this.getCurrentMonthPrefix();
},
getPreviousMonthRange(today = this.getCurrentReportDate()) {
const parsed = this.parseYMD(`${String(today || '').slice(0, 7)}-01`);
if (!parsed) return null;
parsed.setUTCMonth(parsed.getUTCMonth() - 1);
const start = this.formatUtcYMD(parsed);
parsed.setUTCMonth(parsed.getUTCMonth() + 1);
const end = this.formatUtcYMD(parsed);
return { start, end };
},
entryMatchesPeriod(entry, period, today = this.getCurrentReportDate()) {
const rawDate = String(entry?.date || '').trim();
if (!rawDate) return false;
if (period === 'week') return rawDate >= this.shiftYMD(today, -7);
if (period === 'month') return rawDate >= `${today.slice(0, 7)}-01`;
if (period === 'last-month') {
const range = this.getPreviousMonthRange(today);
return !!range && rawDate >= range.start && rawDate < range.end;
}
return true;
},
getEntriesForPeriod(period) {
if (period === 'all') return [...(this.entries || [])];
const today = this.getCurrentReportDate();
return (this.entries || []).filter(e => this.entryMatchesPeriod(e, period, today));
},
ensureSensibleDefaultPeriod() {
const periodEl = document.getElementById('tt-filter-period');
if (!periodEl) return;
this._periodNote = '';
const currentValue = String(periodEl.value || 'month');
if (currentValue !== 'month') return;
const monthEntries = this.getEntriesForPeriod('month');
if (monthEntries.length > 0) return;
const weekEntries = this.getEntriesForPeriod('week');
if (weekEntries.length > 0) {
periodEl.value = 'week';
this._periodNote = 'За текущий месяц записей пока нет, поэтому я показываю эту неделю, чтобы последние часы были сразу видны.';
return;
}
if ((this.entries || []).length > 0) {
periodEl.value = 'all';
this._periodNote = 'За текущий месяц записей пока нет, поэтому я показываю все записи.';
}
},
renderFilterNote(period, filtered) {
const noteEl = document.getElementById('tt-filter-note');
if (!noteEl) return;
let note = '';
if (this._periodNote && period !== 'month') {
note = this._periodNote;
} else if (period === 'month' && filtered.length === 0 && (this.entries || []).length > 0) {
note = 'За текущий месяц записей пока нет. Попробуйте «Прошлый месяц», «Эта неделя» или «Все записи».';
} else if (period === 'last-month' && filtered.length === 0 && (this.entries || []).length > 0) {
note = 'За прошлый месяц записей нет. Попробуйте «Этот месяц» или «Все записи».';
}
noteEl.textContent = note;
noteEl.style.display = note ? '' : 'none';
},
filterAndRender() {
const period = document.getElementById('tt-filter-period').value;
const worker = document.getElementById('tt-filter-worker').value;
const project = document.getElementById('tt-filter-project').value;
const stage = document.getElementById('tt-filter-stage')?.value || '';
let filtered = [...this.entries];
if (period !== 'all') {
const today = this.getCurrentReportDate();
filtered = filtered.filter(e => this.entryMatchesPeriod(e, period, today));
}
if (worker) filtered = filtered.filter(e => e.worker_name === worker);
if (project) filtered = filtered.filter(e => e.project_name === project);
if (stage) filtered = filtered.filter(e => this.stageKey(e) === stage);
filtered.sort((a, b) => String(b?.date || '').localeCompare(String(a?.date || '')));
this.renderFilterNote(period, filtered);
this.renderTable(filtered);
this.renderProjectSummary(filtered);
this.renderPlanFact(filtered);
this.renderPayrollSummary();
},
parseHolidaySet() {
const raw = String((App.settings && App.settings.production_holidays) || '').trim();
if (!raw) return new Set();
const parts = raw.split(/[,\n;]/).map(s => s.trim()).filter(Boolean);
const set = new Set();
parts.forEach(p => {
if (/^\d{4}-\d{2}-\d{2}$/.test(p)) set.add(p);
});
return set;
},
getTrackingTimezoneOffset() {
const explicit = parseInt(App?.settings?.production_timezone_offset, 10);
if (Number.isFinite(explicit)) return explicit;
const offsets = (this.employees || [])
.map(emp => parseInt(emp?.timezone_offset, 10))
.filter(Number.isFinite);
if (offsets.length > 0) {
const counts = new Map();
offsets.forEach(offset => counts.set(offset, (counts.get(offset) || 0) + 1));
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
}
return 3;
},
formatUtcYMD(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '';
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
parseYMD(dateStr) {
const raw = String(dateStr || '').trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const date = new Date(`${raw}T00:00:00Z`);
return Number.isNaN(date.getTime()) ? null : date;
},
shiftYMD(dateStr, deltaDays) {
const date = this.parseYMD(dateStr);
if (!date) return String(dateStr || '').trim();
date.setUTCDate(date.getUTCDate() + Number(deltaDays || 0));
return this.formatUtcYMD(date);
},
getTodayYMD(baseDate = new Date(), timezoneOffset = null) {
const safeOffset = Number.isFinite(Number(timezoneOffset))
? Number(timezoneOffset)
: this.getTrackingTimezoneOffset();
return this.formatUtcYMD(new Date(baseDate.getTime() + safeOffset * 3600000));
},
normalizeWorkDate(dateStr, holidaySet = null) {
let current = String(dateStr || '').trim();
if (!current) return current;
const holidays = holidaySet instanceof Set ? holidaySet : this.parseHolidaySet();
let guard = 0;
while (current && (this.isWeekend(current) || holidays.has(current))) {
current = this.shiftYMD(current, -1);
guard += 1;
if (guard > 31) break;
}
return current;
},
getCurrentReportDate(baseDate = new Date()) {
return this.getTodayYMD(baseDate);
},
getCurrentWorkDate(baseDate = new Date()) {
return this.getCurrentReportDate(baseDate);
},
getLegacyBuggyTodayYMD(baseDate = new Date(), timezoneOffset = null) {
const safeOffset = Number.isFinite(Number(timezoneOffset))
? Number(timezoneOffset)
: this.getTrackingTimezoneOffset();
const utcMs = baseDate.getTime() + baseDate.getTimezoneOffset() * 60000;
return this.formatUtcYMD(new Date(utcMs + safeOffset * 3600000));
},
getLegacyBuggyTodayYMDWithHostOffset(baseDate = new Date(), timezoneOffset = null, hostTimezoneOffsetMinutes = 0) {
const safeOffset = Number.isFinite(Number(timezoneOffset))
? Number(timezoneOffset)
: this.getTrackingTimezoneOffset();
const safeHostOffset = Number.isFinite(Number(hostTimezoneOffsetMinutes))
? Number(hostTimezoneOffsetMinutes)
: 0;
const utcMs = baseDate.getTime() + safeHostOffset * 60000;
return this.formatUtcYMD(new Date(utcMs + safeOffset * 3600000));
},
getLegacyBuggyDateCandidates(baseDate = new Date(), timezoneOffset = null) {
const candidates = new Set();
candidates.add(this.getLegacyBuggyTodayYMD(baseDate, timezoneOffset));
// Legacy bot/runtime could run in UTC or UTC-3; both produced bad day shifts.
[0, 180].forEach(hostOffset => {
candidates.add(this.getLegacyBuggyTodayYMDWithHostOffset(baseDate, timezoneOffset, hostOffset));
});
return candidates;
},
getEntryTimezoneOffset(entry) {
const emp = this.findEmployeeForEntry(entry);
const explicit = parseInt(emp?.timezone_offset, 10);
return Number.isFinite(explicit) ? explicit : this.getTrackingTimezoneOffset();
},
isWeekend(dateStr) {
const d = this.parseYMD(dateStr);
if (!d) return false;
const day = d.getUTCDay();
return day === 0 || day === 6;
},
getPayrollProfile(emp) {
const explicit = String(emp?.payroll_profile || '').trim();
if (explicit) return explicit;
const baseSalary = parseFloat(emp?.pay_base_salary_month) || 0;
if (String(emp?.role || '') === 'management' && baseSalary > 0) {
return 'management_salary_with_production_allocation';
}
if (baseSalary > 0) return 'salary_monthly';
return 'hourly';
},
normalizePersonName(name) {
return String(name || '')
.trim()
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/\s+/g, ' ')
.replace(/[^\p{L}\p{N}\s]/gu, '')
.trim();
},
getPersonShortKey(name) {
return this.normalizePersonName(name).split(' ').filter(Boolean)[0] || '';
},
findEmployeeForEntry(entry) {
if (!entry) return null;
const employeeId = entry.employee_id != null ? String(entry.employee_id) : '';
if (employeeId) {
const byId = (this.employees || []).find(emp => String(emp.id) === employeeId);
if (byId) return byId;
}
const normalizedWorker = this.normalizePersonName(entry.worker_name || entry.employee_name || '');
if (!normalizedWorker) return null;
const exactMatches = (this.employees || []).filter(emp => this.normalizePersonName(emp.name) === normalizedWorker);
if (exactMatches.length === 1) return exactMatches[0];
const shortKey = this.getPersonShortKey(entry.worker_name || entry.employee_name || '');
if (shortKey) {
const shortMatches = (this.employees || []).filter(emp =>
emp.role === 'production' &&
this.getPersonShortKey(emp.name) === shortKey
);
if (shortMatches.length === 1) return shortMatches[0];
}
return null;
},
findEmployeeByName(name, options = {}) {
const normalizedName = this.normalizePersonName(name);
if (!normalizedName) return null;
const source = (this.employees || []).filter(emp => !options.productionOnly || emp.role === 'production');
const exactMatches = source.filter(emp => this.normalizePersonName(emp.name) === normalizedName);
if (exactMatches.length === 1) return exactMatches[0];
const shortKey = this.getPersonShortKey(name);
if (!shortKey) return null;
const shortMatches = source.filter(emp => this.getPersonShortKey(emp.name) === shortKey);
if (shortMatches.length === 1) return shortMatches[0];
return null;
},
entryBelongsToEmployee(entry, employee) {
if (!entry || !employee) return false;
if (entry.employee_id != null && String(entry.employee_id) === String(employee.id)) return true;
const resolved = this.findEmployeeForEntry(entry);
if (resolved && String(resolved.id) === String(employee.id)) return true;
return false;
},
async backfillLegacyFirstHalfEntries() {
if (typeof localStorage !== 'undefined' && localStorage.getItem(TT_LEGACY_IMPORT_MARKER) === '1') {
return 0;
}
const existingEntries = Array.isArray(this.entries) ? [...this.entries] : [];
const originalDayKeys = new Set(existingEntries.map(entry => {
const employee = this.findEmployeeForEntry(entry);
if (!employee || !entry?.date) return '';
return `${employee.id}|${entry.date}`;
}).filter(Boolean));
let imported = 0;
let unresolved = 0;
for (const seed of TT_LEGACY_FIRST_HALF_IMPORT) {
const employee = this.findEmployeeByName(seed.employee, { productionOnly: true });
if (!employee) {
unresolved += 1;
continue;
}
const skipDates = TT_LEGACY_IMPORT_SKIP_DATES[employee.name] || new Set();
if (skipDates.has(seed.date)) continue;
const sameDayExists = originalDayKeys.has(`${employee.id}|${seed.date}`);
if (sameDayExists) continue;
const description = this.buildDescriptionWithMeta(
'other',
'Импорт часов 1–15 марта',
'Автоматически перенесено из legacy Google-таблицы',
seed.project_name,
);
const id = await saveTimeEntry({
employee_id: employee.id,
worker_name: employee.name,
project_name: seed.project_name,
order_id: null,
hours: seed.hours,
date: seed.date,
description,
notes: 'legacy_google_sheet_import_2026_03_first_half',
});
existingEntries.push({
id: id || `legacy-${employee.id}-${seed.date}-${seed.project_name}`,
employee_id: employee.id,
worker_name: employee.name,
project_name: seed.project_name,
order_id: null,
hours: seed.hours,
date: seed.date,
description,
});
imported += 1;
}
if (typeof localStorage !== 'undefined' && unresolved === 0) {
localStorage.setItem(TT_LEGACY_IMPORT_MARKER, '1');
}
if (imported > 0 && App.toast) {
App.toast(`Перенесены legacy-часы за 1–15 марта: ${imported} записей`);
}
return imported;
},
async repairLegacyTimezoneShiftedEntries() {
const entries = Array.isArray(this.entries) ? this.entries : [];
const candidates = entries.filter(entry => {
const rawDate = String(entry?.date || '').trim();
const createdAt = new Date(entry?.created_at || '');
if (!rawDate || Number.isNaN(createdAt.getTime())) return false;
const timezoneOffset = this.getEntryTimezoneOffset(entry);
const correctedDate = this.getTodayYMD(createdAt, timezoneOffset);
const buggyDates = this.getLegacyBuggyDateCandidates(createdAt, timezoneOffset);
return correctedDate && correctedDate !== rawDate && buggyDates.has(rawDate);
});
let repaired = 0;
for (const entry of candidates) {
const createdAt = new Date(entry.created_at);
const correctedDate = this.getTodayYMD(createdAt, this.getEntryTimezoneOffset(entry));
if (!correctedDate || correctedDate === entry.date) continue;
await saveTimeEntry({ ...entry, date: correctedDate });
repaired += 1;
}
if (repaired > 0 && App.toast) {
App.toast(`Скорректированы даты часов по московскому времени: ${repaired}`);
}
return repaired;
},
getHalfMonthBucket(dateStr) {
const day = parseInt(String(dateStr || '').slice(8, 10), 10);
return Number.isFinite(day) && day >= 16 ? 'second' : 'first';
},
normalizePayrollConfig(emp) {
const fallbackRate = parseFloat(App.settings?.fot_per_hour) || 0;
const baseHoursRaw = parseFloat(emp?.pay_base_hours_month);
const baseHoursSemimonthRaw = parseFloat(emp?.pay_base_hours_semimonth);
const baseSalary = parseFloat(emp?.pay_base_salary_month) || 0;
const overtimeRateRaw = parseFloat(emp?.pay_overtime_hour_rate);
const weekendRateRaw = parseFloat(emp?.pay_weekend_hour_rate);
const holidayRateRaw = parseFloat(emp?.pay_holiday_hour_rate);
const payrollProfile = this.getPayrollProfile(emp);
// If no base salary — purely hourly employee, baseHours = 0
const hasSalary = baseSalary > 0 && payrollProfile !== 'hourly';
const safeBaseHours = hasSalary ? (baseHoursRaw > 0 ? baseHoursRaw : 176) : 0;
const safeBaseHoursSemimonth = payrollProfile === 'salary_semimonth_threshold'
? (baseHoursSemimonthRaw > 0 ? baseHoursSemimonthRaw : Math.max(1, Math.round(safeBaseHours / 2)))
: 0;
const baseRateFromSalary = (hasSalary && safeBaseHours > 0) ? (baseSalary / safeBaseHours) : 0;
const overtimeRate = overtimeRateRaw > 0 ? overtimeRateRaw : (baseRateFromSalary || fallbackRate);
const weekendRate = weekendRateRaw > 0 ? weekendRateRaw : overtimeRate;
const holidayRate = holidayRateRaw > 0 ? holidayRateRaw : weekendRate;
return {
payrollProfile,
hasSalary,
baseSalary,
baseHours: safeBaseHours,
baseHoursSemimonth: safeBaseHoursSemimonth,
baseRate: hasSalary ? (baseRateFromSalary || overtimeRate || fallbackRate) : 0,
overtimeRate: overtimeRate || fallbackRate,
weekendRate: weekendRate || fallbackRate,
holidayRate: holidayRate || fallbackRate,
};
},
formatMoney(v) {
return `${(parseFloat(v) || 0).toLocaleString('ru-RU')} ₽`;
},
calculateProductionPayrollForMonth(monthPrefixInput) {
const rawPrefix = String(monthPrefixInput || this.getCurrentMonthPrefix()).trim();
const monthPrefix = rawPrefix.length === 7 ? `${rawPrefix}-` : `${this.getCurrentMonthPrefix()}-`;
const holidaySet = this.parseHolidaySet();
const payrollEmployees = this._getProductionEmployees()
.filter(emp => this.getPayrollProfile(emp) !== 'management_salary_with_production_allocation');
const stats = new Map(payrollEmployees.map(emp => [String(emp.id), {
employee: emp,
regularByHalf: { first: 0, second: 0 },
weekendByHalf: { first: 0, second: 0 },
holidayByHalf: { first: 0, second: 0 },
}]));
(this.entries || []).forEach(entry => {
const workerName = String(entry.worker_name || '').trim();
if (!workerName) return;
if (!String(entry.date || '').startsWith(monthPrefix)) return;
const emp = this.findEmployeeForEntry(entry);
if (!emp || emp.role !== 'production') return;
const hours = parseFloat(entry.hours) || 0;
if (hours <= 0) return;
const isHoliday = holidaySet.has(entry.date);
const isWeekend = this.isWeekend(entry.date);
const statKey = String(emp.id);
if (!stats.has(statKey)) {
stats.set(statKey, {
employee: emp,
regularByHalf: { first: 0, second: 0 },
weekendByHalf: { first: 0, second: 0 },
holidayByHalf: { first: 0, second: 0 },
});
}
const row = stats.get(statKey);
const halfKey = this.getHalfMonthBucket(entry.date);
if (isHoliday) row.holidayByHalf[halfKey] += hours;
else if (isWeekend) row.weekendByHalf[halfKey] += hours;
else {
row.regularByHalf[halfKey] += hours;
}
});
const rows = Array.from(stats.values()).flatMap(row => {
const cfg = this.normalizePayrollConfig(row.employee);
if (cfg.payrollProfile === 'management_salary_with_production_allocation') return [];
const monthHours = ['first', 'second'].reduce((sum, halfKey) => (
sum +
(row.regularByHalf[halfKey] || 0) +
(row.weekendByHalf[halfKey] || 0) +
(row.holidayByHalf[halfKey] || 0)
), 0);
const baseHoursPerHalf = cfg.hasSalary
? (cfg.baseHoursSemimonth || Math.max(1, Math.round(cfg.baseHours / 2)))
: 0;
const halves = [
{ key: 'first', label: '1–15' },
{ key: 'second', label: '16–конец' },
];
return halves.map(half => {
const regularHours = row.regularByHalf[half.key] || 0;
const weekendHours = row.weekendByHalf[half.key] || 0;
const holidayHours = row.holidayByHalf[half.key] || 0;
const inBaseHours = cfg.hasSalary ? Math.min(regularHours, baseHoursPerHalf) : 0;
const overtimeHours = cfg.hasSalary ? Math.max(0, regularHours - baseHoursPerHalf) : regularHours;
const payOvertime = overtimeHours * cfg.overtimeRate;
const payWeekend = weekendHours * cfg.weekendRate;
const payHoliday = holidayHours * cfg.holidayRate;
return {
employeeName: row.employee.name || 'Сотрудник',
periodKey: half.key,
periodLabel: half.label,
payrollProfile: cfg.payrollProfile,
hasSalary: cfg.hasSalary,
regularHours,
inBaseHours,
overtimeHours,
weekendHours,
holidayHours,
totalPay: payOvertime + payWeekend + payHoliday,
};
});
}).sort((a, b) => {
const byName = String(a.employeeName || '').localeCompare(String(b.employeeName || ''), 'ru');
if (byName !== 0) return byName;
return a.periodKey === b.periodKey ? 0 : (a.periodKey === 'first' ? -1 : 1);
});
const total = rows.reduce((s, r) => s + r.totalPay, 0);
return { rows, total };
},
calculateProductionPayrollForCurrentMonth() {
return this.calculateProductionPayrollForMonth(this.getCurrentMonthPrefix());
},
renderPayrollSummary() {
const tableBody = document.getElementById('tt-payroll-body');
const totalEl = document.getElementById('tt-month-pay');
const payrollCard = document.getElementById('tt-payroll-card');
const noteEl = document.getElementById('tt-payroll-note');
if (!tableBody) return;
// Hide entire payroll section from non-admin
const admin = App.isAdmin();
if (payrollCard) payrollCard.style.display = admin ? '' : 'none';
const payStatCard = document.getElementById('tt-pay-stat-card');
if (payStatCard) payStatCard.style.display = admin ? '' : 'none';
if (!admin) {
if (totalEl) totalEl.textContent = '—';
if (noteEl) noteEl.style.display = 'none';
return;
}
const selectedMonth = this.getSelectedPayrollMonthPrefix();
const { rows, total } = this.calculateProductionPayrollForMonth(selectedMonth);
const nonZeroRows = rows.filter(r => (
(r.regularHours || 0) > 0 ||
(r.weekendHours || 0) > 0 ||
(r.holidayHours || 0) > 0 ||
(r.totalPay || 0) > 0
));
const visibleTotal = nonZeroRows.reduce((sum, row) => sum + (row.totalPay || 0), 0);
if (totalEl) totalEl.textContent = this.formatMoney(nonZeroRows.length ? visibleTotal : total);
let payrollNote = `Расчёт показан за ${this.formatPayrollMonthLabel(selectedMonth)}.`;
const hasCurrentMonthHours = (this.entries || []).some(entry => String(entry?.date || '').startsWith(`${this.getCurrentMonthPrefix()}-`));
if (selectedMonth !== this.getCurrentMonthPrefix() && !hasCurrentMonthHours && (this.entries || []).length > 0) {
payrollNote += ' За текущий месяц часов пока нет, поэтому открыт последний месяц с записями.';
}
if (noteEl) {
noteEl.textContent = payrollNote;
noteEl.style.display = payrollNote ? '' : 'none';
}
if (!nonZeroRows.length) {
tableBody.innerHTML = `| За ${this.formatPayrollMonthLabel(selectedMonth)} часов ещё нет |
`;
return;
}
tableBody.innerHTML = nonZeroRows.map(r => `
| ${this.esc(r.employeeName)} |
${this.esc(r.periodLabel)} |
${r.regularHours.toFixed(2)} |
${r.inBaseHours.toFixed(2)} |
${r.overtimeHours.toFixed(2)} |
${r.weekendHours.toFixed(2)} |
${r.holidayHours.toFixed(2)} |
${this.formatMoney(r.totalPay)} |
`).join('');
},
renderTable(entries) {
const tbody = document.getElementById('tt-table-body');
if (entries.length === 0) {
tbody.innerHTML = '| Нет записей |
';
return;
}
tbody.innerHTML = entries.map(e => {
const pctLabel = e.percentage ? ` (${e.percentage}%)` : '';
const srcIcon = e.source === 'telegram' ? ' 📡' : '';
return `
| ${App.formatDate(e.date)} |
${this.esc(e.worker_name)}${srcIcon} |
${this.esc(e.project_name)}${e.order_id ? ' заказ' : ''} |
${this.esc(this.stageLabel(e))} |
${parseFloat(e.hours) || 0} ч${pctLabel} |
${this.esc(this.stripMetaPrefix(e.description || ''))} |
|
`;
}).join('');
},
renderProjectSummary(entries) {
const container = document.getElementById('tt-project-summary');
const byProject = {};
entries.forEach(e => {
const pn = e.project_name || 'Без проекта';
if (!byProject[pn]) byProject[pn] = { hours: 0, workers: new Set() };
byProject[pn].hours += parseFloat(e.hours) || 0;
byProject[pn].workers.add(e.worker_name);
});
const projects = Object.entries(byProject).sort((a, b) => b[1].hours - a[1].hours);
if (projects.length === 0) {
container.innerHTML = 'Нет данных
';
return;
}
const maxHours = Math.max(...projects.map(([_, v]) => v.hours));
container.innerHTML = projects.map(([name, data]) => {
const pct = maxHours > 0 ? (data.hours / maxHours * 100) : 0;
return `
${this.esc(name)}
${data.hours.toFixed(2)} ч
${data.workers.size} сотр.
`;
}).join('');
},
async renderPlanFact(filteredEntries) {
const tbody = document.getElementById('tt-plan-fact-body');
if (!tbody) return;
const entries = (filteredEntries || []).filter(e => !!e.order_id);
if (entries.length === 0) {
tbody.innerHTML = '| Нет данных |
';
return;
}
const orderIds = [...new Set(entries.map(e => Number(e.order_id)).filter(Boolean))];
const orders = await loadOrders({});
const orderMap = new Map((orders || []).map(o => [Number(o.id), o]));
const facts = new Map();
orderIds.forEach(id => {
facts.set(id, { casting: 0, assembly: 0, packaging: 0, other: 0 });
});
entries.forEach(e => {
const orderId = Number(e.order_id);
if (!facts.has(orderId)) return;
const bucket = facts.get(orderId);
const stage = this.stageKey(e);
const hours = parseFloat(e.hours) || 0;
if (stage === 'casting' || stage === 'trim') bucket.casting += hours;
else if (stage === 'assembly') bucket.assembly += hours;
else if (stage === 'packaging') bucket.packaging += hours;
else bucket.other += hours;
});
const rows = orderIds.map(id => {
const o = orderMap.get(id);
if (!o) return '';
const planCasting = parseFloat(o.production_hours_plastic) || 0;
const planAssembly = parseFloat(o.production_hours_hardware) || 0;
const planPackaging = parseFloat(o.production_hours_packaging) || 0;
const planTotal = planCasting + planAssembly + planPackaging;
const f = facts.get(id) || { casting: 0, assembly: 0, packaging: 0, other: 0 };
const factTotal = f.casting + f.assembly + f.packaging + f.other;
const delta = factTotal - planTotal;
const deltaColor = delta > 0 ? 'var(--red)' : 'var(--green)';
return `
| ${this.esc(o.order_name || `Заказ #${id}`)} |
${planCasting.toFixed(2)}ч |
${f.casting.toFixed(2)}ч |
${planAssembly.toFixed(2)}ч |
${f.assembly.toFixed(2)}ч |
${planPackaging.toFixed(2)}ч |
${f.packaging.toFixed(2)}ч |
${delta >= 0 ? '+' : ''}${delta.toFixed(2)}ч |
`;
}).filter(Boolean);
tbody.innerHTML = rows.length
? rows.join('')
: '| Нет данных |
';
},
updateStats() {
const today = this.getCurrentReportDate();
const weekAgo = this.shiftYMD(today, -7);
const monthStart = `${today.slice(0, 7)}-01`;
const weekHours = this.entries
.filter(e => {
const rawDate = String(e?.date || '').trim();
return rawDate && rawDate >= weekAgo;
})
.reduce((s, e) => s + (parseFloat(e.hours) || 0), 0);
const monthHours = this.entries
.filter(e => {
const rawDate = String(e?.date || '').trim();
return rawDate && rawDate >= monthStart;
})
.reduce((s, e) => s + (parseFloat(e.hours) || 0), 0);
const workers = new Set(this.entries.map(e => e.worker_name)).size;
const projects = new Set(this.entries.map(e => e.project_name)).size;
document.getElementById('tt-week-hours').textContent = weekHours.toFixed(1);
document.getElementById('tt-month-hours').textContent = monthHours.toFixed(1);
document.getElementById('tt-workers-count').textContent = workers;
document.getElementById('tt-projects-count').textContent = projects;
this.renderPayrollSummary();
},
// === CRUD ===
async saveEntry() {
const workerName = document.getElementById('tt-worker-name').value.trim();
const projectSelect = document.getElementById('tt-project-select');
const currentEntry = this.editingEntryId != null
? (this.entries || []).find(e => String(e.id) === String(this.editingEntryId))
: null;
const hours = parseFloat(document.getElementById('tt-hours').value) || 0;
const date = document.getElementById('tt-date').value;
const comment = document.getElementById('tt-description').value.trim();
const stage = document.getElementById('tt-stage')?.value || 'casting';
const stageOther = document.getElementById('tt-stage-other')?.value.trim() || '';
const stageLabel = stage === 'other' ? (stageOther || 'Другое') : (TT_STAGE_LABELS[stage] || stage);
if (!workerName) { App.toast('Укажите сотрудника'); return; }
if (hours <= 0) { App.toast('Укажите количество часов'); return; }
if (!date) { App.toast('Укажите дату'); return; }
if (stage === 'other' && !stageOther) { App.toast('Укажите этап для "Другое"'); return; }
const projectValue = projectSelect.value;
let projectName = '';
let orderId = null;
if (projectValue === '__general') {
projectName = 'Общие работы';
} else if (projectValue) {
orderId = parseInt(projectValue, 10);
projectName = projectSelect.options[projectSelect.selectedIndex].textContent;
} else {
App.toast('Выберите проект');
return;
}
// Find employee_id by name
const matchedEmp = (this.employees || []).find(e => e.name === workerName);
const entry = {
id: this.editingEntryId || undefined,
employee_id: matchedEmp ? matchedEmp.id : (currentEntry?.employee_id ?? null),
worker_name: workerName,
project_name: projectName,
order_id: orderId,
hours,
date,
description: this.buildDescriptionWithMeta(stage, stageLabel, comment, projectName),
};
const savedId = await saveTimeEntry(entry);
if (!savedId) {
App.toast('Не удалось сохранить запись');
return;
}
App.toast(this.editingEntryId ? 'Запись обновлена' : 'Запись добавлена');
this.resetManualEntryForm();
await this.load();
document.getElementById('tt-manual-form').style.display = 'none';
},
async deleteEntry(id) {
if (!confirm('Удалить запись?')) return;
await deleteTimeEntry(id);
App.toast('Запись удалена');
this.load();
},
ensureSelectOption(select, value, label) {
if (!select || value == null || value === '') return;
const exists = Array.from(select.options || []).some(opt => String(opt.value) === String(value));
if (exists) return;
const opt = document.createElement('option');
opt.value = String(value);
opt.textContent = label || String(value);
select.appendChild(opt);
},
editEntry(id) {
const entry = (this.entries || []).find(e => String(e.id) === String(id));
if (!entry) {
App.toast('Не удалось найти запись');
return;
}
this.editingEntryId = entry.id;
const workerEl = document.getElementById('tt-worker-name');
const projectEl = document.getElementById('tt-project-select');
const hoursEl = document.getElementById('tt-hours');
const dateEl = document.getElementById('tt-date');
const descEl = document.getElementById('tt-description');
const stageEl = document.getElementById('tt-stage');
const stageOtherEl = document.getElementById('tt-stage-other');
const meta = this.parseMeta(entry);
const stage = meta.stage || 'casting';
this.showManualEntry();
this.ensureSelectOption(workerEl, entry.worker_name || '', entry.worker_name || 'Без имени');
if (entry.order_id) {
this.ensureSelectOption(projectEl, entry.order_id, entry.project_name || `Заказ #${entry.order_id}`);
} else if (entry.project_name) {
this.ensureSelectOption(projectEl, '__general', entry.project_name);
}
if (workerEl) workerEl.value = entry.worker_name || '';
if (projectEl) projectEl.value = entry.order_id ? String(entry.order_id) : '__general';
if (hoursEl) hoursEl.value = parseFloat(entry.hours) || '';
if (dateEl) dateEl.value = entry.date || '';
if (descEl) descEl.value = this.stripMetaPrefix(entry.description || '');
if (stageEl) stageEl.value = TT_STAGE_ORDER.includes(stage) ? stage : 'other';
if (stageOtherEl) stageOtherEl.value = stageEl && stageEl.value === 'other' ? (meta.stage_label || '') : '';
this.syncManualEntryFormState();
this.onStageChange();
const formEl = document.getElementById('tt-manual-form');
if (formEl && typeof formEl.scrollIntoView === 'function') {
formEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
if (projectEl && typeof projectEl.focus === 'function') {
projectEl.focus();
}
App.toast('Запись открыта для редактирования');
},
esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(//g, '>');
},
};