233 lines
9.6 KiB
TypeScript
233 lines
9.6 KiB
TypeScript
|
|
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>
|
|||
|
|
);
|
|||
|
|
};
|