// ============================================= // 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, '>'); }, };