feat: SPEC-36H addendum — status filter, overdue sort, overdue column in upcoming payments

Radio.Group filter: Все/Ожидает/Одобрен/Планируется.
Sort mode: По дате / Просроченные ↑ (overdue first).
New column "Просрочка": shows N дн. in red for past-due payments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louis 2026-04-10 15:21:31 +03:00
parent 80978df991
commit 5400b9328c
2 changed files with 75 additions and 25 deletions

View File

@ -17,8 +17,13 @@ Succes. 2 fichiers modifies (CashflowTab.tsx, PaymentsTab.tsx).
- Arret apres 10s : OUI (setTimeout → setBlinkingId(null))
- Tab key Счета : `payments` (dans DocumentsPage.tsx)
## 4. COMPILATION
## 4. FILTRES ET TRI
- Filtre par statut (Radio.Group) : OUI — Все / Ожидает / Одобрен / Планируется
- Tri "Просроченные ↑" : OUI — overdue en tete, puis par date
- Colonne "Просрочка" (N дн. en rouge) : OUI — calcul diffDays, affiche si > 0
## 5. COMPILATION
- Frontend tsc : 0 erreurs
## 5. COMMITS
## 6. COMMITS
A commiter

View File

@ -188,6 +188,8 @@ export function CashflowTab() {
const [manualSubmitting, setManualSubmitting] = useState(false);
const [manualForm] = Form.useForm();
const [upcomingDateRange, setUpcomingDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [upcomingStatusFilter, setUpcomingStatusFilter] = useState<string>('ALL');
const [upcomingSortMode, setUpcomingSortMode] = useState<'date' | 'overdue'>('date');
// ---- Fetch functions ----
@ -806,15 +808,35 @@ export function CashflowTab() {
// ---- Filtered upcoming payments ----
const filteredUpcoming = useMemo(() => {
const items = dashboard?.upcomingPayments ?? [];
if (!upcomingDateRange || !upcomingDateRange[0] || !upcomingDateRange[1]) return items;
const from = upcomingDateRange[0].startOf('day');
const to = upcomingDateRange[1].endOf('day');
return items.filter(p => {
const d = dayjs(p.date);
return d.isAfter(from.subtract(1, 'ms')) && d.isBefore(to.add(1, 'ms'));
});
}, [dashboard?.upcomingPayments, upcomingDateRange]);
let items = [...(dashboard?.upcomingPayments ?? [])];
// Date range filter
if (upcomingDateRange && upcomingDateRange[0] && upcomingDateRange[1]) {
const from = upcomingDateRange[0].startOf('day');
const to = upcomingDateRange[1].endOf('day');
items = items.filter(p => {
const d = dayjs(p.date);
return d.isAfter(from.subtract(1, 'ms')) && d.isBefore(to.add(1, 'ms'));
});
}
// Status filter
if (upcomingStatusFilter !== 'ALL') {
items = items.filter(p => p.status === upcomingStatusFilter);
}
// Sort
const now = new Date();
now.setHours(0, 0, 0, 0);
if (upcomingSortMode === 'overdue') {
items.sort((a, b) => {
const aOver = new Date(a.date) < now ? 1 : 0;
const bOver = new Date(b.date) < now ? 1 : 0;
if (aOver !== bOver) return bOver - aOver;
return new Date(a.date).getTime() - new Date(b.date).getTime();
});
} else {
items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}
return items;
}, [dashboard?.upcomingPayments, upcomingDateRange, upcomingStatusFilter, upcomingSortMode]);
const upcomingTotal = useMemo(() => filteredUpcoming.reduce((s, p) => s + p.amount, 0), [filteredUpcoming]);
@ -834,6 +856,13 @@ export function CashflowTab() {
{ title: 'Сумма', dataIndex: 'amount', key: 'amount', width: 130, align: 'right', render: (v: number) => formatAmount(v) },
{ title: 'Статус', dataIndex: 'status', key: 'status', width: 120,
render: (v: string) => <Tag color={upcomingStatusColors[v] ?? 'default'}>{upcomingStatusLabels[v] ?? v}</Tag> },
{ title: 'Просрочка', key: 'overdue', width: 100,
render: (_: unknown, r) => {
const now = new Date(); now.setHours(0, 0, 0, 0);
const due = new Date(r.date); due.setHours(0, 0, 0, 0);
const diff = Math.floor((now.getTime() - due.getTime()) / (1000 * 60 * 60 * 24));
return diff > 0 ? <span style={{ color: '#ff4d4f', fontWeight: 600 }}>{diff} дн.</span> : null;
} },
{ title: 'ЗП', dataIndex: 'poNumber', key: 'po', width: 130,
render: (v: string, r) => r.poId ? <a onClick={(e) => { e.stopPropagation(); window.location.href = `/purchases?tab=orders&po=${r.poId}`; }}>{v}</a> : v },
];
@ -1167,20 +1196,36 @@ export function CashflowTab() {
</Button>
</div>
),
children: filteredUpcoming.length > 0 ? (
<Table
columns={upcomingColumns}
dataSource={filteredUpcoming}
rowKey={(r) => `${r.type}-${r.id}`}
pagination={{ pageSize: 10, size: 'small', showSizeChanger: true, pageSizeOptions: ['10', '25', '50'] }}
size="small"
onRow={(r) => ({
style: { cursor: r.poId ? 'pointer' : undefined },
onClick: () => { if (r.poId) window.location.href = `/purchases?tab=orders&po=${r.poId}`; },
})}
/>
) : (
<div style={{ padding: 16, color: '#999', textAlign: 'center' }}>Нет ближайших платежей</div>
children: (
<>
<Space style={{ marginBottom: 12 }} wrap>
<Radio.Group value={upcomingStatusFilter} onChange={(e) => setUpcomingStatusFilter(e.target.value)} size="small">
<Radio.Button value="ALL">Все</Radio.Button>
<Radio.Button value="PENDING">Ожидает</Radio.Button>
<Radio.Button value="APPROVED">Одобрен</Radio.Button>
<Radio.Button value="PLANNED">Планируется</Radio.Button>
</Radio.Group>
<Radio.Group value={upcomingSortMode} onChange={(e) => setUpcomingSortMode(e.target.value)} size="small">
<Radio.Button value="date">По дате</Radio.Button>
<Radio.Button value="overdue">Просроченные </Radio.Button>
</Radio.Group>
</Space>
{filteredUpcoming.length > 0 ? (
<Table
columns={upcomingColumns}
dataSource={filteredUpcoming}
rowKey={(r) => `${r.type}-${r.id}`}
pagination={{ pageSize: 10, size: 'small', showSizeChanger: true, pageSizeOptions: ['10', '25', '50'] }}
size="small"
onRow={(r) => ({
style: { cursor: r.poId ? 'pointer' : undefined },
onClick: () => { if (r.poId) window.location.href = `/purchases?tab=orders&po=${r.poId}`; },
})}
/>
) : (
<div style={{ padding: 16, color: '#999', textAlign: 'center' }}>Нет ближайших платежей</div>
)}
</>
),
}]}
/>