From 32eac7e3edd3b166610b4f86ac4cd8fd10d4f3fe Mon Sep 17 00:00:00 2001 From: Louis-Andre Date: Tue, 24 Mar 2026 13:53:00 +0000 Subject: [PATCH] feat: add spec_30E module (auto-pipeline) --- CLAUDE.md | 11 +- .../pages/orders/tabs/SmetaRevisionTab.tsx | 313 +++++++++++------- 2 files changed, 197 insertions(+), 127 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c015889..66b7894 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,6 +274,7 @@ prisma/ - [x] revision-impact-frontend (SPEC 30B: onglet Изменения Закупок dans OrderDetailPage — 3 cartes stats SURPLUS/RACHAT/MODIFIED, tableau impacts avec ack individuel+bulk, Badge pendingCount sur onglet, onPendingCountChange callback, visible post-AWAITING_ESTIMATE, 18+6 tests OK) - implemente 24/03/2026 - [x] budget-fix-quantity (SPEC 30C: fix budget totalEstimated×order.quantity dans computeTotals+getDashboard, variancePercent Decimal(10,2) overflow fix, 18+6 tests OK) - implemente 24/03/2026 - [x] cancel-revision (SPEC 30D: DELETE /estimates/revisions/:id annule REV_PENDING+supprime newSmeta cascade, bouton Отменить ревизию frontend, historique revisions sorti du Collapse en Card visible, 18+6 tests OK) - implemente 24/03/2026 +- [x] smeta-revision-history-refonte (SPEC 30E: fusion sections 3+4 SmetaRevisionTab en un seul Table AntD История ревизий, 8 colonnes version/status/date/auteur/changements/delta/dopzatraty/actions, expandable rows доп затраты par revision, orphan costs section, bouton Доп затрата dans header Card, Timeline remplace par Tooltip import, 18+6 tests OK) - implemente 24/03/2026 - [ ] invoices (facturation) - [ ] reports (rapports financiers) @@ -726,9 +727,9 @@ Statuts commande : DRAFT → AWAITING_ESTIMATE → AWAITING_VALIDATION → VALID Statuts smeta : DRAFT → SUBMITTED → FINANCIAL_REVIEW → TECHNICAL_REVIEW → APPROVED → LOCKED Statuts tender : DRAFT → SENT → COMPARING → AWARDED → CLOSED -## Derniere session (24/03/2026, cancel-revision) -- SPEC 30D: annulation revision smeta REV_PENDING + historique visible -- DELETE /estimates/revisions/:id supprime revision+newSmeta en transaction -- Bouton Отменить ревизию dans action buttons REV_PENDING -- Historique revisions: Collapse remplace par Card directement visible +## Derniere session (24/03/2026, smeta-revision-history-refonte) +- SPEC 30E: refonte historique SmetaRevisionTab — fusion sections 3+4 en un seul Table AntD +- 8 colonnes: version/statut/date/auteur/changements/delta budget/dop zatraty/actions +- Expandable rows pour доп затраты par revision, section orphans en bas +- Timeline remplace par Tooltip dans imports - E2E 18/18, Playwright 6/6 diff --git a/frontend/src/pages/orders/tabs/SmetaRevisionTab.tsx b/frontend/src/pages/orders/tabs/SmetaRevisionTab.tsx index 6f6b77e..49cb8d9 100644 --- a/frontend/src/pages/orders/tabs/SmetaRevisionTab.tsx +++ b/frontend/src/pages/orders/tabs/SmetaRevisionTab.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Upload, Table, Tag, Select, InputNumber, Input, Button, Alert, Space, - Card, Statistic, Row, Col, Timeline, Modal, Form, message, + Card, Statistic, Row, Col, Tooltip, Modal, Form, message, Popconfirm, Spin, Typography, } from 'antd'; import { @@ -591,140 +591,209 @@ export function SmetaRevisionTab({ orderId, orderStatus }: Props) { )} - {/* Section 3: Additional Costs */} - } - onClick={() => setCostModalOpen(true)} - > - Добавить - - } - > - {additionalCosts.length === 0 ? ( - Нет дополнительных затрат - ) : ( - - dataSource={additionalCosts} + {/* Section 3+4 merged: Revision History Table */} + {revisions.length > 0 && ( + } + onClick={() => setCostModalOpen(true)} + > + Доп. затрата + + } + > + costTypeLabels[t] ?? t, - }, - { title: 'Описание', dataIndex: 'description' }, - { - title: 'Сумма', dataIndex: 'amount', width: 120, - render: (v: string) => `${Number(v).toLocaleString('ru-RU')} ₽`, - }, - { - title: 'Статус', dataIndex: 'status', width: 120, - render: (s: string) => ( - {costStatusLabels[s] ?? s} + title: 'Версия', width: 120, + render: (_: unknown, r: SmetaRevision) => ( + v{r.oldSmeta?.version} → v{r.newSmeta?.version} ), }, - { title: 'Автор', width: 120, render: (_: unknown, r: AdditionalCost) => r.createdBy?.name ?? '—' }, { - title: 'Действия', width: 160, - render: (_: unknown, r: AdditionalCost) => r.status === 'PENDING' ? ( - - { await api.approveAdditionalCost(r.id); fetchAll(); }}> - - - { await api.rejectAdditionalCost(r.id); fetchAll(); }}> - - - - ) : null, + title: 'Статус', width: 140, + render: (_: unknown, r: SmetaRevision) => ( + + {revisionStatusLabels[r.status] ?? r.status} + + ), + }, + { + title: 'Дата', width: 110, + render: (_: unknown, r: SmetaRevision) => new Date(r.createdAt).toLocaleDateString('ru-RU'), + }, + { + title: 'Автор', width: 120, + render: (_: unknown, r: SmetaRevision) => r.createdBy?.name ?? '—', + }, + { + title: 'Изменений', width: 100, + render: (_: unknown, r: SmetaRevision) => { + const report = r.diffReport?.summary; + if (!report) return '—'; + return report.added + report.removed + report.modified; + }, + }, + { + title: 'Дельта бюджета', width: 150, + render: (_: unknown, r: SmetaRevision) => { + const delta = r.diffReport?.summary?.totalBudgetDelta; + if (delta == null || delta === 0) return '—'; + const num = Number(delta); + return ( + 0 ? '#e74c3c' : '#27ae60', fontWeight: 500 }}> + {num > 0 ? '+' : ''}{num.toLocaleString('ru-RU')} ₽ + + ); + }, + }, + { + title: 'Доп. затраты', width: 140, + render: (_: unknown, r: SmetaRevision) => { + const revCosts = additionalCosts.filter((c: AdditionalCost) => c.revisionId === r.id); + if (revCosts.length === 0) return '—'; + const total = revCosts.reduce((s: number, c: AdditionalCost) => s + Number(c.amount), 0); + return ( + `${costTypeLabels[c.type] ?? c.type}: ${Number(c.amount).toLocaleString('ru-RU')} ₽ (${costStatusLabels[c.status] ?? c.status})`).join('\n')}> + {revCosts.length} шт. / {total.toLocaleString('ru-RU')} ₽ + + ); + }, + }, + { + title: 'Действия', width: 120, + render: (_: unknown, r: SmetaRevision) => { + if (r.status === 'REV_PENDING') { + return ( + + ); + } + return ( + + ); + }, }, ]} + expandable={{ + expandedRowRender: (r: SmetaRevision) => { + const revCosts = additionalCosts.filter((c: AdditionalCost) => c.revisionId === r.id); + if (revCosts.length === 0) return Нет дополнительных затрат для этой ревизии; + return ( +
costTypeLabels[t] ?? t, + }, + { title: 'Описание', dataIndex: 'description' }, + { + title: 'Сумма', dataIndex: 'amount', width: 120, + render: (v: string) => `${Number(v).toLocaleString('ru-RU')} ₽`, + }, + { + title: 'Статус', dataIndex: 'status', width: 120, + render: (s: string) => ( + {costStatusLabels[s] ?? s} + ), + }, + { title: 'Автор', width: 120, render: (_: unknown, rc: AdditionalCost) => rc.createdBy?.name ?? '—' }, + { + title: 'Действия', width: 160, + render: (_: unknown, rc: AdditionalCost) => rc.status === 'PENDING' ? ( + + { await api.approveAdditionalCost(rc.id); fetchAll(); }}> + + + { await api.rejectAdditionalCost(rc.id); fetchAll(); }}> + + + + ) : null, + }, + ]} + /> + ); + }, + rowExpandable: (r: SmetaRevision) => { + return additionalCosts.filter((c: AdditionalCost) => c.revisionId === r.id).length > 0; + }, + }} + scroll={{ x: 1000 }} /> - )} - - - {/* Section 4: Revision History */} - {revisions.length > 0 && ( - - { - const report = r.diffReport?.summary; - const totalChanges = report ? (report.added + report.removed + report.modified) : 0; - const revCosts = additionalCosts.filter(c => c.revisionId === r.id); - const timelineColor = r.status === 'REV_APPROVED' ? 'green' - : r.status === 'PARTIALLY_APPROVED' ? '#f39c12' - : r.status === 'REV_REJECTED' ? 'red' - : 'gray'; - return { - color: timelineColor, - children: ( -
- - v{r.oldSmeta?.version} → v{r.newSmeta?.version} - - {revisionStatusLabels[r.status] ?? r.status} - - -
- - {r.createdBy?.name} — {new Date(r.createdAt).toLocaleDateString('ru-RU')} - -
- {report && ( -
- - {totalChanges} изм. - {report.totalBudgetDelta !== 0 && ( - 0 ? '#e74c3c' : '#27ae60' }}> - {report.totalBudgetDelta > 0 ? '+' : ''}{Number(report.totalBudgetDelta).toLocaleString('ru-RU')} ₽ - - )} - {revCosts.length > 0 && ( - доп. затрат: {revCosts.length} - )} - -
- )} -
- {r.status === 'REV_PENDING' ? ( - - ) : ( - - )} -
-
+ {/* Orphan additional costs (not linked to a revision) */} + {additionalCosts.filter((c: AdditionalCost) => !c.revisionId).length > 0 && ( +
+ Доп. затраты без ревизии +
!c.revisionId)} + rowKey="id" + size="small" + pagination={false} + columns={[ + { + title: 'Тип', dataIndex: 'type', width: 160, + render: (t: AdditionalCostType) => costTypeLabels[t] ?? t, + }, + { title: 'Описание', dataIndex: 'description' }, + { + title: 'Сумма', dataIndex: 'amount', width: 120, + render: (v: string) => `${Number(v).toLocaleString('ru-RU')} ₽`, + }, + { + title: 'Статус', dataIndex: 'status', width: 120, + render: (s: string) => ( + {costStatusLabels[s] ?? s} ), - }; - })} + }, + { title: 'Автор', width: 120, render: (_: unknown, rc: AdditionalCost) => rc.createdBy?.name ?? '—' }, + { + title: 'Действия', width: 160, + render: (_: unknown, rc: AdditionalCost) => rc.status === 'PENDING' ? ( + + { await api.approveAdditionalCost(rc.id); fetchAll(); }}> + + + { await api.rejectAdditionalCost(rc.id); fetchAll(); }}> + + + + ) : null, + }, + ]} /> + + )} )}