/* Модуль «История изменений» — changelog проекта Показывает изменения из API endpoint /api/renovation/project//changelog */ window.RD = window.RD || { core: {}, modules: {} }; window.RD.modules.ChangeHistory = function ChangeHistory() { var store = RD.core.useStore(); var h = React.createElement; var fmt = RD.core; var useState = React.useState; var useEffect = React.useEffect; var changesState = useState([]); var changes = changesState[0]; var setChanges = changesState[1]; var loadingState = useState(true); var loading = loadingState[0]; var setLoading = loadingState[1]; var filterState = useState('all'); var filter = filterState[0]; var setFilter = filterState[1]; // Load changelog from API useEffect(function () { var params = new URLSearchParams(window.location.search); var projectId = params.get('project'); if (!projectId) { setLoading(false); return; } fetch('/api/renovation/project/' + projectId + '/changelog') .then(function (r) { return r.json(); }) .then(function (data) { setChanges(data.changes || []); setLoading(false); }) .catch(function (e) { console.error('[ChangeHistory] Failed to load changelog:', e); setLoading(false); }); }, []); var filtered = filter === 'all' ? changes : changes.filter(function (c) { return c.entity === filter; }); function operationBadge(op) { var colors = { create: { bg: 'var(--rd-good)', text: '#fff' }, patch: { bg: 'var(--rd-info)', text: '#fff' }, delete: { bg: 'var(--rd-bad)', text: '#fff' } }; var labels = { create: 'Создано', patch: 'Изменено', delete: 'Удалено' }; var c = colors[op] || { bg: 'var(--rd-surface-2)', text: 'var(--rd-text)' }; return h('span', { style: { fontSize: 10, fontWeight: 700, background: c.bg, color: c.text, padding: '4px 8px', borderRadius: 6, textTransform: 'uppercase' } }, labels[op] || op); } function getItemName(change) { // Priority: name.new > name > label.new > label > description if (!change.diff) return null; if (change.diff.name) { if (typeof change.diff.name === 'object' && change.diff.name['new']) { return change.diff.name['new']; } if (typeof change.diff.name === 'string') { return change.diff.name; } } if (change.diff.label) { if (typeof change.diff.label === 'object' && change.diff.label['new']) { return change.diff.label['new']; } if (typeof change.diff.label === 'string') { return change.diff.label; } } if (change.diff.description && typeof change.diff.description === 'string') { return change.diff.description.substring(0, 60); } return null; } function formatLabel(change) { var entityLabels = { works: 'Работа', sections: 'Раздел сметы', payments: 'Платёж', deliveries: 'Поставка', kotlovans: 'Котлован', project_milestones: 'Этап проекта', pd_deliveries: 'Поставка ПД', pd_orders: 'Заказ ПД' }; var entityLabel = entityLabels[change.entity] || change.entity; var itemName = getItemName(change); if (itemName) { return itemName; } // Fallback: show entity type + short ID return entityLabel + ' #' + (change.item_id ? change.item_id.substring(0, 8) : '???'); } function formatValue(val) { if (val === null) return 'null'; if (val === undefined) return '—'; if (typeof val === 'string') { // Dates if (/^\d{4}-\d{2}-\d{2}/.test(val)) { try { return new Date(val).toLocaleDateString('ru-RU'); } catch (e) { return val; } } // UUIDs — show first 8 chars if (/^[0-9a-f]{8}-[0-9a-f]{4}-/.test(val)) { return val.substring(0, 8) + '...'; } return val; } if (typeof val === 'number') return val.toLocaleString('ru-RU'); if (typeof val === 'boolean') return val ? 'да' : 'нет'; if (Array.isArray(val)) return '[' + val.length + ' элементов]'; if (typeof val === 'object') return '{объект}'; return String(val); } function formatFieldName(key) { var fieldLabels = { name: 'Название', label: 'Название', status: 'Статус', start: 'Начало', end: 'Конец', amount: 'Сумма', qty: 'Количество', price: 'Цена', unit: 'Единица', planned_start: 'Плановое начало', planned_end: 'Плановое окончание', actual_start: 'Фактическое начало', actual_end: 'Фактическое окончание', notes: 'Примечания', created_at: 'Создано', updated_at: 'Обновлено', contractor_id: 'Подрядчик', building_id: 'Здание', milestone_id: 'Этап', description: 'Описание', category: 'Категория', priority: 'Приоритет' }; return fieldLabels[key] || key; } function renderDiff(diff) { if (!diff || typeof diff !== 'object') return '—'; var items = Object.keys(diff) .filter(function (key) { // Skip only internal IDs, but keep updated_at and referenced IDs return !['id', 'tenant_id', 'project_id', 'created_at'].includes(key); }) .map(function (key) { var val = diff[key]; var fieldLabel = formatFieldName(key); if (val && typeof val === 'object' && 'old' in val && 'new' in val) { return h('div', { key: key, style: { marginBottom: 6, paddingLeft: 0 } }, h('div', { style: { fontSize: 11, fontWeight: 600, color: 'var(--rd-text-muted)', marginBottom: 3 } }, fieldLabel), h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 } }, h('span', { style: { color: 'var(--rd-bad)', textDecoration: 'line-through' } }, formatValue(val.old) ), h('span', { style: { color: 'var(--rd-text-muted)' } }, '→'), h('span', { style: { color: 'var(--rd-good)', fontWeight: 600 } }, formatValue(val['new']) ) ) ); } return h('div', { key: key, style: { marginBottom: 6 } }, h('div', { style: { fontSize: 11, fontWeight: 600, color: 'var(--rd-text-muted)', marginBottom: 3 } }, fieldLabel), h('div', { style: { fontSize: 12, color: 'var(--rd-text)' } }, formatValue(val)) ); }); if (items.length === 0) { return h('div', { style: { fontSize: 11, color: 'var(--rd-text-muted)', fontStyle: 'italic' } }, 'Нет деталей'); } return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, items); } if (loading) { return h('div', { style: { flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--rd-text-muted)' } }, h('div', { style: { textAlign: 'center' } }, h('div', { style: { fontSize: 48, marginBottom: 12 } }, '⏳'), h('div', { style: { fontSize: 14 } }, 'Загрузка истории изменений...') ) ); } 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)' } }, changes.length + ' изменений в проекте' ) ), // Filters h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, ['all', 'works', 'sections', 'payments', 'deliveries', 'kotlovans'].map(function (f) { var labels = { all: 'Все', works: 'Работы', sections: 'Смета', payments: 'Платежи', deliveries: 'Поставки', kotlovans: 'Котлованы' }; var isActive = filter === f; return h('button', { key: f, onClick: function () { setFilter(f); }, style: { fontSize: 12, fontWeight: 600, padding: '8px 14px', border: '1px solid var(--rd-border)', borderRadius: 8, background: isActive ? 'var(--rd-accent)' : 'var(--rd-surface)', color: isActive ? '#fff' : 'var(--rd-text)', cursor: 'pointer', transition: 'all 0.2s' } }, labels[f] || f); }) ), // Timeline h('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } }, filtered.length === 0 ? h('div', { style: { padding: 40, textAlign: 'center', color: 'var(--rd-text-muted)', fontSize: 13, background: 'var(--rd-surface)', border: '1px solid var(--rd-border)', borderRadius: 12 } }, 'Нет изменений' ) : filtered.map(function (c, i) { return h('div', { key: i, style: { background: 'var(--rd-surface)', border: '1px solid var(--rd-border)', borderRadius: 12, padding: 16, position: 'relative' } }, // Timeline dot h('div', { style: { position: 'absolute', left: -6, top: 20, width: 12, height: 12, borderRadius: '50%', background: c.op === 'delete' ? 'var(--rd-bad)' : (c.op === 'create' ? 'var(--rd-good)' : 'var(--rd-info)'), border: '3px solid var(--rd-bg)' } }), // Header h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 } }, // Title row h('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } }, operationBadge(c.op), h('div', { style: { fontSize: 14, fontWeight: 700, color: 'var(--rd-text)' } }, formatLabel(c) ), h('div', { style: { flex: 1 } }), h('div', { style: { fontSize: 11, color: 'var(--rd-text-muted)' } }, c.timestamp ? new Date(c.timestamp).toLocaleString('ru-RU') : '—' ) ), // Meta row: WHO + WHY h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, fontSize: 12 } }, c.who && h('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } }, h('span', { style: { color: 'var(--rd-text-muted)' } }, c.who === 'system' ? '🤖' : '👤'), h('span', { style: { fontWeight: 600, color: c.who === 'system' ? 'var(--rd-info)' : 'var(--rd-accent)' } }, c.who) ), // Why: check for notes/reason in diff (c.diff && (c.diff.notes || c.diff.reason)) && h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, flex: 1 } }, h('span', { style: { color: 'var(--rd-text-muted)' } }, '💬'), h('span', { style: { color: 'var(--rd-text)', fontStyle: 'italic' } }, (typeof c.diff.notes === 'string' ? c.diff.notes : (c.diff.notes && c.diff.notes['new'])) || (typeof c.diff.reason === 'string' ? c.diff.reason : (c.diff.reason && c.diff.reason['new'])) ) ), // Cascade indicator c.cascade_count && c.cascade_count > 0 && h('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } }, h('span', { style: { color: 'var(--rd-warn)' } }, '⚡'), h('span', { style: { color: 'var(--rd-warn)', fontWeight: 600 } }, '+' + c.cascade_count + ' связанных' ) ) ) ), // Diff c.diff && h('div', { style: { padding: 12, background: 'var(--rd-surface-2)', borderRadius: 8, border: '1px solid var(--rd-border)' } }, renderDiff(c.diff) ) ); }) ) ); };