feat: add spec_30E module (auto-pipeline)
This commit is contained in:
parent
a71acbef56
commit
32eac7e3ed
11
CLAUDE.md
11
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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user