Files
mkd/components/finance/DebtorReportDetailView.tsx

233 lines
9.6 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect, useCallback } from 'react';
import { ArrowLeft, Search, Loader2, Send, FileText } from 'lucide-react';
import { FinancialReport } from '../../types';
import { authFetch } from '../../services/apiClient';
export interface DebtorReportRow {
id: number;
reportId: number;
rowIndex: number;
account: string;
responsibleName: string | null;
objectAddress: string | null;
monthsDebt: number | null;
totalDebt: number;
createdAt?: string;
}
interface DebtorReportDetailViewProps {
report: FinancialReport;
onBack: () => void;
}
function extractApartment(objectAddress: string | null, account: string): string {
if (!objectAddress || !objectAddress.trim()) return account || '—';
const m = objectAddress.match(/кв\.\s*([^,;\s]+)/i) || objectAddress.match(/кв\.([^,;\s]+)/i);
if (m) return m[1].trim();
const parts = objectAddress.split(/[,;]/).map(p => p.trim()).filter(Boolean);
if (parts.length > 0) return parts[parts.length - 1];
return account || '—';
}
export const DebtorReportDetailView: React.FC<DebtorReportDetailViewProps> = ({ report, onBack }) => {
const [rows, setRows] = useState<DebtorReportRow[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [onlyWithDebt, setOnlyWithDebt] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [transferring, setTransferring] = useState(false);
const fetchRows = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (search.trim()) params.set('search', search.trim());
if (onlyWithDebt) params.set('minDebt', '0.01');
const url = `/api/finance/reports/${report.id}/debtor-rows${params.toString() ? '?' + params.toString() : ''}`;
const res = await authFetch(url);
if (!res.ok) throw new Error('Не удалось загрузить данные');
const data = await res.json();
setRows(data);
} catch (e) {
console.error(e);
setRows([]);
} finally {
setLoading(false);
}
}, [report.id, search, onlyWithDebt]);
useEffect(() => {
fetchRows();
}, [fetchRows]);
const toggleSelect = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === rows.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(rows.map(r => r.id)));
}
};
const handleTransferToLegal = async () => {
const selected = rows.filter(r => selectedIds.has(r.id));
if (selected.length === 0) {
alert('Выберите хотя бы одну строку для передачи в досудебную работу.');
return;
}
setTransferring(true);
let ok = 0;
let err = 0;
for (const row of selected) {
const apartment = extractApartment(row.objectAddress, row.account);
const debtMonths = row.monthsDebt != null ? row.monthsDebt : 0;
if (debtMonths < 1) {
err++;
continue;
}
try {
const res = await authFetch('/api/legal/debtors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
buildingId: null,
apartment,
debtorName: row.responsibleName || undefined,
address: row.objectAddress || row.account,
debtAmount: row.totalDebt,
debtMonths,
}),
});
if (res.ok) ok++;
else err++;
} catch {
err++;
}
}
setTransferring(false);
setSelectedIds(new Set());
if (ok > 0) alert(`Передано в досудебную работу: ${ok}.${err > 0 ? ` Ошибок: ${err}.` : ''}`);
if (err > 0 && ok === 0) alert('Не удалось передать. Проверьте, что у выбранных строк указаны месяцы задолженности.');
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-4">
<button
type="button"
onClick={onBack}
className="flex items-center gap-2 text-slate-600 hover:text-slate-900 font-medium"
>
<ArrowLeft className="w-4 h-4" />
Назад к списку отчётов
</button>
<div className="flex items-center gap-2 text-slate-700">
<FileText className="w-5 h-5 text-primary-600" />
<span className="font-semibold">{report.filename}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по счёту, ФИО, адресу..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={onlyWithDebt}
onChange={e => setOnlyWithDebt(e.target.checked)}
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm font-medium text-slate-700">Только с долгом &gt; 0</span>
</label>
{selectedIds.size > 0 && (
<button
type="button"
onClick={handleTransferToLegal}
disabled={transferring}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-xl text-sm font-semibold hover:bg-primary-700 disabled:opacity-50"
>
{transferring ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
Передать в досудебную работу ({selectedIds.size})
</button>
)}
</div>
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
</div>
) : rows.length === 0 ? (
<div className="py-12 text-center text-slate-500 text-sm">Нет данных по заданным фильтрам.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left p-3 font-semibold text-slate-700 w-10">
<input
type="checkbox"
checked={rows.length > 0 && selectedIds.size === rows.length}
onChange={toggleSelectAll}
className="rounded border-slate-300 text-primary-600"
/>
</th>
<th className="text-left p-3 font-semibold text-slate-700">Лицевой счёт</th>
<th className="text-left p-3 font-semibold text-slate-700">Ответственный</th>
<th className="text-left p-3 font-semibold text-slate-700">Объект учета</th>
<th className="text-right p-3 font-semibold text-slate-700 w-28">Месяцев</th>
<th className="text-right p-3 font-semibold text-slate-700 w-32">Сумма задолженности</th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="p-3">
<input
type="checkbox"
checked={selectedIds.has(row.id)}
onChange={() => toggleSelect(row.id)}
className="rounded border-slate-300 text-primary-600"
/>
</td>
<td className="p-3 font-medium text-slate-800">{row.account}</td>
<td className="p-3 text-slate-700">{row.responsibleName ?? '—'}</td>
<td className="p-3 text-slate-700 max-w-xs truncate" title={row.objectAddress ?? ''}>
{row.objectAddress ?? '—'}
</td>
<td className="p-3 text-right text-slate-700">{row.monthsDebt != null ? row.monthsDebt : '—'}</td>
<td className="p-3 text-right font-medium">
<span className={row.totalDebt > 0 ? 'text-red-600' : 'text-slate-600'}>
{typeof row.totalDebt === 'number' ? row.totalDebt.toLocaleString('ru-RU', { minimumFractionDigits: 2 }) : row.totalDebt}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{!loading && rows.length > 0 && (
<p className="text-xs text-slate-500">Всего строк: {rows.length}</p>
)}
</div>
);
};