/* Alerts: система уведомлений — просрочки работ, платежей, поставок, рисков. Severity: critical / warning / info. Фильтры по роли, типу, статусу. */ window.RD = window.RD || { core: {}, modules: {} }; window.RD.modules.Alerts = function Alerts() { var store = RD.core.useStore(); var h = React.createElement; var fmt = RD.core; // core — объект с функциями money(), dayLabel(), etc. var useState = React.useState; var filterState = useState('all'); // all | critical | warning | info var filter = filterState[0], setFilter = filterState[1]; // FIX: Data is in store.raw directly, not in store.raw.data var tasks = store.raw.works || []; var payments = store.raw.payments || []; var supplies = store.raw.deliveries || []; var risks = store.raw.risks || []; var now = new Date(); // === Генерация алертов === var alerts = []; // Просрочки задач tasks.forEach(function (t, i) { if (t.done) return; if (!t.end) return; var end = new Date(t.end); if (end < now) { var daysOverdue = Math.floor((now - end) / (1000 * 60 * 60 * 24)); alerts.push({ id: 'task-overdue-' + i, severity: daysOverdue > 7 ? 'critical' : 'warning', type: 'task', title: 'Просрочена работа: ' + (t.label || t.name || 'Задача #' + (i + 1)), message: 'Срок истёк ' + daysOverdue + ' дн. назад (' + fmt.dayLabel(t.end) + ')', timestamp: end, roles: ['Прораб', 'Администратор'] }); } }); // Просрочки оплат payments.forEach(function (p, i) { if (p.status === 'paid') return; if (!p.dueDate) return; var due = new Date(p.dueDate); if (due < now) { var daysOverdue = Math.floor((now - due) / (1000 * 60 * 60 * 24)); alerts.push({ id: 'payment-overdue-' + i, severity: daysOverdue > 3 ? 'critical' : 'warning', type: 'payment', title: 'Просрочена оплата: ' + (p.title || p.contractor || 'Платёж #' + (i + 1)), message: fmt.money(p.amount || 0) + ' · срок истёк ' + daysOverdue + ' дн. назад', timestamp: due, roles: ['Администратор', 'Сметчик'] }); } }); // Просрочки поставок supplies.forEach(function (s, i) { if (s.status === 'delivered') return; if (!s.expectedDate) return; var exp = new Date(s.expectedDate); if (exp < now) { var daysOverdue = Math.floor((now - exp) / (1000 * 60 * 60 * 24)); alerts.push({ id: 'supply-overdue-' + i, severity: daysOverdue > 5 ? 'critical' : 'warning', type: 'supply', title: 'Просрочена поставка: ' + (s.item || s.material || 'Поставка #' + (i + 1)), message: 'Ожидалась ' + fmt.dayLabel(s.expectedDate) + ' (' + daysOverdue + ' дн. назад)', timestamp: exp, roles: ['Снабженец', 'Прораб', 'Администратор'] }); } }); // Активные риски risks.forEach(function (r, i) { if (r.status === 'resolved' || r.status === 'closed') return; var severity = r.severity || 'info'; alerts.push({ id: 'risk-' + i, severity: severity, type: 'risk', title: r.title || 'Риск #' + (i + 1), message: r.description || 'Требует внимания', timestamp: r.createdAt ? new Date(r.createdAt) : now, roles: ['Администратор', 'Прораб'] }); }); // Бюджетные предупреждения var smeta = rawData.smeta || []; var totalBudget = smeta.reduce(function (s, item) { return s + ((item.qty || 0) * (item.price || 0)); }, 0); var totalSpent = payments.filter(function (p) { return p.status === 'paid'; }) .reduce(function (s, p) { return s + (p.amount || 0); }, 0); var budgetPercent = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0; if (budgetPercent > 90 && budgetPercent <= 100) { alerts.push({ id: 'budget-90', severity: 'warning', type: 'budget', title: 'Бюджет израсходован на 90%+', message: 'Осталось: ' + fmt.money(totalBudget - totalSpent) + ' из ' + fmt.money(totalBudget), timestamp: now, roles: ['Администратор', 'Сметчик'] }); } else if (budgetPercent > 100) { alerts.push({ id: 'budget-overrun', severity: 'critical', type: 'budget', title: 'Перерасход бюджета!', message: 'Превышение на ' + fmt.money(totalSpent - totalBudget), timestamp: now, roles: ['Администратор', 'Сметчик'] }); } // Фильтрация по роли пользователя var userRole = store.state.role; var visibleAlerts = alerts.filter(function (a) { if (!a.roles || a.roles.length === 0) return true; return a.roles.indexOf(userRole) >= 0 || userRole === 'Администратор'; }); // Фильтрация по severity if (filter !== 'all') { visibleAlerts = visibleAlerts.filter(function (a) { return a.severity === filter; }); } // Сортировка: critical → warning → info, затем по времени var severityOrder = { critical: 0, warning: 1, info: 2 }; visibleAlerts.sort(function (a, b) { var sev = (severityOrder[a.severity] || 3) - (severityOrder[b.severity] || 3); if (sev !== 0) return sev; return (b.timestamp || 0) - (a.timestamp || 0); }); var criticalCount = visibleAlerts.filter(function (a) { return a.severity === 'critical'; }).length; var warningCount = visibleAlerts.filter(function (a) { return a.severity === 'warning'; }).length; var infoCount = visibleAlerts.filter(function (a) { return a.severity === 'info'; }).length; // === UI === function severityBadge(sev) { var config = { critical: { bg: 'var(--rd-bad)', label: 'Критично' }, warning: { bg: 'var(--rd-warn)', label: 'Внимание' }, info: { bg: 'var(--rd-info)', label: 'Инфо' } }[sev] || { bg: 'var(--rd-text-muted)', label: 'Неизвестно' }; return h('div', { style: { fontSize: 10, fontWeight: 700, color: '#fff', background: config.bg, padding: '3px 8px', borderRadius: 6, textTransform: 'uppercase', letterSpacing: .4 } }, config.label); } function alertCard(alert) { var typeIcon = { task: '⚙', payment: '💳', supply: '📦', risk: '⚠', budget: '₽' }[alert.type] || '●'; return h('div', { key: alert.id, style: { background: 'var(--rd-surface)', border: '1px solid var(--rd-border)', borderRadius: 12, padding: 14, display: 'flex', gap: 12, alignItems: 'flex-start' } }, h('div', { style: { width: 40, height: 40, borderRadius: 10, background: alert.severity === 'critical' ? 'var(--rd-bad)' : alert.severity === 'warning' ? 'var(--rd-warn)' : 'var(--rd-info)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, flex: 'none' } }, typeIcon), h('div', { style: { flex: 1 } }, h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 } }, h('div', { style: { fontSize: 13, fontWeight: 700, color: 'var(--rd-text)', flex: 1 } }, alert.title), severityBadge(alert.severity) ), h('div', { style: { fontSize: 12, color: 'var(--rd-text-2)', lineHeight: 1.4 } }, alert.message), alert.timestamp && h('div', { style: { fontSize: 11, color: 'var(--rd-text-muted)', marginTop: 4 } }, fmt.dayLabel(alert.timestamp) ) ) ); } function filterButton(value, label, count) { var active = filter === value; return h('button', { onClick: function () { setFilter(value); }, style: { padding: '8px 14px', border: '1px solid var(--rd-border)', borderRadius: 8, background: active ? 'var(--rd-accent)' : 'var(--rd-surface)', color: active ? '#fff' : 'var(--rd-text)', fontSize: 12, fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 } }, label, count > 0 && h('span', { style: { background: active ? 'rgba(255,255,255,0.25)' : 'var(--rd-surface-3)', color: active ? '#fff' : 'var(--rd-text)', fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 6 } }, count) ); } return h('div', { style: { flex: 1, overflow: 'auto', padding: 20, display: 'flex', flexDirection: 'column', gap: 16 } }, // Header h('div', null, h('h2', { style: { fontSize: 20, fontWeight: 700, color: 'var(--rd-text)', marginBottom: 6 } }, 'Уведомления и алерты'), h('p', { style: { fontSize: 13, color: 'var(--rd-text-muted)' } }, visibleAlerts.length + ' активных уведомлений · роль: ' + userRole ) ), // Filters h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, filterButton('all', 'Все', visibleAlerts.length), filterButton('critical', 'Критично', criticalCount), filterButton('warning', 'Внимание', warningCount), filterButton('info', 'Инфо', infoCount) ), // Alerts list visibleAlerts.length === 0 ? h('div', { style: { padding: 40, textAlign: 'center', color: 'var(--rd-text-muted)', fontSize: 14, background: 'var(--rd-surface)', border: '1px solid var(--rd-border)', borderRadius: 12 } }, '✓ Нет активных уведомлений', h('div', { style: { fontSize: 12, marginTop: 6 } }, 'Все задачи в срок, риски под контролем') ) : h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } }, visibleAlerts.map(alertCard) ) ); };