Files
mkd/components/finance/DebtorReportDetailView.tsx
2026-02-04 00:17:04 +05:00

233 lines
9.6 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};