feat: add spec_30E module (auto-pipeline)

This commit is contained in:
Louis-Andre 2026-03-24 13:53:00 +00:00
parent a71acbef56
commit 32eac7e3ed
2 changed files with 197 additions and 127 deletions

View File

@ -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

View File

@ -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) {
</Card>
)}
{/* Section 3: Additional Costs */}
<Card
title="Дополнительные затраты"
style={{ marginBottom: 16 }}
extra={
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setCostModalOpen(true)}
>
Добавить
</Button>
}
>
{additionalCosts.length === 0 ? (
<Text type="secondary">Нет дополнительных затрат</Text>
) : (
<Table<AdditionalCost>
dataSource={additionalCosts}
{/* Section 3+4 merged: Revision History Table */}
{revisions.length > 0 && (
<Card
title={`История ревизий (${revisions.length})`}
style={{ marginBottom: 16 }}
extra={
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setCostModalOpen(true)}
>
Доп. затрата
</Button>
}
>
<Table
dataSource={revisions}
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) => (
<Tag color={costStatusColors[s]}>{costStatusLabels[s] ?? s}</Tag>
title: 'Версия', width: 120,
render: (_: unknown, r: SmetaRevision) => (
<Text strong>v{r.oldSmeta?.version} v{r.newSmeta?.version}</Text>
),
},
{ title: 'Автор', width: 120, render: (_: unknown, r: AdditionalCost) => r.createdBy?.name ?? '—' },
{
title: 'Действия', width: 160,
render: (_: unknown, r: AdditionalCost) => r.status === 'PENDING' ? (
<Space size="small">
<Popconfirm title="Утвердить?" onConfirm={async () => { await api.approveAdditionalCost(r.id); fetchAll(); }}>
<Button size="small" type="link" style={{ color: '#27ae60' }}>Утвердить</Button>
</Popconfirm>
<Popconfirm title="Отклонить?" onConfirm={async () => { await api.rejectAdditionalCost(r.id); fetchAll(); }}>
<Button size="small" type="link" danger>Отклонить</Button>
</Popconfirm>
</Space>
) : null,
title: 'Статус', width: 140,
render: (_: unknown, r: SmetaRevision) => (
<Tag color={revisionStatusColors[r.status]}>
{revisionStatusLabels[r.status] ?? r.status}
</Tag>
),
},
{
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 (
<span style={{ color: num > 0 ? '#e74c3c' : '#27ae60', fontWeight: 500 }}>
{num > 0 ? '+' : ''}{num.toLocaleString('ru-RU')}
</span>
);
},
},
{
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 (
<Tooltip title={revCosts.map((c: AdditionalCost) => `${costTypeLabels[c.type] ?? c.type}: ${Number(c.amount).toLocaleString('ru-RU')} ₽ (${costStatusLabels[c.status] ?? c.status})`).join('\n')}>
<span>{revCosts.length} шт. / {total.toLocaleString('ru-RU')} </span>
</Tooltip>
);
},
},
{
title: 'Действия', width: 120,
render: (_: unknown, r: SmetaRevision) => {
if (r.status === 'REV_PENDING') {
return (
<Button
size="small"
type="link"
onClick={async () => {
const detail = await api.getRevision(r.id);
setActiveRevision(detail);
setViewingRevision(null);
}}
>
Открыть
</Button>
);
}
return (
<Button
size="small"
type="link"
onClick={async () => {
const detail = await api.getRevision(r.id);
setViewingRevision(detail);
}}
>
Просмотр diff
</Button>
);
},
},
]}
expandable={{
expandedRowRender: (r: SmetaRevision) => {
const revCosts = additionalCosts.filter((c: AdditionalCost) => c.revisionId === r.id);
if (revCosts.length === 0) return <Text type="secondary">Нет дополнительных затрат для этой ревизии</Text>;
return (
<Table
dataSource={revCosts}
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) => (
<Tag color={costStatusColors[s]}>{costStatusLabels[s] ?? s}</Tag>
),
},
{ title: 'Автор', width: 120, render: (_: unknown, rc: AdditionalCost) => rc.createdBy?.name ?? '—' },
{
title: 'Действия', width: 160,
render: (_: unknown, rc: AdditionalCost) => rc.status === 'PENDING' ? (
<Space size="small">
<Popconfirm title="Утвердить?" onConfirm={async () => { await api.approveAdditionalCost(rc.id); fetchAll(); }}>
<Button size="small" type="link" style={{ color: '#27ae60' }}>Утвердить</Button>
</Popconfirm>
<Popconfirm title="Отклонить?" onConfirm={async () => { await api.rejectAdditionalCost(rc.id); fetchAll(); }}>
<Button size="small" type="link" danger>Отклонить</Button>
</Popconfirm>
</Space>
) : null,
},
]}
/>
);
},
rowExpandable: (r: SmetaRevision) => {
return additionalCosts.filter((c: AdditionalCost) => c.revisionId === r.id).length > 0;
},
}}
scroll={{ x: 1000 }}
/>
)}
</Card>
{/* Section 4: Revision History */}
{revisions.length > 0 && (
<Card
title={`История ревизий (${revisions.length})`}
style={{ marginBottom: 16 }}
size="small"
>
<Timeline
items={revisions.map(r => {
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: (
<div key={r.id}>
<Space>
<Text strong>v{r.oldSmeta?.version} v{r.newSmeta?.version}</Text>
<Tag color={revisionStatusColors[r.status]}>
{revisionStatusLabels[r.status] ?? r.status}
</Tag>
</Space>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.createdBy?.name} {new Date(r.createdAt).toLocaleDateString('ru-RU')}
</Text>
</div>
{report && (
<div style={{ fontSize: 12, marginTop: 4 }}>
<Space size="middle">
<span>{totalChanges} изм.</span>
{report.totalBudgetDelta !== 0 && (
<span style={{ color: report.totalBudgetDelta > 0 ? '#e74c3c' : '#27ae60' }}>
{report.totalBudgetDelta > 0 ? '+' : ''}{Number(report.totalBudgetDelta).toLocaleString('ru-RU')}
</span>
)}
{revCosts.length > 0 && (
<span>доп. затрат: {revCosts.length}</span>
)}
</Space>
</div>
)}
<div style={{ marginTop: 4 }}>
{r.status === 'REV_PENDING' ? (
<Button
size="small"
type="link"
onClick={async () => {
const detail = await api.getRevision(r.id);
setActiveRevision(detail);
setViewingRevision(null);
}}
>
Открыть
</Button>
) : (
<Button
size="small"
type="link"
onClick={async () => {
const detail = await api.getRevision(r.id);
setViewingRevision(detail);
}}
>
Просмотр diff
</Button>
)}
</div>
</div>
{/* Orphan additional costs (not linked to a revision) */}
{additionalCosts.filter((c: AdditionalCost) => !c.revisionId).length > 0 && (
<div style={{ marginTop: 16 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>Доп. затраты без ревизии</Text>
<Table
dataSource={additionalCosts.filter((c: AdditionalCost) => !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) => (
<Tag color={costStatusColors[s]}>{costStatusLabels[s] ?? s}</Tag>
),
};
})}
},
{ title: 'Автор', width: 120, render: (_: unknown, rc: AdditionalCost) => rc.createdBy?.name ?? '—' },
{
title: 'Действия', width: 160,
render: (_: unknown, rc: AdditionalCost) => rc.status === 'PENDING' ? (
<Space size="small">
<Popconfirm title="Утвердить?" onConfirm={async () => { await api.approveAdditionalCost(rc.id); fetchAll(); }}>
<Button size="small" type="link" style={{ color: '#27ae60' }}>Утвердить</Button>
</Popconfirm>
<Popconfirm title="Отклонить?" onConfirm={async () => { await api.rejectAdditionalCost(rc.id); fetchAll(); }}>
<Button size="small" type="link" danger>Отклонить</Button>
</Popconfirm>
</Space>
) : null,
},
]}
/>
</div>
)}
</Card>
)}