233 lines
9.6 KiB
TypeScript
Executable File
233 lines
9.6 KiB
TypeScript
Executable File
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">Только с долгом > 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>
|
||
);
|
||
};
|