// ============================================= // Recycle Object — Косвенные расходы // Breakdown and tracking of indirect/overhead costs // v7: total override, carry-forward, Supabase sync // ============================================= const IndirectCosts = { employees: [], timeEntries: [], monthsData: {}, // { "2026-03": { rent: ..., utilities: ..., total_override: ..., ... } } currentMonth: '', COST_ITEMS: [ { key: 'rent', label: 'Аренда (все площадки)', avg: 256054 }, { key: 'subscriptions', label: 'Программы и сервисы', avg: 90513 }, { key: 'marketing', label: 'Маркетинг', avg: 81067 }, { key: 'representation',label: 'Представительские', avg: 38412 }, { key: 'bank', label: 'Банковское обслуживание', avg: 16069 }, { key: 'photo', label: 'Фотограф', avg: 11865 }, { key: 'staff_costs', label: 'Расходы на персонал', avg: 7190 }, { key: 'internet', label: 'Интернет', avg: 4086 }, { key: 'household', label: 'Хоз. товары', avg: 1761 }, { key: 'workshop', label: 'Обслуживание цеха', avg: 714 }, { key: 'fuel', label: 'Бензин', avg: 214 }, { key: 'other', label: 'Прочее', avg: 0 }, ], ROLE_DEFAULT_SHARE: { production: 100, office: 0, management: 0, // Леша = 50% через override в ro_production_shares }, SUPABASE_KEY: 'indirect_costs_json', // ========================================== // Load // ========================================== async load() { this.employees = (await loadEmployees()) || []; this.timeEntries = (await loadTimeEntries()) || []; // Load from Supabase first, fallback to localStorage await this._loadFromSupabase(); this.currentMonth = this._todayMonth(); // Load production_share overrides from settings this._shareOverrides = JSON.parse(localStorage.getItem('ro_production_shares') || '{}'); // Apply production_share: override > role default this.employees.forEach(e => { const key = String(e.id); if (this._shareOverrides[key] !== undefined) { e.production_share = this._shareOverrides[key]; } else { e.production_share = this.ROLE_DEFAULT_SHARE[e.role] ?? 0; } }); // Auto-load FinTablo history if no data yet if (Object.keys(this.monthsData).length === 0) { this.loadHistory(); } // Set month picker const picker = document.getElementById('ic-month-picker'); if (picker) picker.value = this.currentMonth; this.render(); }, async _loadFromSupabase() { if (!supabaseClient) { this.monthsData = getLocal(LOCAL_KEYS.indirectCosts) || {}; return; } try { const { data, error } = await supabaseClient .from('settings') .select('value') .eq('key', this.SUPABASE_KEY) .single(); if (data && data.value && !error) { const parsed = typeof data.value === 'string' ? JSON.parse(data.value) : data.value; if (parsed && typeof parsed === 'object') { this.monthsData = parsed; // Also save to localStorage as cache setLocal(LOCAL_KEYS.indirectCosts, this.monthsData); return; } } } catch (e) { console.warn('IndirectCosts: Supabase load failed, using localStorage', e); } // Fallback to localStorage this.monthsData = getLocal(LOCAL_KEYS.indirectCosts) || {}; }, async _saveToSupabase() { if (!supabaseClient) return; try { await supabaseClient .from('settings') .upsert({ key: this.SUPABASE_KEY, value: JSON.stringify(this.monthsData) }, { onConflict: 'key' }); } catch (e) { console.warn('IndirectCosts: Supabase save failed', e); } }, // ========================================== // Month management // ========================================== setMonth(yyyymm) { if (!yyyymm) return; this.currentMonth = yyyymm; this.render(); }, _todayMonth() { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); }, _getMonthData() { if (this.monthsData[this.currentMonth]) return this.monthsData[this.currentMonth]; // No data for this month — carry forward from the most recent saved month const prev = this._getLatestSavedMonth(this.currentMonth); if (prev) { // Clone cost items + total_override from the last saved month const cloned = {}; this.COST_ITEMS.forEach(item => { if (prev[item.key] !== undefined) cloned[item.key] = prev[item.key]; }); // Carry forward total_override too if (prev.total_override) cloned.total_override = prev.total_override; this.monthsData[this.currentMonth] = cloned; return cloned; } return {}; }, /** Return the month-data object from the most recent saved month strictly before `beforeMonth` */ _getLatestSavedMonth(beforeMonth) { const months = Object.keys(this.monthsData).filter(m => m < beforeMonth).sort(); if (months.length === 0) return null; return this.monthsData[months[months.length - 1]]; }, _monthLabel(yyyymm) { const months = ['Январь','Февраль','Март','Апрель','Май','Июнь', 'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']; const [y, m] = yyyymm.split('-'); return months[parseInt(m) - 1] + ' ' + y; }, // ========================================== // Calculations // ========================================== // Tax calc: white salary is NET → gross = net / 0.87 // НДФЛ = gross × 13%, Social = gross × 30.2% NDFL_RATE: 0.13, SOCIAL_RATE: 0.302, _calcEmployeeTotalCost(e) { let white = e.pay_white_salary || 0; let black = e.pay_black_salary || 0; // Migration: if no white/black but has pay_base_salary_month, treat as all black if (white === 0 && black === 0 && (e.pay_base_salary_month || 0) > 0) { black = e.pay_base_salary_month; } let taxes = 0; if (white > 0) { const gross = Math.round(white / (1 - this.NDFL_RATE)); taxes = Math.round(gross * this.NDFL_RATE) + Math.round(gross * this.SOCIAL_RATE); } return white + black + taxes; }, _getEmployeePayrollProfile(employee) { const explicit = String(employee?.payroll_profile || '').trim(); if (explicit) return explicit; const baseSalary = parseFloat(employee?.pay_base_salary_month) || 0; if (String(employee?.role || '') === 'management' && baseSalary > 0) { return 'management_salary_with_production_allocation'; } if (baseSalary > 0) return 'salary_monthly'; return 'hourly'; }, _getEmployeeProductionHoursForCurrentMonth(employee) { if (!employee) return 0; const prefix = `${this.currentMonth}-`; return (this.timeEntries || []).reduce((sum, entry) => { if (!String(entry.date || '').startsWith(prefix)) return sum; const sameEmployeeId = entry.employee_id != null && String(entry.employee_id) === String(employee.id); const sameName = String(entry.worker_name || '').trim() === String(employee.name || '').trim(); if (!sameEmployeeId && !sameName) return sum; return sum + (parseFloat(entry.hours) || 0); }, 0); }, _getEffectiveProductionShare(employee) { const payrollProfile = this._getEmployeePayrollProfile(employee); if (payrollProfile === 'management_salary_with_production_allocation') { const baseHours = parseFloat(employee?.pay_base_hours_month) || 176; if (baseHours <= 0) return 0; const productionHours = this._getEmployeeProductionHoursForCurrentMonth(employee); return Math.max(0, Math.min(100, Math.round((productionHours / baseHours) * 1000) / 10)); } const key = String(employee?.id || ''); if (this._shareOverrides && this._shareOverrides[key] !== undefined) { return this._shareOverrides[key]; } return employee?.production_share ?? (this.ROLE_DEFAULT_SHARE[employee?.role] ?? 0); }, calcEmployeeIndirectTotal() { return this.employees .filter(e => e.is_active !== false) .reduce((sum, e) => { const totalCost = this._calcEmployeeTotalCost(e); const share = this._getEffectiveProductionShare(e); return sum + totalCost * (100 - share) / 100; }, 0); }, calcFixedTotal() { const data = this._getMonthData(); return this.COST_ITEMS.reduce((sum, item) => sum + (parseFloat(data[item.key]) || 0), 0); }, /** Calculated total (salary indirect + fixed costs) */ calcGrandTotalCalc() { return this.calcEmployeeIndirectTotal() + this.calcFixedTotal(); }, /** Effective total — user override or calculated */ calcGrandTotal() { const data = this._getMonthData(); if (data.total_override && data.total_override > 0) { return data.total_override; } return this.calcGrandTotalCalc(); }, _getWorkloadHours() { const settings = App.settings || {}; const s = key => settings[key] || 0; const totalHoursAll = s('workers_count') * s('hours_per_worker'); const workLoadHours = totalHoursAll * s('work_load_ratio'); const indirectCostMode = settings['indirect_cost_mode'] || 'all'; const plasticHours = workLoadHours * s('plastic_injection_ratio'); return indirectCostMode === 'all' ? workLoadHours : plasticHours; }, // ========================================== // Rendering // ========================================== render() { this._renderStats(); this._renderEmployees(); this._renderCostItems(); this._renderTotalOverride(); this._renderHistory(); }, _renderStats() { const salaryIndirect = this.calcEmployeeIndirectTotal(); const fixedTotal = this.calcFixedTotal(); const grandTotal = this.calcGrandTotal(); const hours = this._getWorkloadHours(); const perHour = hours > 0 ? grandTotal / hours : 0; document.getElementById('ic-stat-salary').textContent = formatRub(salaryIndirect); document.getElementById('ic-stat-fixed').textContent = formatRub(fixedTotal); document.getElementById('ic-stat-total').textContent = formatRub(grandTotal); document.getElementById('ic-stat-per-hour').textContent = formatRub(Math.round(perHour)); }, _renderEmployees() { const tbody = document.getElementById('ic-employees-body'); if (!tbody) return; const roleLabels = { production: 'Производство', office: 'Офис', management: 'Руководство' }; const roleBadges = { production: 'badge-blue', office: 'badge-yellow', management: 'badge-green' }; // Only show employees who contribute to indirect costs (production_share < 100) const activeEmps = this.employees.filter(e => { if (e.is_active === false) return false; const share = this._getEffectiveProductionShare(e); return share < 100; // hide 100% production workers }); if (activeEmps.length === 0) { tbody.innerHTML = '