Initial commit MKD fixes
This commit is contained in:
232
components/finance/DebtorReportDetailView.tsx
Executable file
232
components/finance/DebtorReportDetailView.tsx
Executable file
@@ -0,0 +1,232 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user