Initial commit MKD fixes
This commit is contained in:
233
components/finance/AggregatedReportView.tsx
Executable file
233
components/finance/AggregatedReportView.tsx
Executable file
@@ -0,0 +1,233 @@
|
||||
import React from 'react';
|
||||
import { Download, Printer } from 'lucide-react';
|
||||
|
||||
interface AggregatedReportViewProps {
|
||||
data: {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
totalIncome: number;
|
||||
totalExpenses: number;
|
||||
totalBalance: number;
|
||||
totalArea: number;
|
||||
totalLivingArea: number;
|
||||
totalNonLivingArea: number;
|
||||
buildingsCount: number;
|
||||
aggregatedExpenses: Record<string, number>;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const AggregatedReportView: React.FC<AggregatedReportViewProps> = ({ data }) => {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
const handleDownloadCSV = () => {
|
||||
// TODO: Реализовать экспорт в CSV
|
||||
alert('Экспорт в CSV будет реализован');
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
// Рассчитываем период в месяцах
|
||||
const startDate = new Date(data.periodStart);
|
||||
const endDate = new Date(data.periodEnd);
|
||||
const monthsDiff = (endDate.getFullYear() - startDate.getFullYear()) * 12 +
|
||||
(endDate.getMonth() - startDate.getMonth()) + 1;
|
||||
|
||||
// Группируем расходы по категориям
|
||||
const expenseCategories: Record<string, { items: Array<{ name: string; amount: number }>; total: number }> = {};
|
||||
|
||||
for (const [key, amount] of Object.entries(data.aggregatedExpenses)) {
|
||||
const parts = key.split(' > ');
|
||||
const category = parts[0] || 'Прочие';
|
||||
const item = parts[1] || key;
|
||||
|
||||
if (!expenseCategories[category]) {
|
||||
expenseCategories[category] = { items: [], total: 0 };
|
||||
}
|
||||
|
||||
expenseCategories[category].items.push({ name: item, amount });
|
||||
expenseCategories[category].total += amount;
|
||||
}
|
||||
|
||||
// Сортируем категории и элементы
|
||||
const sortedCategories = Object.entries(expenseCategories)
|
||||
.sort((a, b) => b[1].total - a[1].total)
|
||||
.map(([category, data]) => ({
|
||||
category,
|
||||
items: data.items.sort((a, b) => b.amount - a.amount),
|
||||
total: data.total
|
||||
}));
|
||||
|
||||
const totalExpensesPerMonth = data.totalExpenses / monthsDiff;
|
||||
const totalExpensesPerSqM = data.totalArea > 0 ? data.totalExpenses / data.totalArea : 0;
|
||||
const totalExpensesPerSqMPerMonth = data.totalArea > 0 ? totalExpensesPerMonth / data.totalArea : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in print:space-y-4">
|
||||
{/* Кнопки действий */}
|
||||
<div className="flex justify-end gap-3 print:hidden">
|
||||
<button
|
||||
onClick={handleDownloadCSV}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Скачать CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="bg-slate-100 text-slate-700 px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
Печать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Параметры */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4">Параметры:</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-slate-600 mb-1">
|
||||
Период: {new Date(data.periodStart).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })} - {new Date(data.periodEnd).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
</p>
|
||||
{data.title && (
|
||||
<p className="text-sm text-slate-600 mb-1">{data.title}</p>
|
||||
)}
|
||||
{data.subtitle && (
|
||||
<p className="text-sm text-slate-600 mb-1">{data.subtitle}</p>
|
||||
)}
|
||||
<p className="text-sm text-slate-600 mb-1">
|
||||
Количество домов: {data.buildingsCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-2 border-t border-slate-200">
|
||||
{data.totalLivingArea > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-600 mb-1">Ж (кв.м)</p>
|
||||
<p className="font-bold text-slate-800">{formatNumber(data.totalLivingArea)}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.totalNonLivingArea > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-600 mb-1">НЖ (кв.м) в т.ч.:</p>
|
||||
<p className="font-bold text-slate-800">{formatNumber(data.totalNonLivingArea)}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.totalArea > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-600 mb-1">Итого (кв.м)</p>
|
||||
<p className="font-bold text-slate-800">{formatNumber(data.totalArea)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сводка по доходам и расходам */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4">Сводка:</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-emerald-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-slate-600 mb-1">Доходы</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(data.totalIncome)}</p>
|
||||
</div>
|
||||
<div className="bg-red-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-slate-600 mb-1">Расходы</p>
|
||||
<p className="text-2xl font-bold text-red-600">{formatCurrency(data.totalExpenses)}</p>
|
||||
</div>
|
||||
<div className={`p-4 rounded-lg ${data.totalBalance >= 0 ? 'bg-emerald-50' : 'bg-red-50'}`}>
|
||||
<p className="text-sm text-slate-600 mb-1">Баланс</p>
|
||||
<p className={`text-2xl font-bold ${data.totalBalance >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(data.totalBalance)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Детальная таблица расходов */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none overflow-x-auto">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4">Детальная разбивка расходов:</h3>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-slate-300">
|
||||
<th className="text-left p-3 text-sm font-bold text-slate-700">№ п/п</th>
|
||||
<th className="text-left p-3 text-sm font-bold text-slate-700">Статья затрат</th>
|
||||
<th className="text-right p-3 text-sm font-bold text-slate-700">В месяц</th>
|
||||
<th className="text-right p-3 text-sm font-bold text-slate-700">Сумма</th>
|
||||
<th className="text-right p-3 text-sm font-bold text-slate-700">руб/кв.м в месяц</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedCategories.map((category, categoryIndex) => (
|
||||
<React.Fragment key={categoryIndex}>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<td colSpan={5} className="p-3 font-bold text-slate-800">
|
||||
{categoryIndex + 1}. {category.category}
|
||||
</td>
|
||||
</tr>
|
||||
{category.items.map((item, itemIndex) => {
|
||||
const perMonth = item.amount / monthsDiff;
|
||||
const perSqMPerMonth = data.totalArea > 0 ? perMonth / data.totalArea : 0;
|
||||
return (
|
||||
<tr key={itemIndex} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="p-3 text-sm text-slate-600">{categoryIndex + 1}.{itemIndex + 1}</td>
|
||||
<td className="p-3 text-sm text-slate-700">{item.name}</td>
|
||||
<td className="p-3 text-sm text-right text-slate-700">{formatCurrency(perMonth)}</td>
|
||||
<td className="p-3 text-sm text-right font-medium text-slate-800">{formatCurrency(item.amount)}</td>
|
||||
<td className="p-3 text-sm text-right text-slate-600">{formatNumber(perSqMPerMonth)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="bg-slate-100 border-b-2 border-slate-300">
|
||||
<td colSpan={2} className="p-3 font-bold text-slate-800">
|
||||
Итого по {category.category}:
|
||||
</td>
|
||||
<td className="p-3 text-sm text-right font-bold text-slate-800">
|
||||
{formatCurrency(category.total / monthsDiff)}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-right font-bold text-slate-800">
|
||||
{formatCurrency(category.total)}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-right font-bold text-slate-800">
|
||||
{formatNumber((category.total / monthsDiff) / (data.totalArea || 1))}
|
||||
</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<tr className="bg-primary-50 border-t-4 border-primary-600">
|
||||
<td colSpan={2} className="p-4 font-bold text-lg text-slate-800">
|
||||
ИТОГО:
|
||||
</td>
|
||||
<td className="p-4 text-right font-bold text-lg text-slate-800">
|
||||
{formatCurrency(totalExpensesPerMonth)}
|
||||
</td>
|
||||
<td className="p-4 text-right font-bold text-lg text-slate-800">
|
||||
{formatCurrency(data.totalExpenses)}
|
||||
</td>
|
||||
<td className="p-4 text-right font-bold text-lg text-slate-800">
|
||||
{formatNumber(totalExpensesPerSqMPerMonth)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
245
components/finance/BuildingFinancialSummary.tsx
Executable file
245
components/finance/BuildingFinancialSummary.tsx
Executable file
@@ -0,0 +1,245 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Building, BuildingFinancialData } from '../../types';
|
||||
import { TrendingUp, TrendingDown, DollarSign, Calendar, Filter, Download } from 'lucide-react';
|
||||
|
||||
interface BuildingFinancialSummaryProps {
|
||||
buildingId: string;
|
||||
building?: Building;
|
||||
}
|
||||
|
||||
export const BuildingFinancialSummary: React.FC<BuildingFinancialSummaryProps> = ({
|
||||
buildingId,
|
||||
building
|
||||
}) => {
|
||||
const [financialData, setFinancialData] = useState<BuildingFinancialData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
periodStart: '',
|
||||
periodEnd: '',
|
||||
periodType: 'month' as 'month' | 'quarter' | 'year'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchFinancialData();
|
||||
}, [buildingId, filters]);
|
||||
|
||||
const fetchFinancialData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.periodStart) params.append('periodStart', filters.periodStart);
|
||||
if (filters.periodEnd) params.append('periodEnd', filters.periodEnd);
|
||||
if (filters.periodType) params.append('periodType', filters.periodType);
|
||||
|
||||
const response = await fetch(`/api/finance/building/${buildingId}?${params}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFinancialData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки финансовых данных:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTotals = () => {
|
||||
return financialData.reduce((acc, item) => ({
|
||||
totalIncome: acc.totalIncome + (item.totalIncome || 0),
|
||||
totalExpenses: acc.totalExpenses + (item.totalExpenses || 0),
|
||||
balance: acc.balance + (item.balance || 0)
|
||||
}), { totalIncome: 0, totalExpenses: 0, balance: 0 });
|
||||
};
|
||||
|
||||
const totals = calculateTotals();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Заголовок */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-800">
|
||||
Финансовая сводка
|
||||
</h3>
|
||||
{building && (
|
||||
<p className="text-sm text-slate-500 mt-1">{building.passport.address}</p>
|
||||
)}
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-bold hover:bg-primary-700 transition-colors">
|
||||
<Download className="w-4 h-4" />
|
||||
Экспорт
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Filter className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm font-bold text-slate-700">Фильтры</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Период с
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.periodStart}
|
||||
onChange={(e) => setFilters({ ...filters, periodStart: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Период по
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.periodEnd}
|
||||
onChange={(e) => setFilters({ ...filters, periodEnd: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Тип периода
|
||||
</label>
|
||||
<select
|
||||
value={filters.periodType}
|
||||
onChange={(e) => setFilters({ ...filters, periodType: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="month">Месяц</option>
|
||||
<option value="quarter">Квартал</option>
|
||||
<option value="year">Год</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => setFilters({ periodStart: '', periodEnd: '', periodType: 'month' })}
|
||||
className="w-full px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Итоговые показатели */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-xl">
|
||||
<TrendingUp className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-black text-slate-800">
|
||||
{totals.totalIncome.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||
Общий доход
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-red-50 text-red-600 rounded-xl">
|
||||
<TrendingDown className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-black text-slate-800">
|
||||
{totals.totalExpenses.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||
Общие расходы
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`p-3 rounded-xl ${
|
||||
totals.balance >= 0 ? 'bg-primary-50 text-primary-600' : 'bg-red-50 text-red-600'
|
||||
}`}>
|
||||
<DollarSign className="w-6 h-6" />
|
||||
</div>
|
||||
<span className={`text-2xl font-black ${
|
||||
totals.balance >= 0 ? 'text-primary-600' : 'text-red-600'
|
||||
}`}>
|
||||
{totals.balance.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||
Баланс
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Детальная таблица */}
|
||||
{financialData.length > 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-4 bg-slate-50 border-b border-slate-200">
|
||||
<h4 className="font-bold text-slate-800 text-sm">Детальная разбивка по периодам</h4>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-slate-600 uppercase">Период</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-slate-600 uppercase">Доходы</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-slate-600 uppercase">Расходы</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-slate-600 uppercase">Баланс</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-slate-600 uppercase">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{financialData.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 text-sm text-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-slate-400" />
|
||||
{new Date(item.periodStart).toLocaleDateString('ru-RU')} - {new Date(item.periodEnd).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium text-emerald-600">
|
||||
{item.totalIncome.toLocaleString('ru-RU')} ₽
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium text-red-600">
|
||||
{item.totalExpenses.toLocaleString('ru-RU')} ₽
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-right font-bold ${
|
||||
item.balance >= 0 ? 'text-primary-600' : 'text-red-600'
|
||||
}`}>
|
||||
{item.balance.toLocaleString('ru-RU')} ₽
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button className="text-primary-600 hover:text-primary-700 text-xs font-medium">
|
||||
Детали
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<p className="text-slate-500">Нет данных за выбранный период</p>
|
||||
<p className="text-sm text-slate-400 mt-2">
|
||||
Загрузите отчеты из 1С для отображения финансовых данных
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
42
components/finance/DebtorsList.tsx
Executable file
42
components/finance/DebtorsList.tsx
Executable file
@@ -0,0 +1,42 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Users, Search, Filter } from 'lucide-react';
|
||||
import { Debtor } from '../../types';
|
||||
|
||||
export const DebtorsList: React.FC<{ debtors: (Debtor & { address: string })[] }> = ({ debtors }) => {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input type="text" placeholder="Поиск должника..." className="w-full pl-9 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm" />
|
||||
</div>
|
||||
<button className="p-2.5 bg-white border border-slate-200 rounded-xl text-slate-500 hover:bg-slate-50"><Filter className="w-5 h-5"/></button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm space-y-3">
|
||||
<div className="flex justify-between items-center mb-2 px-1">
|
||||
<h3 className="font-bold text-slate-700 text-sm">Реестр неплательщиков</h3>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase">Всего: {debtors.length}</span>
|
||||
</div>
|
||||
{debtors.map((d, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 bg-slate-50 hover:bg-slate-100 transition-colors rounded-xl border border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${d.months > 3 ? 'bg-red-100 text-red-600' : 'bg-amber-100 text-amber-600'}`}>
|
||||
<Users className="w-4 h-4"/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-700">{d.address}, кв. {d.apartment}</p>
|
||||
<p className="text-xs text-slate-500">{d.months} мес. долга • {d.status === 'legal' ? 'В суде' : 'Уведомлен'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-black text-red-600">{d.amount.toLocaleString()} ₽</p>
|
||||
<button className="text-[9px] font-black text-primary-600 uppercase hover:underline">Выписать претензию</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
369
components/finance/ExpenseDirectory.tsx
Executable file
369
components/finance/ExpenseDirectory.tsx
Executable file
@@ -0,0 +1,369 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Edit2, Trash2, ChevronDown, ChevronRight, Folder, FileText, Save, X } from 'lucide-react';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
|
||||
interface ExpenseCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
items?: ExpenseItem[];
|
||||
}
|
||||
|
||||
interface ExpenseItem {
|
||||
id: number;
|
||||
categoryId: number;
|
||||
name: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
parentItemId?: number;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
children?: ExpenseItem[];
|
||||
}
|
||||
|
||||
export const ExpenseDirectory: React.FC = () => {
|
||||
const [categories, setCategories] = useState<ExpenseCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set());
|
||||
const [editingItem, setEditingItem] = useState<{ type: 'category' | 'item'; id?: number; categoryId?: number; parentId?: number } | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', code: '', description: '', sortOrder: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
fetchDirectory();
|
||||
}, []);
|
||||
|
||||
const fetchDirectory = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiClient.get<ExpenseCategory[]>('/api/finance/expense-directory/tree');
|
||||
setCategories(data);
|
||||
// Раскрываем все категории по умолчанию
|
||||
setExpandedCategories(new Set(data.map(c => c.id)));
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки справочника:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInitialize = async () => {
|
||||
if (!confirm('Инициализировать справочник из стандартного списка? Существующие данные не будут удалены.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/finance/expense-directory/initialize');
|
||||
alert('Справочник успешно инициализирован');
|
||||
fetchDirectory();
|
||||
} catch (err: any) {
|
||||
alert(`Ошибка: ${err.message || 'Не удалось инициализировать справочник'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
alert('Название обязательно');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingItem?.type === 'category') {
|
||||
if (editingItem.id) {
|
||||
await apiClient.put(`/api/finance/expense-categories/${editingItem.id}`, {
|
||||
name: formData.name,
|
||||
code: formData.code || null,
|
||||
description: formData.description || null,
|
||||
sortOrder: formData.sortOrder
|
||||
});
|
||||
} else {
|
||||
await apiClient.post('/api/finance/expense-categories', {
|
||||
name: formData.name,
|
||||
code: formData.code || null,
|
||||
description: formData.description || null,
|
||||
sortOrder: formData.sortOrder
|
||||
});
|
||||
}
|
||||
} else if (editingItem?.type === 'item') {
|
||||
if (!editingItem.categoryId) {
|
||||
alert('Не указана категория');
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingItem.id) {
|
||||
await apiClient.put(`/api/finance/expense-items/${editingItem.id}`, {
|
||||
categoryId: editingItem.categoryId,
|
||||
name: formData.name,
|
||||
code: formData.code || null,
|
||||
description: formData.description || null,
|
||||
parentItemId: editingItem.parentId || null,
|
||||
sortOrder: formData.sortOrder
|
||||
});
|
||||
} else {
|
||||
await apiClient.post('/api/finance/expense-items', {
|
||||
categoryId: editingItem.categoryId,
|
||||
name: formData.name,
|
||||
code: formData.code || null,
|
||||
description: formData.description || null,
|
||||
parentItemId: editingItem.parentId || null,
|
||||
sortOrder: formData.sortOrder
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setEditingItem(null);
|
||||
setFormData({ name: '', code: '', description: '', sortOrder: 0 });
|
||||
fetchDirectory();
|
||||
} catch (err: any) {
|
||||
alert(`Ошибка: ${err.message || 'Не удалось сохранить'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (type: 'category' | 'item', id: number) => {
|
||||
if (!confirm(`Удалить ${type === 'category' ? 'категорию' : 'статью'}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (type === 'category') {
|
||||
await apiClient.delete(`/api/finance/expense-categories/${id}`);
|
||||
} else {
|
||||
await apiClient.delete(`/api/finance/expense-items/${id}`);
|
||||
}
|
||||
fetchDirectory();
|
||||
} catch (err: any) {
|
||||
alert(`Ошибка: ${err.message || 'Не удалось удалить'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (categoryId: number) => {
|
||||
const newExpanded = new Set(expandedCategories);
|
||||
if (newExpanded.has(categoryId)) {
|
||||
newExpanded.delete(categoryId);
|
||||
} else {
|
||||
newExpanded.add(categoryId);
|
||||
}
|
||||
setExpandedCategories(newExpanded);
|
||||
};
|
||||
|
||||
const renderItem = (item: ExpenseItem, categoryId: number, level: number = 0) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="ml-4">
|
||||
<div className={`flex items-center gap-2 p-2 hover:bg-slate-50 rounded ${level > 0 ? 'ml-6' : ''}`}>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{hasChildren ? (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<div className="w-4" />
|
||||
)}
|
||||
<FileText className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-700">{item.name}</span>
|
||||
{item.code && (
|
||||
<span className="text-xs text-slate-400">({item.code})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItem({ type: 'item', id: item.id, categoryId, parentId: item.parentItemId });
|
||||
setFormData({ name: item.name, code: item.code || '', description: item.description || '', sortOrder: item.sortOrder });
|
||||
}}
|
||||
className="p-1 hover:bg-slate-200 rounded"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete('item', item.id)}
|
||||
className="p-1 hover:bg-red-100 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{hasChildren && item.children && (
|
||||
<div className="ml-4">
|
||||
{item.children.map(child => renderItem(child, categoryId, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-slate-600">Загрузка справочника...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-slate-800">Справочник статей расходов</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Инициализировать справочник
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItem({ type: 'category' });
|
||||
setFormData({ name: '', code: '', description: '', sortOrder: categories.length });
|
||||
}}
|
||||
className="bg-emerald-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить категорию
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Форма редактирования */}
|
||||
{editingItem && (
|
||||
<div className="bg-slate-50 rounded-lg p-4 mb-4 border border-slate-200">
|
||||
<h3 className="font-bold text-slate-800 mb-3">
|
||||
{editingItem.id ? 'Редактировать' : 'Добавить'} {editingItem.type === 'category' ? 'категорию' : 'статью'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Название *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="Введите название"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Код</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="Код (опционально)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Порядок сортировки</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.sortOrder}
|
||||
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Описание</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
rows={2}
|
||||
placeholder="Описание (опционально)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItem(null);
|
||||
setFormData({ name: '', code: '', description: '', sortOrder: 0 });
|
||||
}}
|
||||
className="bg-slate-200 text-slate-700 px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-slate-300 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список категорий */}
|
||||
<div className="space-y-2">
|
||||
{categories.map(category => (
|
||||
<div key={category.id} className="border border-slate-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 p-3 bg-slate-50 hover:bg-slate-100">
|
||||
<button
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className="p-1 hover:bg-slate-200 rounded"
|
||||
>
|
||||
{expandedCategories.has(category.id) ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-600" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-600" />
|
||||
)}
|
||||
</button>
|
||||
<Folder className="w-5 h-5 text-primary-600" />
|
||||
<span className="font-bold text-slate-800 flex-1">{category.name}</span>
|
||||
{category.code && (
|
||||
<span className="text-xs text-slate-400">({category.code})</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItem({ type: 'category', id: category.id });
|
||||
setFormData({ name: category.name, code: category.code || '', description: category.description || '', sortOrder: category.sortOrder });
|
||||
}}
|
||||
className="p-1 hover:bg-slate-200 rounded"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItem({ type: 'item', categoryId: category.id });
|
||||
setFormData({ name: '', code: '', description: '', sortOrder: (category.items?.length || 0) });
|
||||
}}
|
||||
className="p-1 hover:bg-emerald-100 rounded"
|
||||
title="Добавить статью"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-emerald-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete('category', category.id)}
|
||||
className="p-1 hover:bg-red-100 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expandedCategories.has(category.id) && category.items && (
|
||||
<div className="p-2">
|
||||
{category.items.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 p-2">Нет статей в этой категории</p>
|
||||
) : (
|
||||
category.items.map(item => renderItem(item, category.id))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
components/finance/FinanceReports.tsx
Executable file
32
components/finance/FinanceReports.tsx
Executable file
@@ -0,0 +1,32 @@
|
||||
|
||||
import React from 'react';
|
||||
import { CheckCircle2, History } from 'lucide-react';
|
||||
|
||||
export const FinanceReports: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Archives */}
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-700 text-sm mb-3 flex items-center gap-2 px-1">
|
||||
<History className="w-4 h-4 text-slate-400"/> Архив сформированных отчетов
|
||||
</h3>
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-3 border-b border-slate-100 flex items-center justify-between hover:bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500"/>
|
||||
<span className="text-xs font-medium text-slate-700">Отчет по ГИС ЖКХ за 1кв 2024</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-slate-400">15.04.2024</span>
|
||||
</div>
|
||||
<div className="p-3 border-b border-slate-100 flex items-center justify-between hover:bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500"/>
|
||||
<span className="text-xs font-medium text-slate-700">Годовой финансовый отчет (2023)</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-slate-400">20.01.2024</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
components/finance/FinanceSummary.tsx
Executable file
61
components/finance/FinanceSummary.tsx
Executable file
@@ -0,0 +1,61 @@
|
||||
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown, Activity, ArrowUpRight } from 'lucide-react';
|
||||
|
||||
const FinancialKPI: React.FC<{ title: string; value: string; trend?: 'up' | 'down'; subtext?: string }> = ({ title, value, trend, subtext }) => {
|
||||
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : null;
|
||||
const trendColor = trend === 'up' ? 'text-emerald-500' : trend === 'down' ? 'text-red-500' : 'text-slate-400';
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
||||
<p className="text-xs text-slate-500 font-bold uppercase">{title}</p>
|
||||
<div className="flex justify-between items-end mt-2">
|
||||
<div>
|
||||
<p className="text-2xl font-black text-slate-800">{value}</p>
|
||||
{subtext && <p className="text-[10px] text-slate-400 font-medium mt-1">{subtext}</p>}
|
||||
</div>
|
||||
{TrendIcon && <TrendIcon className={`w-5 h-5 ${trendColor}`} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FinanceSummary: React.FC<{ data: any }> = ({ data }) => {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<FinancialKPI title="Общий баланс" value={`${(data.totalBalance / 1_000_000).toFixed(1)}M ₽`} subtext="Свободные средства" />
|
||||
<FinancialKPI title="В графике" value={`${(data.scheduledAmount / 1000).toFixed(0)}k ₽`} trend="up" subtext="К оплате до конца нед." />
|
||||
<FinancialKPI title="Дебиторка" value={`${(data.totalDebt / 1_000_000).toFixed(1)}M ₽`} trend="down" />
|
||||
<FinancialKPI title="Закрывающие" value={data.missingDocs.toString()} subtext="Счетов без актов" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
|
||||
<h3 className="font-bold text-slate-800 mb-4">Финансовый прогноз (Кассовый план)</h3>
|
||||
<div className="h-48 flex items-end justify-between gap-2">
|
||||
{[4.2, 5.1, 3.8, 4.9, 6.2, 4.5].map((val, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-2">
|
||||
<div className="w-full bg-slate-100 rounded-t-lg relative overflow-hidden h-32">
|
||||
<div className="absolute bottom-0 w-full bg-indigo-500/20" style={{ height: `${val * 15}%` }} />
|
||||
<div className="absolute bottom-0 w-full bg-indigo-500 rounded-t h-2" style={{ bottom: `${val * 15}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-slate-400">Нед.{i+1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-indigo-600 to-indigo-800 p-5 rounded-2xl text-white shadow-xl relative overflow-hidden">
|
||||
<ArrowUpRight className="absolute -top-4 -right-4 w-24 h-24 text-white opacity-10" />
|
||||
<h4 className="font-bold text-sm mb-2">Совет казначея</h4>
|
||||
<p className="text-[11px] text-indigo-100 leading-relaxed mb-4">
|
||||
У вас {data.missingDocs} оплаченных счетов без закрывающих актов. Это создает риски при налоговой проверке. Рекомендуем запросить документы у подрядчиков перед следующей выплатой.
|
||||
</p>
|
||||
<button className="w-full py-2 bg-white/20 hover:bg-white/30 rounded-lg text-[10px] font-black uppercase transition-colors">
|
||||
Сформировать реестр
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
244
components/finance/InvoiceDistribution.tsx
Executable file
244
components/finance/InvoiceDistribution.tsx
Executable file
@@ -0,0 +1,244 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Building, District } from '../../types';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { Calculator, Equal, Ruler, Edit3 } from 'lucide-react';
|
||||
|
||||
interface InvoiceDistributionProps {
|
||||
purposeType: 'building' | 'district';
|
||||
selectedBuildingIds: string[];
|
||||
selectedDistrictIds: string[];
|
||||
totalAmount: number;
|
||||
distributionMethod: 'equal' | 'by_area' | 'manual' | null;
|
||||
distributionData: Record<string, number>;
|
||||
onDistributionChange: (method: 'equal' | 'by_area' | 'manual', data: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
export const InvoiceDistribution: React.FC<InvoiceDistributionProps> = ({
|
||||
purposeType,
|
||||
selectedBuildingIds,
|
||||
selectedDistrictIds,
|
||||
totalAmount,
|
||||
distributionMethod,
|
||||
distributionData,
|
||||
onDistributionChange
|
||||
}) => {
|
||||
const [buildings, setBuildings] = useState<Building[]>([]);
|
||||
const [districts, setDistricts] = useState<District[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [method, setMethod] = useState<'equal' | 'by_area' | 'manual'>(distributionMethod || 'equal');
|
||||
const [manualAmounts, setManualAmounts] = useState<Record<string, number>>(distributionData || {});
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (method && totalAmount > 0) {
|
||||
calculateDistribution();
|
||||
}
|
||||
}, [method, totalAmount, selectedBuildingIds, selectedDistrictIds, buildings]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [buildingsData, districtsData] = await Promise.all([
|
||||
backendApi.getBuildings(),
|
||||
backendApi.getDistricts()
|
||||
]);
|
||||
setBuildings(buildingsData);
|
||||
setDistricts(districtsData);
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateDistribution = () => {
|
||||
if (method === 'equal') {
|
||||
const items = purposeType === 'building'
|
||||
? selectedBuildingIds
|
||||
: selectedDistrictIds;
|
||||
const count = items.length;
|
||||
if (count === 0) return;
|
||||
|
||||
const amountPerItem = totalAmount / count;
|
||||
const rounded = Math.floor(amountPerItem * 100) / 100;
|
||||
const remainder = totalAmount - (rounded * count);
|
||||
|
||||
const data: Record<string, number> = {};
|
||||
items.forEach((id, index) => {
|
||||
data[id] = rounded + (index === 0 ? remainder : 0);
|
||||
});
|
||||
|
||||
onDistributionChange('equal', data);
|
||||
setManualAmounts(data);
|
||||
} else if (method === 'by_area' && purposeType === 'building') {
|
||||
// Распределение по площади
|
||||
const selectedBuildings = buildings.filter(b => selectedBuildingIds.includes(b.id));
|
||||
const totalArea = selectedBuildings.reduce((sum, b) => {
|
||||
const area = b.passport?.general?.totalArea || 0;
|
||||
return sum + area;
|
||||
}, 0);
|
||||
|
||||
if (totalArea === 0) {
|
||||
// Если площади нет, распределяем поровну
|
||||
calculateDistribution();
|
||||
return;
|
||||
}
|
||||
|
||||
const data: Record<string, number> = {};
|
||||
selectedBuildings.forEach(building => {
|
||||
const area = building.passport?.general?.totalArea || 0;
|
||||
const proportion = area / totalArea;
|
||||
data[building.id] = Math.round(totalAmount * proportion * 100) / 100;
|
||||
});
|
||||
|
||||
// Корректируем остаток на первый элемент
|
||||
const sum = Object.values(data).reduce((s, v) => s + v, 0);
|
||||
const remainder = totalAmount - sum;
|
||||
if (remainder !== 0 && selectedBuildings.length > 0) {
|
||||
data[selectedBuildings[0].id] = (data[selectedBuildings[0].id] || 0) + remainder;
|
||||
}
|
||||
|
||||
onDistributionChange('by_area', data);
|
||||
setManualAmounts(data);
|
||||
} else if (method === 'manual') {
|
||||
// Для ручного ввода просто обновляем данные
|
||||
onDistributionChange('manual', manualAmounts);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualAmountChange = (id: string, amount: number) => {
|
||||
const newAmounts = { ...manualAmounts, [id]: amount };
|
||||
setManualAmounts(newAmounts);
|
||||
onDistributionChange('manual', newAmounts);
|
||||
};
|
||||
|
||||
const getItemName = (id: string): string => {
|
||||
if (purposeType === 'building') {
|
||||
const building = buildings.find(b => b.id === id);
|
||||
return building?.passport?.address || id;
|
||||
} else {
|
||||
const district = districts.find(d => d.id === id);
|
||||
return district?.name || id;
|
||||
}
|
||||
};
|
||||
|
||||
const getItemArea = (id: string): number => {
|
||||
if (purposeType === 'building') {
|
||||
const building = buildings.find(b => b.id === id);
|
||||
return building?.passport?.general?.totalArea || 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-center text-slate-500">Загрузка...</div>;
|
||||
}
|
||||
|
||||
const items = purposeType === 'building' ? selectedBuildingIds : selectedDistrictIds;
|
||||
const currentSum = Object.values(manualAmounts).reduce((sum, val) => sum + val, 0);
|
||||
const difference = totalAmount - currentSum;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calculator className="w-5 h-5 text-primary-600" />
|
||||
<h4 className="font-bold text-slate-800">Распределение суммы</h4>
|
||||
</div>
|
||||
|
||||
{/* Выбор метода распределения */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
onClick={() => setMethod('equal')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
method === 'equal'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Equal className="w-5 h-5 mx-auto mb-1" />
|
||||
<span className="text-xs font-medium">Поровну</span>
|
||||
</button>
|
||||
{purposeType === 'building' && (
|
||||
<button
|
||||
onClick={() => setMethod('by_area')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
method === 'by_area'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Ruler className="w-5 h-5 mx-auto mb-1" />
|
||||
<span className="text-xs font-medium">По площади</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setMethod('manual')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
method === 'manual'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="w-5 h-5 mx-auto mb-1" />
|
||||
<span className="text-xs font-medium">Вручную</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Таблица распределения */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{items.map(id => (
|
||||
<div key={id} className="flex items-center justify-between gap-4 p-2 bg-white rounded border border-slate-200">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{getItemName(id)}</p>
|
||||
{method === 'by_area' && purposeType === 'building' && (
|
||||
<p className="text-xs text-slate-500">
|
||||
Площадь: {getItemArea(id).toLocaleString('ru-RU')} м²
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={manualAmounts[id]?.toFixed(2) || '0.00'}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value) || 0;
|
||||
handleManualAmountChange(id, value);
|
||||
}}
|
||||
disabled={method !== 'manual'}
|
||||
className="w-32 px-2 py-1 text-sm border border-slate-300 rounded focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-slate-100 disabled:text-slate-500"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
<span className="text-sm text-slate-600 font-medium">₽</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Итого и разница */}
|
||||
<div className="mt-4 pt-4 border-t border-slate-300 space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Итого распределено:</span>
|
||||
<span className="font-bold text-slate-800">{currentSum.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Общая сумма:</span>
|
||||
<span className="font-bold text-slate-800">{totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽</span>
|
||||
</div>
|
||||
{Math.abs(difference) > 0.01 && (
|
||||
<div className={`flex justify-between text-sm font-bold ${
|
||||
difference > 0 ? 'text-red-600' : 'text-emerald-600'
|
||||
}`}>
|
||||
<span>Разница:</span>
|
||||
<span>{difference > 0 ? '+' : ''}{difference.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
101
components/finance/InvoiceRegistry.tsx
Executable file
101
components/finance/InvoiceRegistry.tsx
Executable file
@@ -0,0 +1,101 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Invoice, InvoiceStatus } from '../../types';
|
||||
import { FileText, FileSearch, AlertCircle, FileCheck, Check, X, MessageSquareQuote, CalendarPlus } from 'lucide-react';
|
||||
|
||||
const StatusBadge: React.FC<{ status: InvoiceStatus }> = ({ status }) => {
|
||||
const config: Record<InvoiceStatus, { label: string, color: string, bg: string }> = {
|
||||
draft: { label: 'Черновик', color: 'text-slate-500', bg: 'bg-slate-100' },
|
||||
pending_approval: { label: 'На согл.', color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
clarification: { label: 'Уточнение', color: 'text-purple-600', bg: 'bg-purple-50' },
|
||||
approved: { label: 'Согласован', color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
scheduled: { label: 'В графике', color: 'text-indigo-600', bg: 'bg-indigo-50' },
|
||||
paid: { label: 'Оплачен', color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
rejected: { label: 'Отказ', color: 'text-red-600', bg: 'bg-red-50' },
|
||||
overdue: { label: 'Просрочен', color: 'text-white', bg: 'bg-red-500' },
|
||||
};
|
||||
const s = config[status] || config.draft;
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${s.bg} ${s.color}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const InvoiceRegistry: React.FC<{ invoices: Invoice[], onUpdateStatus: (id: string, s: InvoiceStatus, extra?: any) => void }> = ({ invoices, onUpdateStatus }) => {
|
||||
// Show only "inbox" invoices: draft, pending, clarification, approved
|
||||
const registryInvoices = invoices.filter(i => ['draft', 'pending_approval', 'clarification', 'approved', 'rejected'].includes(i.status));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden animate-fade-in">
|
||||
<div className="p-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="font-black text-slate-700 text-[10px] uppercase tracking-widest">Реестр согласования счетов</h3>
|
||||
<div className="flex gap-2">
|
||||
<button className="p-1.5 bg-white border border-slate-200 rounded-lg text-slate-400"><FileSearch className="w-4 h-4"/></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{registryInvoices.length === 0 && <div className="p-10 text-center text-slate-400 text-sm">Новых счетов нет</div>}
|
||||
{registryInvoices.map(inv => (
|
||||
<div key={inv.id} className="p-4 flex flex-col md:flex-row md:items-center justify-between hover:bg-slate-50 transition-colors group gap-4">
|
||||
<div className="flex gap-4 items-center flex-1">
|
||||
<div className={`p-3 rounded-xl ${inv.status === 'approved' ? 'bg-blue-50 text-blue-600' : 'bg-slate-50 text-slate-400'}`}>
|
||||
<FileText className="w-5 h-5"/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<StatusBadge status={inv.status}/>
|
||||
{inv.priority === 'high' && <span className="text-[9px] font-black text-red-500 uppercase">Срочно</span>}
|
||||
</div>
|
||||
<p className="text-sm font-bold text-slate-800">{inv.contractorName}</p>
|
||||
<p className="text-[10px] text-slate-500 font-medium">{inv.address} • {inv.serviceName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between md:justify-end gap-6">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-black text-slate-900">{inv.amount.toLocaleString()} ₽</p>
|
||||
<p className="text-[10px] text-slate-400 font-medium">{inv.date}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{inv.status === 'pending_approval' || inv.status === 'clarification' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onUpdateStatus(inv.id, 'approved')}
|
||||
className="p-2 bg-emerald-50 text-emerald-600 rounded-xl hover:bg-emerald-100 border border-emerald-100"
|
||||
title="Согласовать"
|
||||
>
|
||||
<Check className="w-4 h-4"/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onUpdateStatus(inv.id, 'clarification')}
|
||||
className="p-2 bg-purple-50 text-purple-600 rounded-xl hover:bg-purple-100 border border-purple-100"
|
||||
title="На уточнение"
|
||||
>
|
||||
<MessageSquareQuote className="w-4 h-4"/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onUpdateStatus(inv.id, 'rejected')}
|
||||
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 border border-red-100"
|
||||
title="Отказать"
|
||||
>
|
||||
<X className="w-4 h-4"/>
|
||||
</button>
|
||||
</>
|
||||
) : inv.status === 'approved' ? (
|
||||
<button
|
||||
onClick={() => onUpdateStatus(inv.id, 'scheduled', { scheduledDate: new Date().toISOString().split('T')[0] })}
|
||||
className="px-3 py-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 text-[10px] font-black uppercase flex items-center gap-1.5 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4"/> В график
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
515
components/finance/PaymentCalendar.tsx
Executable file
515
components/finance/PaymentCalendar.tsx
Executable file
@@ -0,0 +1,515 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
PaymentInvoice,
|
||||
PaymentCalendarEntry,
|
||||
PaymentDirection,
|
||||
FinanceAccount
|
||||
} from '../../types';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Wallet,
|
||||
History,
|
||||
Pause,
|
||||
XCircle,
|
||||
Plus,
|
||||
ArrowDownCircle,
|
||||
ArrowUpCircle,
|
||||
Banknote,
|
||||
Landmark
|
||||
} from 'lucide-react';
|
||||
import { PaymentCalendarEntryForm } from './PaymentCalendarEntryForm';
|
||||
import { PaymentStatusModal, PaymentStatusAction, PaymentStatusModalPayload } from './PaymentStatusModal';
|
||||
|
||||
type CalendarInterval = 'week' | 'month';
|
||||
|
||||
function getPeriodBounds(interval: CalendarInterval): { dateFrom: string; dateTo: string; label: string } {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
if (interval === 'week') {
|
||||
const day = now.getDay();
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() + mondayOffset);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
const dateFrom = monday.toISOString().split('T')[0];
|
||||
const dateTo = sunday.toISOString().split('T')[0];
|
||||
const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' };
|
||||
const label = `${monday.toLocaleDateString('ru-RU', options)} – ${sunday.toLocaleDateString('ru-RU', options)} ${year}`;
|
||||
return { dateFrom, dateTo, label };
|
||||
}
|
||||
const first = new Date(year, month, 1);
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const dateFrom = first.toISOString().split('T')[0];
|
||||
const dateTo = last.toISOString().split('T')[0];
|
||||
const label = first.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' });
|
||||
return { dateFrom, dateTo, label };
|
||||
}
|
||||
|
||||
interface PaymentCalendarProps {
|
||||
invoices?: Invoice[];
|
||||
paymentInvoices?: PaymentInvoice[];
|
||||
calendarEntries?: PaymentCalendarEntry[];
|
||||
onUpdateStatus?: (id: string, s: InvoiceStatus, extra?: any) => void;
|
||||
onUpdatePaymentInvoiceStatus?: (id: number, status: 'paid' | 'postponed' | 'cancelled', payload?: PaymentStatusModalPayload) => Promise<void>;
|
||||
onRefreshCalendar?: () => void;
|
||||
currentBalance: number;
|
||||
currentUserId: string;
|
||||
interval?: CalendarInterval;
|
||||
onIntervalChange?: (interval: CalendarInterval) => void;
|
||||
periodLabel?: string;
|
||||
}
|
||||
|
||||
type RowItem = {
|
||||
id: string;
|
||||
direction: PaymentDirection;
|
||||
amount: number;
|
||||
scheduledDate: string;
|
||||
contractorName: string;
|
||||
description: string;
|
||||
probability?: string;
|
||||
isCash?: boolean;
|
||||
status?: string;
|
||||
paymentInvoiceId?: number;
|
||||
isOld?: boolean;
|
||||
type: 'invoice' | 'entry';
|
||||
entryId?: number;
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
export const PaymentCalendar: React.FC<PaymentCalendarProps> = ({
|
||||
invoices = [],
|
||||
paymentInvoices = [],
|
||||
calendarEntries = [],
|
||||
onUpdateStatus,
|
||||
onUpdatePaymentInvoiceStatus,
|
||||
onRefreshCalendar,
|
||||
currentBalance,
|
||||
currentUserId,
|
||||
interval = 'month',
|
||||
onIntervalChange,
|
||||
periodLabel: periodLabelProp
|
||||
}) => {
|
||||
const [filter, setFilter] = useState<'all' | 'planned' | 'paid'>('all');
|
||||
const [directionFilter, setDirectionFilter] = useState<'all' | 'outgoing' | 'incoming'>('all');
|
||||
const [loading, setLoading] = useState<Record<number, boolean>>({});
|
||||
const [showEntryForm, setShowEntryForm] = useState(false);
|
||||
const [defaultFormDirection, setDefaultFormDirection] = useState<PaymentDirection>('outgoing');
|
||||
const [statusModal, setStatusModal] = useState<{ action: PaymentStatusAction; row: RowItem } | null>(null);
|
||||
const [financeAccounts, setFinanceAccounts] = useState<FinanceAccount[]>([]);
|
||||
|
||||
const { dateFrom, dateTo, label } = useMemo(() => getPeriodBounds(interval), [interval]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const list = await apiClient.get<FinanceAccount[]>('/finance/accounts');
|
||||
setFinanceAccounts(Array.isArray(list) ? list : []);
|
||||
} catch {
|
||||
setFinanceAccounts([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
const periodLabel = periodLabelProp ?? label;
|
||||
|
||||
const invoiceRows: RowItem[] = useMemo(() => {
|
||||
const invList = [
|
||||
...invoices.map((inv) => ({
|
||||
id: inv.id,
|
||||
direction: 'outgoing' as PaymentDirection,
|
||||
amount: Number(inv.amount) || 0,
|
||||
scheduledDate: inv.scheduledDate || '',
|
||||
contractorName: inv.contractorName,
|
||||
description: inv.serviceName,
|
||||
status: inv.status,
|
||||
isOld: true,
|
||||
type: 'invoice' as const,
|
||||
paymentInvoiceId: undefined
|
||||
})),
|
||||
...paymentInvoices.map((inv) => ({
|
||||
id: `pi-${inv.id}`,
|
||||
direction: 'outgoing' as PaymentDirection,
|
||||
amount: Number(inv.totalAmount) || 0,
|
||||
scheduledDate: inv.scheduledDate || '',
|
||||
contractorName: inv.contractorName,
|
||||
description: inv.serviceDescription,
|
||||
status: inv.status,
|
||||
isOld: false,
|
||||
type: 'invoice' as const,
|
||||
paymentInvoiceId: inv.id
|
||||
}))
|
||||
];
|
||||
return invList;
|
||||
}, [invoices, paymentInvoices]);
|
||||
|
||||
const entryRows: RowItem[] = useMemo(() => {
|
||||
const invoiceIds = new Set(paymentInvoices.map((pi) => pi.id));
|
||||
return calendarEntries
|
||||
.filter((e) => !e.paymentInvoiceId || !invoiceIds.has(e.paymentInvoiceId))
|
||||
.map((e) => ({
|
||||
id: `entry-${e.id}`,
|
||||
direction: e.direction,
|
||||
amount: Number(e.amount) || 0,
|
||||
scheduledDate: e.scheduledDate,
|
||||
contractorName: e.contractorName,
|
||||
description: e.description || e.category,
|
||||
probability: e.probability,
|
||||
isCash: e.isCash,
|
||||
type: 'entry' as const,
|
||||
entryId: e.id,
|
||||
currency: e.currency
|
||||
}));
|
||||
}, [calendarEntries, paymentInvoices]);
|
||||
|
||||
const allRows = useMemo(() => {
|
||||
const combined = [...invoiceRows, ...entryRows];
|
||||
return combined
|
||||
.filter((r) => {
|
||||
if (directionFilter !== 'all' && r.direction !== directionFilter) return false;
|
||||
if (filter === 'planned') {
|
||||
if (r.type === 'invoice') return r.status === 'scheduled' || r.status === 'overdue';
|
||||
return !r.scheduledDate ? false : true;
|
||||
}
|
||||
if (filter === 'paid') {
|
||||
if (r.type === 'invoice') return r.status === 'paid';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => (a.scheduledDate || '').localeCompare(b.scheduledDate || ''));
|
||||
}, [invoiceRows, entryRows, filter, directionFilter]);
|
||||
|
||||
const scheduledTotalOutgoing =
|
||||
invoiceRows.filter((i) => i.status === 'scheduled').reduce((s, i) => s + Number(i.amount) || 0, 0) +
|
||||
entryRows.filter((e) => e.direction === 'outgoing').reduce((s, e) => s + Number(e.amount) || 0, 0);
|
||||
const scheduledTotalIncoming = entryRows
|
||||
.filter((e) => e.direction === 'incoming')
|
||||
.reduce((s, e) => s + Number(e.amount) || 0, 0);
|
||||
|
||||
const handlePaymentActionClick = (row: RowItem, action: 'paid' | 'postponed' | 'cancelled') => {
|
||||
if (row.type !== 'invoice' || row.isOld) {
|
||||
if (onUpdateStatus) onUpdateStatus(row.id, action === 'paid' ? 'paid' : 'scheduled');
|
||||
return;
|
||||
}
|
||||
if (row.paymentInvoiceId && onUpdatePaymentInvoiceStatus) {
|
||||
setStatusModal({ action, row });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusModalConfirm = async (payload: PaymentStatusModalPayload) => {
|
||||
if (!statusModal || !statusModal.row.paymentInvoiceId || !onUpdatePaymentInvoiceStatus) return;
|
||||
const { row, action } = statusModal;
|
||||
const id = row.paymentInvoiceId!;
|
||||
setStatusModal(null);
|
||||
setLoading((prev) => ({ ...prev, [id]: true }));
|
||||
try {
|
||||
await onUpdatePaymentInvoiceStatus(id, action, payload);
|
||||
} catch (err) {
|
||||
console.error('Error updating payment invoice status:', err);
|
||||
alert('Ошибка при обновлении статуса');
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (entryId: number) => {
|
||||
if (!window.confirm('Удалить запись из календаря?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/finance/payment-calendar/entries/${entryId}`);
|
||||
onRefreshCalendar?.();
|
||||
} catch (err) {
|
||||
console.error('Error deleting entry:', err);
|
||||
alert('Ошибка удаления');
|
||||
}
|
||||
};
|
||||
|
||||
const probabilityLabel: Record<string, string> = {
|
||||
confirmed: '100%',
|
||||
high: '80%',
|
||||
medium: '50%',
|
||||
low: '30%'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl w-fit">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all ${filter === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('planned')}
|
||||
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all ${filter === 'planned' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
|
||||
>
|
||||
План
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('paid')}
|
||||
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all ${filter === 'paid' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
|
||||
>
|
||||
Оплачено
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl w-fit">
|
||||
<button
|
||||
onClick={() => setDirectionFilter('all')}
|
||||
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${directionFilter === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDirectionFilter('outgoing')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${directionFilter === 'outgoing' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
|
||||
>
|
||||
<ArrowUpCircle className="w-3.5 h-3.5" /> Расходы
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDirectionFilter('incoming')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${directionFilter === 'incoming' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
|
||||
>
|
||||
<ArrowDownCircle className="w-3.5 h-3.5" /> Поступления
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{onIntervalChange && (
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
<button
|
||||
onClick={() => onIntervalChange('week')}
|
||||
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${interval === 'week' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
|
||||
>
|
||||
Неделя
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onIntervalChange('month')}
|
||||
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${interval === 'month' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
|
||||
>
|
||||
Месяц
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{financeAccounts.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{financeAccounts.filter((a) => a.type === 'bank').map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-2 px-3 py-2 bg-slate-100 rounded-xl border border-slate-200">
|
||||
<Landmark className="w-4 h-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-500 font-bold uppercase">{a.name}</p>
|
||||
<p className="text-sm font-black text-slate-800">{Number(a.balance).toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{financeAccounts.filter((a) => a.type === 'cash').map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-2 px-3 py-2 bg-amber-50 rounded-xl border border-amber-200">
|
||||
<Banknote className="w-4 h-4 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-[10px] text-amber-700 font-bold uppercase">{a.name}</p>
|
||||
<p className="text-sm font-black text-amber-800">{Number(a.balance).toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-black uppercase">Итого: {financeAccounts.reduce((s, a) => s + Number(a.balance), 0).toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-[10px] text-slate-400 font-black uppercase tracking-widest">Текущий остаток</p>
|
||||
<p className="text-sm font-black text-slate-800">{currentBalance.toLocaleString()} ₽</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setDefaultFormDirection('outgoing');
|
||||
setShowEntryForm(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-900 text-white rounded-xl text-xs font-bold hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-4 bg-slate-900 text-white flex flex-wrap justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-white/10 rounded-lg">
|
||||
<Calendar className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold leading-none uppercase tracking-tighter">Платежный календарь</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-1 uppercase font-bold tracking-widest">{periodLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-black">{Number(scheduledTotalOutgoing).toLocaleString('ru-RU')} ₽</p>
|
||||
<p className="text-[10px] text-slate-400 uppercase font-bold">К оплате (план)</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-black text-emerald-300">+{Number(scheduledTotalIncoming).toLocaleString('ru-RU')} ₽</p>
|
||||
<p className="text-[10px] text-slate-400 uppercase font-bold">Ожидаемые поступления</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{allRows.length === 0 && (
|
||||
<div className="p-12 text-center text-slate-400 italic">Нет записей за выбранный период</div>
|
||||
)}
|
||||
{allRows.map((row) => {
|
||||
const isPaid = row.type === 'invoice' && row.status === 'paid';
|
||||
const isOverdue = row.type === 'invoice' && row.status === 'overdue';
|
||||
const isIncoming = row.direction === 'incoming';
|
||||
const day = row.scheduledDate?.split('-')[2] || '??';
|
||||
const monthShort = row.scheduledDate
|
||||
? new Date(row.scheduledDate + 'T12:00:00').toLocaleDateString('ru-RU', { month: 'short' })
|
||||
: '—';
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={`p-4 flex items-center gap-4 hover:bg-slate-50 transition-colors group ${isPaid ? 'bg-slate-50/50' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`p-2 rounded-xl text-center min-w-[50px] border ${
|
||||
isPaid
|
||||
? 'bg-emerald-50 border-emerald-100 text-emerald-600'
|
||||
: isOverdue
|
||||
? 'bg-red-50 border-red-100 text-red-600'
|
||||
: isIncoming
|
||||
? 'bg-emerald-50/50 border-emerald-100 text-emerald-700'
|
||||
: 'bg-white border-slate-200 text-slate-500 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-black leading-none">{day}</p>
|
||||
<p className="text-[9px] font-bold uppercase mt-1">{monthShort}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
{isIncoming && <ArrowDownCircle className="w-3.5 h-3.5 text-emerald-500 shrink-0" />}
|
||||
{row.isCash && <Banknote className="w-3.5 h-3.5 text-amber-600 shrink-0" title="Наличные" />}
|
||||
<p
|
||||
className={`text-sm font-bold truncate ${isPaid ? 'text-slate-500' : 'text-slate-800'}`}
|
||||
>
|
||||
{row.contractorName || '—'}
|
||||
</p>
|
||||
{row.type === 'invoice' && isPaid && <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />}
|
||||
{row.probability && row.probability !== 'confirmed' && (
|
||||
<span className="text-[9px] font-bold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
{probabilityLabel[row.probability] || row.probability}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 truncate">{row.description || '—'}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right flex items-center gap-4">
|
||||
<div>
|
||||
<p
|
||||
className={`text-sm font-black ${isPaid ? 'text-slate-400' : isIncoming ? 'text-emerald-700' : 'text-slate-900'}`}
|
||||
>
|
||||
{isIncoming ? '+' : ''}
|
||||
{row.amount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{' '}
|
||||
{row.currency === 'RUB' || !row.currency ? '₽' : row.currency}
|
||||
</p>
|
||||
{!isPaid && !isIncoming && row.type === 'invoice' && (
|
||||
<p className="text-[8px] text-slate-400 font-bold uppercase">
|
||||
Остаток после: {((currentBalance - row.amount) / 1000).toFixed(0)}k
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{row.type === 'entry' && row.entryId && (
|
||||
<button
|
||||
onClick={() => handleDeleteEntry(row.entryId!)}
|
||||
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
|
||||
title="Удалить запись"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{row.type === 'invoice' &&
|
||||
(row.status !== 'paid' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handlePaymentActionClick(row, 'paid')}
|
||||
disabled={loading[row.paymentInvoiceId!]}
|
||||
className="p-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 disabled:opacity-50"
|
||||
title="Подтвердить оплату"
|
||||
>
|
||||
<Wallet className="w-4 h-4" />
|
||||
</button>
|
||||
{row.paymentInvoiceId && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handlePaymentActionClick(row, 'postponed')}
|
||||
disabled={loading[row.paymentInvoiceId!]}
|
||||
className="p-2 bg-orange-100 text-orange-600 rounded-lg hover:bg-orange-200 disabled:opacity-50"
|
||||
title="Отложить"
|
||||
>
|
||||
<Pause className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePaymentActionClick(row, 'cancelled')}
|
||||
disabled={loading[row.paymentInvoiceId!]}
|
||||
className="p-2 bg-red-100 text-red-600 rounded-lg hover:bg-red-200 disabled:opacity-50"
|
||||
title="Отменить"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handlePaymentActionClick(row, 'postponed')}
|
||||
disabled={loading[row.paymentInvoiceId!]}
|
||||
className="p-2 bg-white text-slate-300 rounded-lg hover:text-indigo-600 disabled:opacity-50"
|
||||
title="Вернуть в план"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEntryForm && (
|
||||
<PaymentCalendarEntryForm
|
||||
currentUserId={currentUserId}
|
||||
defaultDirection={defaultFormDirection}
|
||||
onSave={async () => {
|
||||
setShowEntryForm(false);
|
||||
onRefreshCalendar?.();
|
||||
}}
|
||||
onCancel={() => setShowEntryForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{statusModal && (
|
||||
<PaymentStatusModal
|
||||
action={statusModal.action}
|
||||
invoiceLabel={`${statusModal.row.contractorName} — ${statusModal.row.amount.toLocaleString('ru-RU')} ₽`}
|
||||
onConfirm={handleStatusModalConfirm}
|
||||
onCancel={() => setStatusModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
317
components/finance/PaymentCalendarEntryForm.tsx
Executable file
317
components/finance/PaymentCalendarEntryForm.tsx
Executable file
@@ -0,0 +1,317 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
PaymentCalendarEntry,
|
||||
PaymentDirection,
|
||||
PaymentType,
|
||||
PaymentProbability,
|
||||
PaymentCategory
|
||||
} from '../../types';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
import { X, Save, Calendar, Wallet, ArrowDownCircle, ArrowUpCircle, Banknote } from 'lucide-react';
|
||||
|
||||
interface PaymentCalendarEntryFormProps {
|
||||
entry?: PaymentCalendarEntry | null;
|
||||
currentUserId: string;
|
||||
defaultDirection?: PaymentDirection;
|
||||
onSave: (entry: Partial<PaymentCalendarEntry>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const DIRECTION_OPTIONS: { value: PaymentDirection; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'outgoing', label: 'Расход', icon: <ArrowUpCircle className="w-4 h-4" /> },
|
||||
{ value: 'incoming', label: 'Поступление', icon: <ArrowDownCircle className="w-4 h-4" /> }
|
||||
];
|
||||
|
||||
const TYPE_OPTIONS: { value: PaymentType; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'manual', label: 'Без счета', icon: <Wallet className="w-4 h-4" /> },
|
||||
{ value: 'cash', label: 'Наличные', icon: <Banknote className="w-4 h-4" /> },
|
||||
{ value: 'invoice', label: 'По счету', icon: <Wallet className="w-4 h-4" /> }
|
||||
];
|
||||
|
||||
const PROBABILITY_OPTIONS: { value: PaymentProbability; label: string }[] = [
|
||||
{ value: 'confirmed', label: '100% (подтверждено)' },
|
||||
{ value: 'high', label: '80%' },
|
||||
{ value: 'medium', label: '50%' },
|
||||
{ value: 'low', label: '30%' }
|
||||
];
|
||||
|
||||
const CURRENCIES = [{ value: 'RUB', label: '₽ RUB' }, { value: 'USD', label: '$ USD' }, { value: 'EUR', label: '€ EUR' }];
|
||||
|
||||
export const PaymentCalendarEntryForm: React.FC<PaymentCalendarEntryFormProps> = ({
|
||||
entry,
|
||||
currentUserId,
|
||||
defaultDirection = 'outgoing',
|
||||
onSave,
|
||||
onCancel
|
||||
}) => {
|
||||
const [direction, setDirection] = useState<PaymentDirection>(entry?.direction ?? defaultDirection);
|
||||
const [type, setType] = useState<PaymentType>(entry?.type ?? 'manual');
|
||||
const [category, setCategory] = useState(entry?.category ?? '');
|
||||
const [contractorName, setContractorName] = useState(entry?.contractorName ?? '');
|
||||
const [description, setDescription] = useState(entry?.description ?? '');
|
||||
const [amount, setAmount] = useState(entry?.amount ?? 0);
|
||||
const [scheduledDate, setScheduledDate] = useState(
|
||||
entry?.scheduledDate ?? new Date().toISOString().split('T')[0]
|
||||
);
|
||||
const [probability, setProbability] = useState<PaymentProbability>(entry?.probability ?? 'confirmed');
|
||||
const [currency, setCurrency] = useState(entry?.currency ?? 'RUB');
|
||||
const [isCash, setIsCash] = useState(entry?.isCash ?? false);
|
||||
const [notes, setNotes] = useState(entry?.notes ?? '');
|
||||
const [categories, setCategories] = useState<PaymentCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const list = await apiClient.get<PaymentCategory[]>(
|
||||
`/finance/payment-calendar/categories?direction=${direction}`
|
||||
);
|
||||
setCategories(Array.isArray(list) ? list : []);
|
||||
} catch {
|
||||
setCategories([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [direction]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (amount <= 0) {
|
||||
setError('Укажите сумму больше нуля');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
if (entry?.id) {
|
||||
await apiClient.put(`/finance/payment-calendar/entries/${entry.id}`, {
|
||||
direction,
|
||||
type,
|
||||
category,
|
||||
description,
|
||||
amount,
|
||||
scheduledDate,
|
||||
probability,
|
||||
currency,
|
||||
isCash: type === 'cash' || isCash,
|
||||
contractorName,
|
||||
notes
|
||||
});
|
||||
} else {
|
||||
await apiClient.post('/finance/payment-calendar/entries', {
|
||||
createdBy: currentUserId,
|
||||
direction,
|
||||
type,
|
||||
category,
|
||||
description,
|
||||
amount,
|
||||
scheduledDate,
|
||||
probability,
|
||||
currency,
|
||||
isCash: type === 'cash' || isCash,
|
||||
contractorName,
|
||||
notes
|
||||
});
|
||||
}
|
||||
await onSave({} as Partial<PaymentCalendarEntry>);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Ошибка сохранения');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-4 border-b border-slate-200 flex justify-between items-center sticky top-0 bg-white">
|
||||
<h3 className="font-bold text-slate-800">
|
||||
{entry?.id ? 'Редактировать запись' : 'Добавить запись в календарь'}
|
||||
</h3>
|
||||
<button type="button" onClick={onCancel} className="p-2 rounded-lg hover:bg-slate-100 text-slate-500">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Направление</label>
|
||||
<div className="flex gap-2">
|
||||
{DIRECTION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setDirection(opt.value)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium border transition-colors ${
|
||||
direction === opt.value
|
||||
? opt.value === 'incoming'
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
||||
: 'bg-slate-900 text-white border-slate-900'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Тип</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setType(opt.value);
|
||||
if (opt.value === 'cash') setIsCash(true);
|
||||
else if (type === 'cash') setIsCash(false);
|
||||
}}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium border transition-colors ${
|
||||
type === opt.value ? 'bg-primary-50 border-primary-200 text-primary-700' : 'bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Статья</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
>
|
||||
<option value="">— не выбрано —</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.name}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Контрагент *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contractorName}
|
||||
onChange={(e) => setContractorName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
placeholder="Название контрагента"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Описание</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
placeholder="Краткое описание"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Сумма *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={amount || ''}
|
||||
onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Валюта</label>
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Дата в графике *</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Вероятность</label>
|
||||
<select
|
||||
value={probability}
|
||||
onChange={(e) => setProbability(e.target.value as PaymentProbability)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
>
|
||||
{PROBABILITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Примечания</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm resize-none"
|
||||
placeholder="Необязательно"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-xl font-medium text-sm hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{entry?.id ? 'Сохранить' : 'Добавить'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-slate-200 rounded-xl font-medium text-sm text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
729
components/finance/PaymentInvoiceDetail.tsx
Executable file
729
components/finance/PaymentInvoiceDetail.tsx
Executable file
@@ -0,0 +1,729 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { PaymentInvoice, UserRole } from '../../types';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
import { ArrowLeft, Check, X, Calendar, Clock, FileText, Building2, MapPin, Briefcase, Users, Calculator, History, CheckCircle2, Paperclip, Download, PartyPopper, Wallet, Banknote } from 'lucide-react';
|
||||
import { PaymentStatusModal, PaymentStatusModalPayload } from './PaymentStatusModal';
|
||||
|
||||
interface PaymentInvoiceDetailProps {
|
||||
invoice: PaymentInvoice;
|
||||
currentUserId: string;
|
||||
onBack: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
const StatusConfig: Record<PaymentInvoice['status'], { label: string; color: string; bg: string }> = {
|
||||
draft: { label: 'Черновик', color: 'text-slate-500', bg: 'bg-slate-100' },
|
||||
pending_manager_approval: { label: 'На согласовании у руководителя', color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
pending_finance_manager_approval: { label: 'На согласовании у финансового руководителя', color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
approved: { label: 'Согласован', color: 'text-indigo-600', bg: 'bg-indigo-50' },
|
||||
scheduled: { label: 'В графике платежей', color: 'text-purple-600', bg: 'bg-purple-50' },
|
||||
paid: { label: 'Оплачен', color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
postponed: { label: 'Отложен', color: 'text-orange-600', bg: 'bg-orange-50' },
|
||||
cancelled: { label: 'Отменен', color: 'text-red-600', bg: 'bg-red-50' },
|
||||
rejected: { label: 'Отклонен', color: 'text-red-600', bg: 'bg-red-50' },
|
||||
completed: { label: 'Выполнено', color: 'text-green-600', bg: 'bg-green-50' }
|
||||
};
|
||||
|
||||
const PurposeTypeIcons: Record<PaymentInvoice['purposeType'], any> = {
|
||||
building: Building2,
|
||||
district: MapPin,
|
||||
legal: Briefcase,
|
||||
office: FileText,
|
||||
hr: Users,
|
||||
event: PartyPopper,
|
||||
other: FileText
|
||||
};
|
||||
|
||||
export const PaymentInvoiceDetail: React.FC<PaymentInvoiceDetailProps> = ({
|
||||
invoice,
|
||||
currentUserId,
|
||||
onBack,
|
||||
onUpdate
|
||||
}) => {
|
||||
const [userRoles, setUserRoles] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState('');
|
||||
const [approvalComment, setApprovalComment] = useState('');
|
||||
const [scheduledDate, setScheduledDate] = useState(invoice.scheduledDate || '');
|
||||
const [closingDocsUpdating, setClosingDocsUpdating] = useState(false);
|
||||
const closingDocsFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [closingDocsUploading, setClosingDocsUploading] = useState(false);
|
||||
const [showPayModal, setShowPayModal] = useState<'default' | 'cash' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserRoles();
|
||||
}, [currentUserId]);
|
||||
|
||||
const fetchUserRoles = async () => {
|
||||
try {
|
||||
const roles = await apiClient.get<UserRole[]>(`/finance/user-roles?userId=${currentUserId}`);
|
||||
setUserRoles(roles.map(r => r.role));
|
||||
} catch (err) {
|
||||
console.error('Error fetching user roles:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const canApprove = () => {
|
||||
// Проверка прав на согласование
|
||||
const TOP_MANAGEMENT_ROLES = ['finance_director', 'director', 'top_management'];
|
||||
const statusConfig: Record<string, { role?: string }> = {
|
||||
pending_manager_approval: { role: 'manager' },
|
||||
pending_finance_manager_approval: { role: 'finance_manager' }
|
||||
};
|
||||
const config = statusConfig[invoice.status];
|
||||
if (!config || !config.role) return false;
|
||||
|
||||
// Если требуется роль manager, но пользователь из высшего звена - может согласовать
|
||||
if (config.role === 'manager' && userRoles.some(role => TOP_MANAGEMENT_ROLES.includes(role))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return userRoles.includes(config.role);
|
||||
};
|
||||
|
||||
const canSchedule = () => {
|
||||
// Ставить в график может финансист ИЛИ финансовый руководитель.
|
||||
// В демо, если ролей нет, считаем текущего пользователя финансовым блоком.
|
||||
const canScheduleRole =
|
||||
userRoles.includes('financier') ||
|
||||
userRoles.includes('finance_manager') ||
|
||||
userRoles.length === 0;
|
||||
|
||||
if (!canScheduleRole) return false;
|
||||
|
||||
// Для постоплаты можем поставить в график после согласования или после выполнения
|
||||
if (invoice.paymentFormat === 'postpayment') {
|
||||
return ['approved', 'completed'].includes(invoice.status);
|
||||
}
|
||||
|
||||
// Для предоплаты и аванса ставим в график первый платеж после согласования
|
||||
if (invoice.paymentFormat === 'prepayment' || invoice.paymentFormat === 'advance') {
|
||||
return invoice.status === 'approved';
|
||||
}
|
||||
|
||||
return invoice.status === 'approved';
|
||||
};
|
||||
|
||||
const canMarkCompleted = () => {
|
||||
if (invoice.paymentFormat === 'postpayment') {
|
||||
return ['approved', 'scheduled'].includes(invoice.status) && !invoice.isCompleted;
|
||||
} else if (invoice.paymentFormat === 'prepayment' || invoice.paymentFormat === 'advance') {
|
||||
return invoice.status === 'paid' && !invoice.isCompleted;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiClient.post(`/finance/payment-invoices/${invoice.id}/approve`, {
|
||||
userId: currentUserId,
|
||||
comment: approvalComment
|
||||
});
|
||||
setApprovalComment('');
|
||||
onUpdate();
|
||||
} catch (err) {
|
||||
console.error('Error approving invoice:', err);
|
||||
alert('Ошибка при согласовании счета');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectionReason.trim()) {
|
||||
alert('Укажите причину отклонения');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiClient.post(`/finance/payment-invoices/${invoice.id}/reject`, {
|
||||
userId: currentUserId,
|
||||
reason: rejectionReason
|
||||
});
|
||||
setRejectionReason('');
|
||||
onUpdate();
|
||||
} catch (err) {
|
||||
console.error('Error rejecting invoice:', err);
|
||||
alert('Ошибка при отклонении счета');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchedule = async () => {
|
||||
if (!scheduledDate) {
|
||||
alert('Укажите дату для графика платежей');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiClient.post(`/finance/payment-invoices/${invoice.id}/schedule`, {
|
||||
userId: currentUserId,
|
||||
scheduledDate
|
||||
});
|
||||
onUpdate();
|
||||
} catch (err) {
|
||||
console.error('Error scheduling invoice:', err);
|
||||
alert('Ошибка при постановке в график');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkCompleted = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiClient.post(`/finance/payment-invoices/${invoice.id}/mark-completed`);
|
||||
onUpdate();
|
||||
} catch (err) {
|
||||
console.error('Error marking as completed:', err);
|
||||
alert('Ошибка при отметке выполнения');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosingDocsReceived = async () => {
|
||||
try {
|
||||
setClosingDocsUpdating(true);
|
||||
await apiClient.post(`/finance/payment-invoices/${invoice.id}/closing-docs`, {
|
||||
received: true
|
||||
});
|
||||
onUpdate();
|
||||
} catch (err) {
|
||||
console.error('Error updating closing docs flag:', err);
|
||||
alert('Ошибка при отметке закрывающих документов');
|
||||
} finally {
|
||||
setClosingDocsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkPaid = async (payload: PaymentStatusModalPayload) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiClient.post(`/finance/payment-invoices/${invoice.id}/update-payment-status`, {
|
||||
status: 'paid',
|
||||
paymentDate: payload.paymentDate,
|
||||
paymentRef: payload.paymentRef,
|
||||
isCash: payload.isCash
|
||||
});
|
||||
setShowPayModal(null);
|
||||
onUpdate();
|
||||
} catch (err) {
|
||||
console.error('Error marking invoice as paid:', err);
|
||||
alert('Ошибка при отметке оплаты');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosingDocsFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setClosingDocsUploading(true);
|
||||
|
||||
// Загружаем файл на сервер (как для обычных счетов)
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const uploadResponse = await fetch('/api/finance/payment-invoices/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Ошибка загрузки файла');
|
||||
}
|
||||
|
||||
const data = await uploadResponse.json();
|
||||
const fileInfo = data.file;
|
||||
|
||||
// Привязываем файл к счету как закрывающий документ и помечаем, что закрывашки получены
|
||||
await apiClient.post(`/finance/payment-invoices/${invoice.id}/closing-docs`, {
|
||||
received: true,
|
||||
files: [fileInfo]
|
||||
});
|
||||
|
||||
onUpdate();
|
||||
} catch (err) {
|
||||
console.error('Error uploading closing docs file:', err);
|
||||
alert('Ошибка при загрузке файла закрывающих документов');
|
||||
} finally {
|
||||
setClosingDocsUploading(false);
|
||||
if (closingDocsFileInputRef.current) {
|
||||
closingDocsFileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const PurposeIcon = PurposeTypeIcons[invoice.purposeType] || FileText;
|
||||
const statusInfo = StatusConfig[invoice.status];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Назад к списку
|
||||
</button>
|
||||
<div className={`px-3 py-1 rounded-lg ${statusInfo.bg} ${statusInfo.color}`}>
|
||||
<span className="text-sm font-bold">{statusInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основная информация */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-1">Счет № {invoice.invoiceNumber}</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Создан {new Date(invoice.createdAt).toLocaleDateString('ru-RU')} пользователем {invoice.createdBy}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Назначение */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<PurposeIcon className="w-4 h-4" />
|
||||
Назначение
|
||||
</h3>
|
||||
<p className="text-slate-800">
|
||||
{invoice.purposeType === 'building' && invoice.purposeBuildingIds.length > 0 && (
|
||||
<>Дом ({invoice.purposeBuildingIds.length} шт.)</>
|
||||
)}
|
||||
{invoice.purposeType === 'district' && invoice.purposeDistrictIds.length > 0 && (
|
||||
<>Участок ({invoice.purposeDistrictIds.length} шт.)</>
|
||||
)}
|
||||
{['legal', 'office', 'hr'].includes(invoice.purposeType) && (
|
||||
<>{invoice.purposeType === 'legal' ? 'Юристы' : invoice.purposeType === 'office' ? 'Офис' : 'HR'}</>
|
||||
)}
|
||||
{invoice.purposeType === 'event' && (invoice.purposeDescription || 'Мероприятие')}
|
||||
{invoice.purposeType === 'other' && invoice.purposeDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Формат оплаты */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<Calculator className="w-4 h-4" />
|
||||
Формат оплаты
|
||||
</h3>
|
||||
<p className="text-slate-800">
|
||||
{invoice.paymentFormat === 'prepayment' && 'Предоплата'}
|
||||
{invoice.paymentFormat === 'postpayment' && 'Постоплата'}
|
||||
{invoice.paymentFormat === 'advance' && 'Аванс'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Подрядчик */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-2">Подрядчик</h3>
|
||||
<p className="text-slate-800 font-medium">{invoice.contractorName}</p>
|
||||
{invoice.contractorInn && (
|
||||
<p className="text-sm text-slate-500">ИНН: {invoice.contractorInn}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Сумма */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-2">Сумма</h3>
|
||||
<p className="text-2xl font-black text-slate-900">
|
||||
{invoice.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Услуги или ТМЦ */}
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-2">
|
||||
{invoice.itemType === 'service' ? 'Услуги' : 'ТМЦ (товарно-материальные ценности)'}
|
||||
</h3>
|
||||
|
||||
{invoice.itemType === 'service' && invoice.serviceItems && invoice.serviceItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{invoice.serviceItems.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-2 bg-slate-50 rounded-lg">
|
||||
<span className="text-slate-800 font-medium">{item.name}</span>
|
||||
<span className="text-slate-900 font-bold">
|
||||
{item.amount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : invoice.itemType === 'materials' && invoice.materialItems && invoice.materialItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{invoice.materialItems.map((item, index) => (
|
||||
<div key={index} className="p-2 bg-slate-50 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-slate-800 font-medium">{item.name}</span>
|
||||
<span className="text-slate-900 font-bold">
|
||||
{item.amount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{item.quantity} {item.unit} × {item.pricePerUnit.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-800">{invoice.serviceDescription || 'Нет данных'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Даты */}
|
||||
<div className="mt-6 pt-6 border-t border-slate-200 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{invoice.scheduledDate && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Дата в графике</p>
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{new Date(invoice.scheduledDate).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{invoice.paymentDate && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Дата оплаты</p>
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{new Date(invoice.paymentDate).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{invoice.status === 'paid' && invoice.paymentRef && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Номер платежки</p>
|
||||
<p className="text-sm font-medium text-slate-800">{invoice.paymentRef}</p>
|
||||
</div>
|
||||
)}
|
||||
{invoice.status === 'paid' && invoice.isCash && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Способ оплаты</p>
|
||||
<p className="text-sm font-medium text-amber-700 flex items-center gap-1">
|
||||
<Banknote className="w-4 h-4" /> Оплата наличными
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{invoice.isCompleted && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Статус выполнения</p>
|
||||
<p className="text-sm font-medium text-emerald-600 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Выполнено
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(invoice.paymentFormat === 'prepayment' || invoice.paymentFormat === 'advance') && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Закрывающие документы</p>
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{invoice.closingDocsReceived ? 'Получены' : 'Не получены'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Загруженные файлы (физические счета) */}
|
||||
{invoice.fileUrls && invoice.fileUrls.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-4 flex items-center gap-2">
|
||||
<Paperclip className="w-4 h-4" />
|
||||
Физические счета
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{invoice.fileUrls.map((fileUrl, index) => {
|
||||
// fileUrl может быть строкой или объектом с информацией о файле
|
||||
const fileInfo = typeof fileUrl === 'string'
|
||||
? { url: fileUrl, filename: fileUrl.split('/').pop() || 'Файл' }
|
||||
: fileUrl;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<FileText className="w-4 h-4 text-slate-500 flex-shrink-0" />
|
||||
<span className="text-sm text-slate-700 truncate" title={fileInfo.filename || fileInfo.url}>
|
||||
{fileInfo.filename || fileInfo.url}
|
||||
</span>
|
||||
{fileInfo.size && (
|
||||
<span className="text-xs text-slate-500 flex-shrink-0">
|
||||
({(fileInfo.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={fileInfo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Скачать
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Файлы закрывающих документов (сканы/фото) */}
|
||||
{invoice.closingDocsFiles && invoice.closingDocsFiles.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-4 flex items-center gap-2">
|
||||
<Paperclip className="w-4 h-4" />
|
||||
Закрывающие документы
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{invoice.closingDocsFiles.map((file: any, index: number) => {
|
||||
const fileInfo = file || {};
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<FileText className="w-4 h-4 text-slate-500 flex-shrink-0" />
|
||||
<span className="text-sm text-slate-700 truncate" title={fileInfo.filename || fileInfo.url}>
|
||||
{fileInfo.filename || fileInfo.url || 'Файл'}
|
||||
</span>
|
||||
{fileInfo.size && (
|
||||
<span className="text-xs text-slate-500 flex-shrink-0">
|
||||
({(fileInfo.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{fileInfo.url && (
|
||||
<a
|
||||
href={fileInfo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Скачать
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Примечания */}
|
||||
{invoice.notes && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-2">Примечания</h3>
|
||||
<p className="text-slate-800">{invoice.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* История согласований */}
|
||||
{invoice.approvalHistory && invoice.approvalHistory.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-4 flex items-center gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
История согласований
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{invoice.approvalHistory.map((entry, index) => (
|
||||
<div key={index} className="flex items-start gap-3 p-3 bg-slate-50 rounded-lg">
|
||||
<div className={`p-1.5 rounded ${
|
||||
entry.action === 'approve' ? 'bg-emerald-100 text-emerald-600' :
|
||||
entry.action === 'reject' ? 'bg-red-100 text-red-600' :
|
||||
'bg-amber-100 text-amber-600'
|
||||
}`}>
|
||||
{entry.action === 'approve' ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : entry.action === 'reject' ? (
|
||||
<X className="w-4 h-4" />
|
||||
) : (
|
||||
<Clock className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{entry.action === 'approve' ? 'Согласовано' :
|
||||
entry.action === 'reject' ? 'Отклонено' : 'На доработку'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{entry.userRole} • {new Date(entry.date).toLocaleString('ru-RU')}
|
||||
</p>
|
||||
{entry.comment && (
|
||||
<p className="text-sm text-slate-700 mt-1">{entry.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Причина отклонения */}
|
||||
{invoice.status === 'rejected' && invoice.rejectionReason && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-sm font-bold text-red-700 mb-2">Причина отклонения</h3>
|
||||
<p className="text-red-600">{invoice.rejectionReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Причина отмены */}
|
||||
{invoice.status === 'cancelled' && invoice.cancelReason && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-2">Причина отмены</h3>
|
||||
<p className="text-slate-600">{invoice.cancelReason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Действия */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<h3 className="text-sm font-bold text-slate-700 mb-4">Действия</h3>
|
||||
|
||||
{canApprove() && (
|
||||
<div className="space-y-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Комментарий (необязательно)</label>
|
||||
<textarea
|
||||
value={approvalComment}
|
||||
onChange={(e) => setApprovalComment(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
rows={2}
|
||||
placeholder="Добавьте комментарий..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Согласовать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const reason = prompt('Укажите причину отклонения:');
|
||||
if (reason) {
|
||||
setRejectionReason(reason);
|
||||
setTimeout(() => handleReject(), 100);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canSchedule() && (
|
||||
<div className="space-y-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Дата в графике платежей *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSchedule}
|
||||
disabled={loading || !scheduledDate}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
Поставить в график платежей
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.status === 'scheduled' && (
|
||||
<div className="space-y-2 mb-4">
|
||||
<button
|
||||
onClick={() => setShowPayModal('default')}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Wallet className="w-4 h-4" />
|
||||
Оплатить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPayModal('cash')}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Banknote className="w-4 h-4" />
|
||||
Оплата наличкой
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canMarkCompleted() && (
|
||||
<button
|
||||
onClick={handleMarkCompleted}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
{invoice.itemType === 'materials' ? 'Отметить полученным (ТМЦ)' : 'Отметить выполненным'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(invoice.paymentFormat === 'prepayment' || invoice.paymentFormat === 'advance') &&
|
||||
invoice.isCompleted && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{!invoice.closingDocsReceived && (
|
||||
<button
|
||||
onClick={handleClosingDocsReceived}
|
||||
disabled={closingDocsUpdating}
|
||||
className="w-full px-4 py-2 bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Подтвердить получение закрывающих документов
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center">
|
||||
<input
|
||||
ref={closingDocsFileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.webp,.txt,.zip,.rar"
|
||||
onChange={handleClosingDocsFileChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closingDocsFileInputRef.current?.click()}
|
||||
disabled={closingDocsUploading}
|
||||
className="flex-1 px-4 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 disabled:bg-slate-100 disabled:text-slate-400 flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
{closingDocsUploading ? 'Загрузка файла...' : 'Прикрепить скан/фото закрывающих'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPayModal !== null && (
|
||||
<PaymentStatusModal
|
||||
action="paid"
|
||||
invoiceLabel={`${invoice.contractorName} — ${invoice.totalAmount.toLocaleString('ru-RU')} ₽`}
|
||||
defaultIsCash={showPayModal === 'cash'}
|
||||
onConfirm={handleMarkPaid}
|
||||
onCancel={() => setShowPayModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
965
components/finance/PaymentInvoiceForm.tsx
Executable file
965
components/finance/PaymentInvoiceForm.tsx
Executable file
@@ -0,0 +1,965 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PaymentInvoice, PaymentInvoicePurposeType, PaymentInvoiceFormat, PaymentInvoiceItemType, Building, District, ServiceItem, MaterialItem, PlanItem, DistributionMethod } from '../../types';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
import { X, Save, Upload, Building2, MapPin, Briefcase, Users, FileText, Calculator, Plus, Trash2, Paperclip, Download, PartyPopper, CalendarDays } from 'lucide-react';
|
||||
import { InvoiceDistribution } from './InvoiceDistribution';
|
||||
|
||||
export type PaymentInvoiceInitialPrefill =
|
||||
| {
|
||||
purposeType: 'event';
|
||||
purposeEventId?: string;
|
||||
purposeDescription?: string;
|
||||
totalAmount?: number;
|
||||
}
|
||||
| {
|
||||
purposeType: 'office';
|
||||
purposeDescription?: string;
|
||||
contractorName?: string;
|
||||
totalAmount?: number;
|
||||
itemType?: 'service' | 'materials';
|
||||
serviceItems?: { name: string; amount: number }[];
|
||||
materialItems?: { name: string; quantity: number; unit: string; pricePerUnit: number; amount: number }[];
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
interface PaymentInvoiceFormProps {
|
||||
invoice?: PaymentInvoice;
|
||||
initialPrefill?: PaymentInvoiceInitialPrefill;
|
||||
currentUserId: string;
|
||||
onSave: (invoice: Partial<PaymentInvoice>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const PaymentInvoiceForm: React.FC<PaymentInvoiceFormProps> = ({
|
||||
invoice,
|
||||
initialPrefill,
|
||||
currentUserId,
|
||||
onSave,
|
||||
onCancel
|
||||
}) => {
|
||||
const [formData, setFormData] = useState(() => {
|
||||
const prefill = initialPrefill;
|
||||
const purposeType = (invoice?.purposeType || (prefill && 'purposeType' in prefill ? prefill.purposeType : undefined) || 'building') as PaymentInvoicePurposeType;
|
||||
const officePrefill = prefill && 'purposeType' in prefill && prefill.purposeType === 'office' ? prefill : null;
|
||||
return {
|
||||
purposeType,
|
||||
purposeBuildingIds: invoice?.purposeBuildingIds || [],
|
||||
purposeDistrictIds: invoice?.purposeDistrictIds || [],
|
||||
purposeDescription: invoice?.purposeDescription ?? (prefill && 'purposeDescription' in prefill ? prefill.purposeDescription : undefined) ?? '',
|
||||
purposeEventId: invoice?.purposeEventId ?? (prefill && 'purposeEventId' in prefill ? prefill.purposeEventId : undefined) ?? undefined as number | string | undefined,
|
||||
planItemId: invoice?.planItemId ?? undefined as string | undefined,
|
||||
planItemBuildingId: invoice?.planItemBuildingId ?? undefined as string | undefined,
|
||||
fromWorkPlan: !!(invoice?.planItemId && invoice?.planItemBuildingId),
|
||||
paymentFormat: (invoice?.paymentFormat || 'postpayment') as PaymentInvoiceFormat,
|
||||
itemType: (invoice?.itemType || (officePrefill?.itemType ?? 'service')) as PaymentInvoiceItemType,
|
||||
contractorName: invoice?.contractorName || (officePrefill?.contractorName ?? '') || '',
|
||||
contractorInn: invoice?.contractorInn || '',
|
||||
serviceDescription: invoice?.serviceDescription || '',
|
||||
serviceItems: invoice?.serviceItems ?? (officePrefill?.itemType !== 'materials' && (officePrefill?.serviceItems?.length ? officePrefill.serviceItems : officePrefill?.purposeDescription && (officePrefill?.totalAmount ?? 0) > 0 ? [{ name: officePrefill.purposeDescription!, amount: officePrefill.totalAmount! }] : undefined)) ?? (invoice?.itemType === 'service' ? [] : undefined),
|
||||
materialItems: invoice?.materialItems ?? (officePrefill?.itemType === 'materials' && (officePrefill?.materialItems?.length ? officePrefill.materialItems : officePrefill?.purposeDescription && (officePrefill?.totalAmount ?? 0) > 0 ? [{ name: officePrefill.purposeDescription, quantity: 1, unit: 'шт', pricePerUnit: officePrefill.totalAmount!, amount: officePrefill.totalAmount! }] : undefined)) ?? (invoice?.itemType === 'materials' ? [] : undefined),
|
||||
totalAmount: invoice?.totalAmount ?? (prefill && 'totalAmount' in prefill ? prefill.totalAmount : undefined) ?? 0,
|
||||
notes: invoice?.notes || (officePrefill?.notes ?? '') || '',
|
||||
fileUrls: invoice?.fileUrls || [],
|
||||
distributionMethod: (invoice?.distributionMethod ?? undefined) as DistributionMethod | undefined,
|
||||
distributionData: invoice?.distributionData ?? {}
|
||||
};
|
||||
});
|
||||
|
||||
const [buildings, setBuildings] = useState<Building[]>([]);
|
||||
const [districts, setDistricts] = useState<District[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const planSourceBuildings = React.useMemo(() => {
|
||||
if (formData.purposeType === 'building' && formData.purposeBuildingIds.length > 0) {
|
||||
return buildings.filter(b => formData.purposeBuildingIds.includes(b.id));
|
||||
}
|
||||
if (formData.purposeType === 'district' && formData.purposeDistrictIds.length > 0) {
|
||||
return buildings.filter(b => formData.purposeDistrictIds.includes((b as any).districtId));
|
||||
}
|
||||
return [];
|
||||
}, [formData.purposeType, formData.purposeBuildingIds, formData.purposeDistrictIds, buildings]);
|
||||
|
||||
const planItemsForSelect = React.useMemo(() => {
|
||||
const items: { item: PlanItem; buildingId: string; buildingLabel: string }[] = [];
|
||||
for (const b of planSourceBuildings) {
|
||||
const plan = (b as any).annualPlan || [];
|
||||
const arr = Array.isArray(plan) ? plan : [];
|
||||
for (const p of arr) {
|
||||
if ((p.year || currentYear) === currentYear) {
|
||||
items.push({ item: p, buildingId: b.id, buildingLabel: (b.passport as any)?.address || b.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [planSourceBuildings, currentYear]);
|
||||
|
||||
// Для распределения: при «Дом» — выбранные дома; при «Участок» — все дома на выбранном участке(ах)
|
||||
const distributionBuildingIds = React.useMemo(() => {
|
||||
if (formData.purposeType === 'building') return formData.purposeBuildingIds;
|
||||
if (formData.purposeType === 'district') return planSourceBuildings.map(b => b.id);
|
||||
return [];
|
||||
}, [formData.purposeType, formData.purposeBuildingIds, planSourceBuildings]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [buildingsData, districtsData] = await Promise.all([
|
||||
backendApi.getBuildings(),
|
||||
backendApi.getDistricts()
|
||||
]);
|
||||
setBuildings(buildingsData);
|
||||
setDistricts(districtsData);
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.contractorName.trim()) {
|
||||
newErrors.contractorName = 'Название подрядчика обязательно';
|
||||
}
|
||||
|
||||
// Валидация в зависимости от типа
|
||||
if (formData.itemType === 'service') {
|
||||
if (!formData.serviceItems || formData.serviceItems.length === 0) {
|
||||
newErrors.serviceItems = 'Добавьте хотя бы одну услугу';
|
||||
} else {
|
||||
// Проверяем, что все услуги заполнены
|
||||
formData.serviceItems.forEach((item, index) => {
|
||||
if (!item.name.trim()) {
|
||||
newErrors[`serviceItem_${index}_name`] = 'Название услуги обязательно';
|
||||
}
|
||||
if (item.amount <= 0) {
|
||||
newErrors[`serviceItem_${index}_amount`] = 'Сумма должна быть больше нуля';
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (formData.itemType === 'materials') {
|
||||
if (!formData.materialItems || formData.materialItems.length === 0) {
|
||||
newErrors.materialItems = 'Добавьте хотя бы одну позицию ТМЦ';
|
||||
} else {
|
||||
// Проверяем, что все ТМЦ заполнены
|
||||
formData.materialItems.forEach((item, index) => {
|
||||
if (!item.name.trim()) {
|
||||
newErrors[`materialItem_${index}_name`] = 'Наименование ТМЦ обязательно';
|
||||
}
|
||||
if (item.quantity <= 0) {
|
||||
newErrors[`materialItem_${index}_quantity`] = 'Количество должно быть больше нуля';
|
||||
}
|
||||
if (!item.unit.trim()) {
|
||||
newErrors[`materialItem_${index}_unit`] = 'Единица измерения обязательна';
|
||||
}
|
||||
if (item.pricePerUnit <= 0) {
|
||||
newErrors[`materialItem_${index}_price`] = 'Цена за единицу должна быть больше нуля';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.totalAmount <= 0) {
|
||||
newErrors.totalAmount = 'Общая сумма должна быть больше нуля';
|
||||
}
|
||||
|
||||
// Для building и district требуется выбор домов/участков
|
||||
if (formData.purposeType === 'building' && formData.purposeBuildingIds.length === 0) {
|
||||
newErrors.purposeBuildingIds = 'Выберите хотя бы один дом';
|
||||
}
|
||||
|
||||
if (formData.purposeType === 'district' && formData.purposeDistrictIds.length === 0) {
|
||||
newErrors.purposeDistrictIds = 'Выберите хотя бы один участок';
|
||||
}
|
||||
|
||||
// Для legal, office, hr, other не требуется выбор домов/участков
|
||||
if (formData.purposeType === 'other' && !formData.purposeDescription.trim()) {
|
||||
newErrors.purposeDescription = 'Укажите описание назначения';
|
||||
}
|
||||
|
||||
// Для event — описание или привязка к мероприятию
|
||||
if (formData.purposeType === 'event' && !formData.purposeDescription?.trim() && !formData.purposeEventId) {
|
||||
newErrors.purposeDescription = 'Укажите описание или выберите мероприятие';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const payload: Partial<PaymentInvoice> = {
|
||||
...formData,
|
||||
createdBy: currentUserId
|
||||
};
|
||||
if (formData.fromWorkPlan && formData.planItemId && formData.planItemBuildingId) {
|
||||
payload.planItemId = formData.planItemId;
|
||||
payload.planItemBuildingId = formData.planItemBuildingId;
|
||||
} else {
|
||||
payload.planItemId = undefined;
|
||||
payload.planItemBuildingId = undefined;
|
||||
}
|
||||
const needsDistribution = distributionBuildingIds.length > 1;
|
||||
if (needsDistribution && formData.itemType === 'service') {
|
||||
payload.distributionMethod = formData.distributionMethod ?? 'equal';
|
||||
payload.distributionData = formData.distributionData ?? {};
|
||||
} else {
|
||||
payload.distributionMethod = undefined;
|
||||
payload.distributionData = {};
|
||||
}
|
||||
await onSave(payload);
|
||||
} catch (err) {
|
||||
console.error('Error saving invoice:', err);
|
||||
alert('Ошибка при сохранении счета');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuildingToggle = (buildingId: string) => {
|
||||
const current = formData.purposeBuildingIds;
|
||||
const newIds = current.includes(buildingId)
|
||||
? current.filter(id => id !== buildingId)
|
||||
: [...current, buildingId];
|
||||
setFormData({ ...formData, purposeBuildingIds: newIds, fromWorkPlan: false, planItemId: undefined, planItemBuildingId: undefined });
|
||||
};
|
||||
|
||||
const handleDistrictToggle = (districtId: string) => {
|
||||
const current = formData.purposeDistrictIds;
|
||||
const newIds = current.includes(districtId)
|
||||
? current.filter(id => id !== districtId)
|
||||
: [...current, districtId];
|
||||
setFormData({ ...formData, purposeDistrictIds: newIds, fromWorkPlan: false, planItemId: undefined, planItemBuildingId: undefined });
|
||||
};
|
||||
|
||||
// Обработчик загрузки файла
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploadingFile(true);
|
||||
try {
|
||||
const formDataUpload = new FormData();
|
||||
formDataUpload.append('file', file);
|
||||
|
||||
const response = await fetch('/api/finance/payment-invoices/upload', {
|
||||
method: 'POST',
|
||||
body: formDataUpload
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const fileInfo = result.file || { url: result.url || result.fileUrl, filename: file.name };
|
||||
|
||||
// Добавляем файл в список
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
fileUrls: [...(prev.fileUrls || []), fileInfo]
|
||||
}));
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
|
||||
alert(`Ошибка загрузки файла: ${errorData.error || 'Неизвестная ошибка'}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка загрузки файла:', error);
|
||||
alert(`Ошибка загрузки файла: ${error.message || 'Неизвестная ошибка'}`);
|
||||
} finally {
|
||||
setIsUploadingFile(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчики для услуг
|
||||
const addServiceItem = () => {
|
||||
const newItems = [...(formData.serviceItems || []), { name: '', amount: 0 }];
|
||||
setFormData({ ...formData, serviceItems: newItems });
|
||||
recalculateTotal();
|
||||
};
|
||||
|
||||
const updateServiceItem = (index: number, field: keyof ServiceItem, value: string | number) => {
|
||||
const newItems = [...(formData.serviceItems || [])];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setFormData({ ...formData, serviceItems: newItems });
|
||||
recalculateTotal();
|
||||
};
|
||||
|
||||
const removeServiceItem = (index: number) => {
|
||||
const newItems = formData.serviceItems?.filter((_, i) => i !== index) || [];
|
||||
setFormData({ ...formData, serviceItems: newItems.length > 0 ? newItems : undefined });
|
||||
recalculateTotal();
|
||||
};
|
||||
|
||||
// Обработчики для ТМЦ
|
||||
const addMaterialItem = () => {
|
||||
const newItems = [...(formData.materialItems || []), { name: '', quantity: 0, unit: 'шт', pricePerUnit: 0, amount: 0 }];
|
||||
setFormData({ ...formData, materialItems: newItems });
|
||||
recalculateTotal();
|
||||
};
|
||||
|
||||
const updateMaterialItem = (index: number, field: keyof MaterialItem, value: string | number) => {
|
||||
const newItems = [...(formData.materialItems || [])];
|
||||
const item = { ...newItems[index], [field]: value };
|
||||
|
||||
// Автоматически рассчитываем сумму при изменении количества или цены
|
||||
if (field === 'quantity' || field === 'pricePerUnit') {
|
||||
item.amount = item.quantity * item.pricePerUnit;
|
||||
}
|
||||
|
||||
newItems[index] = item;
|
||||
setFormData({ ...formData, materialItems: newItems });
|
||||
recalculateTotal();
|
||||
};
|
||||
|
||||
const removeMaterialItem = (index: number) => {
|
||||
const newItems = formData.materialItems?.filter((_, i) => i !== index) || [];
|
||||
setFormData({ ...formData, materialItems: newItems.length > 0 ? newItems : undefined });
|
||||
recalculateTotal();
|
||||
};
|
||||
|
||||
// Пересчет общей суммы
|
||||
const recalculateTotal = () => {
|
||||
let total = 0;
|
||||
|
||||
if (formData.itemType === 'service' && formData.serviceItems) {
|
||||
total = formData.serviceItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||
} else if (formData.itemType === 'materials' && formData.materialItems) {
|
||||
total = formData.materialItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||
}
|
||||
|
||||
setFormData(prev => ({ ...prev, totalAmount: total }));
|
||||
};
|
||||
|
||||
// При изменении типа предмета счета - очищаем данные другого типа
|
||||
useEffect(() => {
|
||||
if (formData.itemType === 'service') {
|
||||
setFormData(prev => {
|
||||
if (!prev.serviceItems || prev.serviceItems.length === 0) {
|
||||
return { ...prev, materialItems: undefined, serviceItems: [] };
|
||||
}
|
||||
return { ...prev, materialItems: undefined };
|
||||
});
|
||||
} else if (formData.itemType === 'materials') {
|
||||
setFormData(prev => {
|
||||
if (!prev.materialItems || prev.materialItems.length === 0) {
|
||||
return { ...prev, serviceItems: undefined, materialItems: [] };
|
||||
}
|
||||
return { ...prev, serviceItems: undefined };
|
||||
});
|
||||
}
|
||||
// Пересчитываем сумму после изменения типа
|
||||
setTimeout(() => recalculateTotal(), 0);
|
||||
}, [formData.itemType]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Назначение счета */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h3 className="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary-600" />
|
||||
Назначение счета
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Тип назначения *</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: 'building', label: 'Дом', icon: Building2 },
|
||||
{ value: 'district', label: 'Участок', icon: MapPin },
|
||||
{ value: 'legal', label: 'Юристы', icon: Briefcase },
|
||||
{ value: 'office', label: 'Офис', icon: FileText },
|
||||
{ value: 'hr', label: 'HR', icon: Users },
|
||||
{ value: 'event', label: 'Мероприятие', icon: PartyPopper },
|
||||
{ value: 'other', label: 'Другое', icon: FileText }
|
||||
].map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, purposeType: value as PaymentInvoicePurposeType, purposeBuildingIds: [], purposeDistrictIds: [], purposeEventId: value === 'event' ? formData.purposeEventId : undefined, fromWorkPlan: false, planItemId: undefined, planItemBuildingId: undefined })}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
formData.purposeType === value
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 mx-auto mb-1" />
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Выбор домов */}
|
||||
{formData.purposeType === 'building' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Выберите дома *</label>
|
||||
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
|
||||
{buildings.map(building => (
|
||||
<label key={building.id} className="flex items-center gap-2 p-2 hover:bg-slate-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.purposeBuildingIds.includes(building.id)}
|
||||
onChange={() => handleBuildingToggle(building.id)}
|
||||
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">{building.passport?.address || building.id}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.purposeBuildingIds && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors.purposeBuildingIds}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Выбор участков */}
|
||||
{formData.purposeType === 'district' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Выберите участки *</label>
|
||||
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
|
||||
{districts.map(district => (
|
||||
<label key={district.id} className="flex items-center gap-2 p-2 hover:bg-slate-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.purposeDistrictIds.includes(district.id)}
|
||||
onChange={() => handleDistrictToggle(district.id)}
|
||||
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">{district.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.purposeDistrictIds && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors.purposeDistrictIds}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Из плана работ — при доме или участке */}
|
||||
{(formData.purposeType === 'building' || formData.purposeType === 'district') && (formData.purposeBuildingIds.length > 0 || formData.purposeDistrictIds.length > 0) && (
|
||||
<div className="border-t border-slate-200 pt-4 mt-4">
|
||||
<label className="flex items-center gap-2 p-2 rounded-lg hover:bg-slate-50 cursor-pointer w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!formData.fromWorkPlan}
|
||||
onChange={(e) => setFormData({ ...formData, fromWorkPlan: e.target.checked, planItemId: e.target.checked ? formData.planItemId : undefined, planItemBuildingId: e.target.checked ? formData.planItemBuildingId : undefined })}
|
||||
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<CalendarDays className="w-4 h-4 text-primary-600" />
|
||||
<span className="text-sm font-medium text-slate-700">Из плана работ</span>
|
||||
</label>
|
||||
{formData.fromWorkPlan && planItemsForSelect.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Пункт плана *</label>
|
||||
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
|
||||
{planItemsForSelect.map(({ item, buildingId, buildingLabel }) => (
|
||||
<label key={`${buildingId}-${item.id}`} className="flex items-center gap-2 p-2 hover:bg-slate-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="planItem"
|
||||
checked={formData.planItemId === item.id && formData.planItemBuildingId === buildingId}
|
||||
onChange={() => setFormData({ ...formData, planItemId: item.id, planItemBuildingId: buildingId })}
|
||||
className="border-slate-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-700 flex-1">{item.workName}</span>
|
||||
<span className="text-xs text-slate-500">{item.month} · {buildingLabel}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{formData.fromWorkPlan && planItemsForSelect.length === 0 && (
|
||||
<p className="text-xs text-slate-500 mt-2">Нет пунктов плана на текущий год у выбранных домов. Добавьте работы в Участки → Дом → План работ.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Мероприятие: описание (название мероприятия) и привязка по ID */}
|
||||
{formData.purposeType === 'event' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Мероприятие (название / описание) *</label>
|
||||
<textarea
|
||||
value={formData.purposeDescription}
|
||||
onChange={(e) => setFormData({ ...formData, purposeDescription: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
rows={2}
|
||||
placeholder="Название мероприятия или описание..."
|
||||
/>
|
||||
{formData.purposeEventId && (
|
||||
<p className="text-xs text-slate-500 mt-1">Привязано к мероприятию ID: {formData.purposeEventId}</p>
|
||||
)}
|
||||
{errors.purposeDescription && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors.purposeDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Описание для "Другое" */}
|
||||
{formData.purposeType === 'other' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Описание назначения *</label>
|
||||
<textarea
|
||||
value={formData.purposeDescription}
|
||||
onChange={(e) => setFormData({ ...formData, purposeDescription: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
rows={3}
|
||||
placeholder="Опишите назначение счета..."
|
||||
/>
|
||||
{errors.purposeDescription && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors.purposeDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Формат оплаты */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h3 className="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Calculator className="w-5 h-5 text-primary-600" />
|
||||
Формат оплаты
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: 'prepayment', label: 'Предоплата' },
|
||||
{ value: 'postpayment', label: 'Постоплата' },
|
||||
{ value: 'advance', label: 'Аванс' }
|
||||
].map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, paymentFormat: value as PaymentInvoiceFormat })}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
formData.paymentFormat === value
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Тип предмета счета (услуга или ТМЦ) */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h3 className="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary-600" />
|
||||
Тип предмета счета
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ value: 'service', label: 'Услуга' },
|
||||
{ value: 'materials', label: 'ТМЦ (товарно-материальные ценности)' }
|
||||
].map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// При смене типа очищаем данные другого типа
|
||||
if (value === 'service') {
|
||||
setFormData(prev => ({ ...prev, itemType: 'service', materialItems: undefined, serviceItems: prev.serviceItems || [] }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, itemType: 'materials', serviceItems: undefined, materialItems: prev.materialItems || [] }));
|
||||
}
|
||||
}}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
formData.itemType === value
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о подрядчике */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h3 className="font-bold text-slate-800 mb-4">Информация о подрядчике</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Название подрядчика *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.contractorName}
|
||||
onChange={(e) => setFormData({ ...formData, contractorName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="ООО Рога и Копыта"
|
||||
/>
|
||||
{errors.contractorName && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors.contractorName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">ИНН подрядчика</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.contractorInn}
|
||||
onChange={(e) => setFormData({ ...formData, contractorInn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="1234567890"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Услуги */}
|
||||
{formData.itemType === 'service' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-slate-800">Услуги</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addServiceItem}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить услугу
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.serviceItems && formData.serviceItems.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-12 gap-2 text-xs font-bold text-slate-600 uppercase tracking-wider pb-2 border-b border-slate-200">
|
||||
<div className="col-span-6">Название услуги</div>
|
||||
<div className="col-span-4">Сумма, ₽</div>
|
||||
<div className="col-span-2"></div>
|
||||
</div>
|
||||
{formData.serviceItems.map((item, index) => (
|
||||
<div key={index} className="grid grid-cols-12 gap-2 items-center">
|
||||
<div className="col-span-6">
|
||||
<input
|
||||
type="text"
|
||||
value={item.name}
|
||||
onChange={(e) => updateServiceItem(index, 'name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
placeholder="Название услуги"
|
||||
/>
|
||||
{errors[`serviceItem_${index}_name`] && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors[`serviceItem_${index}_name`]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<input
|
||||
type="number"
|
||||
value={item.amount || ''}
|
||||
onChange={(e) => updateServiceItem(index, 'amount', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
placeholder="0.00"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
{errors[`serviceItem_${index}_amount`] && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors[`serviceItem_${index}_amount`]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeServiceItem(index)}
|
||||
className="w-full p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mx-auto" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-3 border-t border-slate-200 flex justify-end">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-slate-600">Итого:</p>
|
||||
<p className="text-xl font-black text-slate-900">
|
||||
{formData.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<p className="mb-4">Нет добавленных услуг</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addServiceItem}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
Добавить первую услугу
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{errors.serviceItems && (
|
||||
<p className="text-xs text-red-600 mt-2">{errors.serviceItems}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Распределение по домам: несколько домов или участок (= все дома на участке), тип "Услуга" */}
|
||||
{distributionBuildingIds.length > 1 && formData.itemType === 'service' && formData.totalAmount > 0 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<InvoiceDistribution
|
||||
purposeType="building"
|
||||
selectedBuildingIds={distributionBuildingIds}
|
||||
selectedDistrictIds={[]}
|
||||
totalAmount={formData.totalAmount}
|
||||
distributionMethod={formData.distributionMethod ?? null}
|
||||
distributionData={formData.distributionData ?? {}}
|
||||
onDistributionChange={(method, data) =>
|
||||
setFormData(prev => ({ ...prev, distributionMethod: method, distributionData: data }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ТМЦ */}
|
||||
{formData.itemType === 'materials' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-slate-800">ТМЦ (товарно-материальные ценности)</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMaterialItem}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить позицию
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.materialItems && formData.materialItems.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-12 gap-2 text-xs font-bold text-slate-600 uppercase tracking-wider pb-2 border-b border-slate-200">
|
||||
<div className="col-span-4">Наименование</div>
|
||||
<div className="col-span-2">Количество</div>
|
||||
<div className="col-span-2">Ед. изм.</div>
|
||||
<div className="col-span-2">Цена за ед., ₽</div>
|
||||
<div className="col-span-1">Сумма, ₽</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
{formData.materialItems.map((item, index) => (
|
||||
<div key={index} className="grid grid-cols-12 gap-2 items-start">
|
||||
<div className="col-span-4">
|
||||
<input
|
||||
type="text"
|
||||
value={item.name}
|
||||
onChange={(e) => updateMaterialItem(index, 'name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
placeholder="Наименование ТМЦ"
|
||||
/>
|
||||
{errors[`materialItem_${index}_name`] && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors[`materialItem_${index}_name`]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<input
|
||||
type="number"
|
||||
value={item.quantity || ''}
|
||||
onChange={(e) => updateMaterialItem(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
placeholder="0"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
{errors[`materialItem_${index}_quantity`] && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors[`materialItem_${index}_quantity`]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<input
|
||||
type="text"
|
||||
value={item.unit}
|
||||
onChange={(e) => updateMaterialItem(index, 'unit', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
placeholder="шт"
|
||||
/>
|
||||
{errors[`materialItem_${index}_unit`] && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors[`materialItem_${index}_unit`]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<input
|
||||
type="number"
|
||||
value={item.pricePerUnit || ''}
|
||||
onChange={(e) => updateMaterialItem(index, 'pricePerUnit', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
placeholder="0.00"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
{errors[`materialItem_${index}_price`] && (
|
||||
<p className="text-xs text-red-600 mt-1">{errors[`materialItem_${index}_price`]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm font-medium text-slate-700">
|
||||
{item.amount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMaterialItem(index)}
|
||||
className="w-full p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mx-auto" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-3 border-t border-slate-200 flex justify-end">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-slate-600">Итого:</p>
|
||||
<p className="text-xl font-black text-slate-900">
|
||||
{formData.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<p className="mb-4">Нет добавленных позиций ТМЦ</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMaterialItem}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
Добавить первую позицию
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{errors.materialItems && (
|
||||
<p className="text-xs text-red-600 mt-2">{errors.materialItems}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Загруженные файлы (физические счета) */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h3 className="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Paperclip className="w-5 h-5 text-primary-600" />
|
||||
Физические счета
|
||||
</h3>
|
||||
|
||||
{formData.fileUrls && formData.fileUrls.length > 0 ? (
|
||||
<div className="space-y-2 mb-4">
|
||||
{formData.fileUrls.map((fileUrl, index) => {
|
||||
// fileUrl может быть строкой или объектом с информацией о файле
|
||||
const fileInfo = typeof fileUrl === 'string'
|
||||
? { url: fileUrl, filename: fileUrl.split('/').pop() || 'Файл' }
|
||||
: fileUrl;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<FileText className="w-4 h-4 text-slate-500 flex-shrink-0" />
|
||||
<span className="text-sm text-slate-700 truncate" title={fileInfo.filename || fileInfo.url}>
|
||||
{fileInfo.filename || fileInfo.url}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={fileInfo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||
title="Скачать"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newUrls = formData.fileUrls.filter((_, i) => i !== index);
|
||||
setFormData({ ...formData, fileUrls: newUrls });
|
||||
}}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 mb-4">Нет загруженных файлов</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.webp,.txt,.zip,.rar"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingFile}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
{isUploadingFile ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Загрузка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Загрузить файл счета
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Примечания */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Примечания</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
rows={3}
|
||||
placeholder="Дополнительная информация..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg text-slate-700 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{loading ? 'Сохранение...' : invoice ? 'Сохранить изменения' : 'Создать счет'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
293
components/finance/PaymentInvoiceList.tsx
Executable file
293
components/finance/PaymentInvoiceList.tsx
Executable file
@@ -0,0 +1,293 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PaymentInvoice, PaymentInvoiceStatus, PaymentInvoicePurposeType, PaymentInvoiceFormat } from '../../types';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
import { FileText, Search, Filter, Eye, Edit2, CheckCircle2, XCircle, Clock, Calendar } from 'lucide-react';
|
||||
|
||||
interface PaymentInvoiceListProps {
|
||||
onInvoiceClick: (invoice: PaymentInvoice) => void;
|
||||
onCreateNew: () => void;
|
||||
currentUserId: string;
|
||||
/** Можно ли создавать/редактировать (по умолчанию true) */
|
||||
canEdit?: boolean;
|
||||
/** Показывать только счета на своё имя / от своего имени */
|
||||
scopeOwn?: boolean;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<{ status: PaymentInvoiceStatus }> = ({ status }) => {
|
||||
const config: Record<PaymentInvoiceStatus, { label: string; color: string; bg: string }> = {
|
||||
draft: { label: 'Черновик', color: 'text-slate-500', bg: 'bg-slate-100' },
|
||||
pending_manager_approval: { label: 'На согл. руков.', color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
pending_finance_manager_approval: { label: 'На согл. фин. руков.', color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
approved: { label: 'Согласован', color: 'text-indigo-600', bg: 'bg-indigo-50' },
|
||||
scheduled: { label: 'В графике', color: 'text-purple-600', bg: 'bg-purple-50' },
|
||||
paid: { label: 'Оплачен', color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
postponed: { label: 'Отложен', color: 'text-orange-600', bg: 'bg-orange-50' },
|
||||
cancelled: { label: 'Отменен', color: 'text-red-600', bg: 'bg-red-50' },
|
||||
rejected: { label: 'Отклонен', color: 'text-red-600', bg: 'bg-red-50' },
|
||||
completed: { label: 'Выполнено', color: 'text-green-600', bg: 'bg-green-50' }
|
||||
};
|
||||
const s = config[status] || config.draft;
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${s.bg} ${s.color}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const PurposeTypeLabel: Record<PaymentInvoicePurposeType, string> = {
|
||||
building: 'Дом',
|
||||
district: 'Участок',
|
||||
legal: 'Юристы',
|
||||
office: 'Офис',
|
||||
hr: 'HR',
|
||||
event: 'Мероприятие',
|
||||
other: 'Другое'
|
||||
};
|
||||
|
||||
const PaymentFormatLabel: Record<PaymentInvoiceFormat, string> = {
|
||||
prepayment: 'Предоплата',
|
||||
postpayment: 'Постоплата',
|
||||
advance: 'Аванс'
|
||||
};
|
||||
|
||||
export const PaymentInvoiceList: React.FC<PaymentInvoiceListProps> = ({
|
||||
onInvoiceClick,
|
||||
onCreateNew,
|
||||
currentUserId,
|
||||
canEdit = true,
|
||||
scopeOwn = false,
|
||||
}) => {
|
||||
const [invoices, setInvoices] = useState<PaymentInvoice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [purposeTypeFilter, setPurposeTypeFilter] = useState<string>('all');
|
||||
const [paymentFormatFilter, setPaymentFormatFilter] = useState<string>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices();
|
||||
}, [statusFilter, purposeTypeFilter, paymentFormatFilter, search, page, scopeOwn]);
|
||||
|
||||
const fetchInvoices = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter !== 'all') params.append('status', statusFilter);
|
||||
if (purposeTypeFilter !== 'all') params.append('purposeType', purposeTypeFilter);
|
||||
if (paymentFormatFilter !== 'all') params.append('paymentFormat', paymentFormatFilter);
|
||||
if (search) params.append('search', search);
|
||||
if (scopeOwn) params.append('scope', 'own');
|
||||
params.append('page', page.toString());
|
||||
params.append('limit', '20');
|
||||
|
||||
const response = await apiClient.get<{ invoices: PaymentInvoice[]; pagination: any }>(
|
||||
`/finance/payment-invoices?${params.toString()}`
|
||||
);
|
||||
setInvoices(response.invoices);
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
} catch (err) {
|
||||
console.error('Error fetching invoices:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPurposeLabel = (invoice: PaymentInvoice): string => {
|
||||
if (invoice.purposeType === 'building' && invoice.purposeBuildingIds.length > 0) {
|
||||
return `Дом (${invoice.purposeBuildingIds.length})`;
|
||||
}
|
||||
if (invoice.purposeType === 'district' && invoice.purposeDistrictIds.length > 0) {
|
||||
return `Участок (${invoice.purposeDistrictIds.length})`;
|
||||
}
|
||||
if (invoice.purposeType === 'event') {
|
||||
return invoice.purposeDescription || PurposeTypeLabel.event || 'Мероприятие';
|
||||
}
|
||||
return PurposeTypeLabel[invoice.purposeType] || invoice.purposeDescription || 'Не указано';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-slate-600">Загрузка счетов...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Фильтры и поиск */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Поиск */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Поиск по номеру, подрядчику, услуге..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="draft">Черновик</option>
|
||||
<option value="pending_manager_approval">На согл. руков.</option>
|
||||
<option value="pending_finance_manager_approval">На согл. фин. руков.</option>
|
||||
<option value="approved">Согласован</option>
|
||||
<option value="scheduled">В графике</option>
|
||||
<option value="paid">Оплачен</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={purposeTypeFilter}
|
||||
onChange={(e) => {
|
||||
setPurposeTypeFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
>
|
||||
<option value="all">Все назначения</option>
|
||||
<option value="building">Дом</option>
|
||||
<option value="district">Участок</option>
|
||||
<option value="legal">Юристы</option>
|
||||
<option value="office">Офис</option>
|
||||
<option value="hr">HR</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={paymentFormatFilter}
|
||||
onChange={(e) => {
|
||||
setPaymentFormatFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
>
|
||||
<option value="all">Все форматы</option>
|
||||
<option value="prepayment">Предоплата</option>
|
||||
<option value="postpayment">Постоплата</option>
|
||||
<option value="advance">Аванс</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список счетов */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="font-black text-slate-700 text-sm uppercase tracking-widest">
|
||||
Счета на оплату ({invoices.length})
|
||||
</h3>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={onCreateNew}
|
||||
className="px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-xs font-bold transition-colors"
|
||||
>
|
||||
+ Новый счет
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{invoices.length === 0 ? (
|
||||
<div className="p-10 text-center text-slate-400 text-sm">Счетов не найдено</div>
|
||||
) : (
|
||||
invoices.map(invoice => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
onClick={() => onInvoiceClick(invoice)}
|
||||
className="p-4 flex flex-col md:flex-row md:items-center justify-between hover:bg-slate-50 transition-colors cursor-pointer group gap-4"
|
||||
>
|
||||
<div className="flex gap-4 items-center flex-1 min-w-0">
|
||||
<div className="p-3 rounded-xl bg-slate-50 text-slate-400 group-hover:bg-primary-50 group-hover:text-primary-600 transition-colors">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<StatusBadge status={invoice.status} />
|
||||
<span className="text-xs text-slate-500 font-mono">{invoice.invoiceNumber}</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-slate-800 truncate">{invoice.contractorName}</p>
|
||||
<p className="text-[10px] text-slate-500 truncate">
|
||||
{getPurposeLabel(invoice)} • {PaymentFormatLabel[invoice.paymentFormat]} • {invoice.itemType === 'materials' ? 'ТМЦ' : 'Услуга'} • {invoice.serviceDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between md:justify-end gap-6">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-black text-slate-900">
|
||||
{invoice.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
{new Date(invoice.createdAt).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
{invoice.scheduledDate && (
|
||||
<p className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(invoice.scheduledDate).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInvoiceClick(invoice);
|
||||
}}
|
||||
className="p-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-primary-100 hover:text-primary-600 transition-colors"
|
||||
title="Просмотр"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Пагинация */}
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-slate-200 flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border border-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-50"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="px-4 py-1 text-sm text-slate-600">
|
||||
Страница {page} из {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border border-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-50"
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
101
components/finance/PaymentSchedule.tsx
Executable file
101
components/finance/PaymentSchedule.tsx
Executable file
@@ -0,0 +1,101 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Invoice, InvoiceStatus } from '../../types';
|
||||
import { Calendar, Clock, CheckCircle2, X } from 'lucide-react';
|
||||
|
||||
export const PaymentSchedule: React.FC<{
|
||||
approvedInvoices: Invoice[],
|
||||
scheduledInvoices: Invoice[],
|
||||
onUpdateStatus: (id: string, s: InvoiceStatus, extra?: any) => void,
|
||||
currentBalance: number
|
||||
}> = ({ approvedInvoices, scheduledInvoices, onUpdateStatus, currentBalance }) => {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Approved but not scheduled */}
|
||||
{approvedInvoices.length > 0 && (
|
||||
<div className="bg-white p-5 rounded-2xl border-2 border-dashed border-primary-200">
|
||||
<h3 className="font-bold text-primary-700 text-sm mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5"/> Очередь на оплату ({approvedInvoices.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{approvedInvoices.map(inv => (
|
||||
<div key={inv.id} className="flex items-center justify-between p-3 bg-primary-50/50 rounded-xl border border-primary-100">
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<p className="text-xs font-bold text-slate-800 truncate">{inv.contractorName}</p>
|
||||
<p className="text-[10px] text-slate-500">{inv.address}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-black text-primary-700">{inv.amount.toLocaleString()}₽</span>
|
||||
<button
|
||||
onClick={() => onUpdateStatus(inv.id, 'scheduled', { scheduledDate: new Date().toISOString().split('T')[0] })}
|
||||
className="bg-primary-600 text-white px-3 py-1.5 rounded-lg text-[10px] font-black uppercase hover:bg-primary-700 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Calendar className="w-3 h-3"/> В график
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The Calendar List */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-4 bg-slate-900 text-white flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-white/10 rounded-lg"><Calendar className="w-5 h-5"/></div>
|
||||
<div>
|
||||
<h3 className="font-bold leading-none">График платежей</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-1 uppercase font-bold tracking-widest">Июнь 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-black">{scheduledInvoices.reduce((s, i) => s + i.amount, 0).toLocaleString()} ₽</p>
|
||||
<p className="text-[10px] text-slate-400 uppercase font-bold">К оплате</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{scheduledInvoices.length === 0 && <div className="p-12 text-center text-slate-400 italic">График выплат пуст</div>}
|
||||
{scheduledInvoices.map(inv => (
|
||||
<div key={inv.id} className="p-4 flex items-center gap-4 hover:bg-slate-50 transition-colors group">
|
||||
<div className={`p-2 rounded-xl text-center min-w-[50px] ${inv.status === 'overdue' ? 'bg-red-50 text-red-600' : 'bg-slate-50 text-slate-500'}`}>
|
||||
<p className="text-xs font-black leading-none">{inv.scheduledDate?.split('-')[2] || '??'}</p>
|
||||
<p className="text-[9px] font-bold uppercase mt-1">июн</p>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<p className="text-sm font-bold text-slate-800 truncate">{inv.contractorName}</p>
|
||||
{inv.status === 'overdue' && <span className="bg-red-500 text-white text-[8px] font-black px-1.5 rounded-full uppercase animate-pulse">Просрочен</span>}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 truncate">{inv.address} • {inv.serviceName}</p>
|
||||
</div>
|
||||
<div className="text-right flex items-center gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-black text-slate-900">{inv.amount.toLocaleString()} ₽</p>
|
||||
<p className="text-[9px] text-slate-400 font-bold uppercase">Баланс: {(currentBalance/1000).toFixed(0)}k</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => onUpdateStatus(inv.id, 'paid')}
|
||||
className="p-2 bg-emerald-50 text-emerald-600 rounded-lg hover:bg-emerald-100 shadow-sm transition-colors border border-emerald-100"
|
||||
title="Оплачено"
|
||||
>
|
||||
<CheckCircle2 className="w-5 h-5"/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onUpdateStatus(inv.id, 'approved', { scheduledDate: undefined })}
|
||||
className="p-2 bg-slate-50 text-slate-400 rounded-lg hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="Убрать"
|
||||
>
|
||||
<X className="w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
169
components/finance/PaymentStatusModal.tsx
Executable file
169
components/finance/PaymentStatusModal.tsx
Executable file
@@ -0,0 +1,169 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Wallet, Calendar, Ban, Clock } from 'lucide-react';
|
||||
|
||||
export type PaymentStatusAction = 'paid' | 'postponed' | 'cancelled';
|
||||
|
||||
export interface PaymentStatusModalPayload {
|
||||
paymentDate?: string;
|
||||
paymentRef?: string;
|
||||
isCash?: boolean;
|
||||
postponedDate?: string;
|
||||
cancelReason?: string;
|
||||
}
|
||||
|
||||
interface PaymentStatusModalProps {
|
||||
action: PaymentStatusAction;
|
||||
invoiceLabel?: string;
|
||||
/** По умолчанию отметить «Оплата наличными» */
|
||||
defaultIsCash?: boolean;
|
||||
onConfirm: (payload: PaymentStatusModalPayload) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const PaymentStatusModal: React.FC<PaymentStatusModalProps> = ({
|
||||
action,
|
||||
invoiceLabel,
|
||||
defaultIsCash = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}) => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const [paymentDate, setPaymentDate] = useState(today);
|
||||
const [paymentRef, setPaymentRef] = useState('');
|
||||
const [isCash, setIsCash] = useState(defaultIsCash);
|
||||
const [postponedDate, setPostponedDate] = useState('');
|
||||
const [cancelReason, setCancelReason] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (action === 'paid') {
|
||||
onConfirm({ paymentDate, paymentRef: paymentRef.trim() || undefined, isCash });
|
||||
} else if (action === 'postponed') {
|
||||
if (!postponedDate) {
|
||||
alert('Укажите дату переноса');
|
||||
return;
|
||||
}
|
||||
onConfirm({ postponedDate });
|
||||
} else {
|
||||
if (!cancelReason.trim()) {
|
||||
alert('Укажите причину отмены');
|
||||
return;
|
||||
}
|
||||
onConfirm({ cancelReason: cancelReason.trim() });
|
||||
}
|
||||
};
|
||||
|
||||
const title =
|
||||
action === 'paid'
|
||||
? 'Оплата счета'
|
||||
: action === 'postponed'
|
||||
? 'Перенос на другую дату'
|
||||
: 'Отмена / отказ';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full">
|
||||
<div className="p-4 border-b border-slate-200 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-800">{title}</h3>
|
||||
<button type="button" onClick={onCancel} className="p-2 rounded-lg hover:bg-slate-100 text-slate-500">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{invoiceLabel && (
|
||||
<p className="text-sm text-slate-600">{invoiceLabel}</p>
|
||||
)}
|
||||
|
||||
{action === 'paid' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Дата оплаты</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={paymentDate}
|
||||
onChange={(e) => setPaymentDate(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Номер платежки</label>
|
||||
<input
|
||||
type="text"
|
||||
value={paymentRef}
|
||||
onChange={(e) => setPaymentRef(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
placeholder="Номер платёжного поручения"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isCash}
|
||||
onChange={(e) => setIsCash(e.target.checked)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">Оплата наличными</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{action === 'postponed' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Перенос на дату *</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={postponedDate}
|
||||
onChange={(e) => setPostponedDate(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-slate-200 rounded-xl text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'cancelled' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Причина отмены / отказа *</label>
|
||||
<textarea
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm resize-none"
|
||||
placeholder="Укажите причину"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-medium text-sm ${
|
||||
action === 'paid'
|
||||
? 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||
: action === 'postponed'
|
||||
? 'bg-amber-600 text-white hover:bg-amber-700'
|
||||
: 'bg-red-600 text-white hover:bg-red-700'
|
||||
}`}
|
||||
>
|
||||
{action === 'paid' && <Wallet className="w-4 h-4" />}
|
||||
{action === 'postponed' && <Clock className="w-4 h-4" />}
|
||||
{action === 'cancelled' && <Ban className="w-4 h-4" />}
|
||||
{action === 'paid' ? 'Оплатить' : action === 'postponed' ? 'Перенести' : 'Отменить'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-slate-200 rounded-xl font-medium text-sm text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
859
components/finance/ReportDetailView.tsx
Executable file
859
components/finance/ReportDetailView.tsx
Executable file
@@ -0,0 +1,859 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, Building2, Grid, List, Home, MapPin, DollarSign, TrendingUp, TrendingDown, FileText, Loader2, CheckCircle2, Eye } from 'lucide-react';
|
||||
import { FinancialReport } from '../../types';
|
||||
import { apiClient, authFetch } from '../../services/apiClient';
|
||||
import { AggregatedReportView } from './AggregatedReportView';
|
||||
import { ResidentReportView } from '../pr/ResidentReportView';
|
||||
|
||||
interface Building {
|
||||
id: string;
|
||||
address: string;
|
||||
district_id?: string;
|
||||
district_name?: string;
|
||||
total_income: number;
|
||||
total_expenses: number;
|
||||
balance: number;
|
||||
reports_count: number;
|
||||
}
|
||||
|
||||
interface ReportDetailViewProps {
|
||||
report: FinancialReport;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type ViewMode = 'all' | 'sections' | 'specific';
|
||||
type DisplayMode = 'grid' | 'list';
|
||||
|
||||
export const ReportDetailView: React.FC<ReportDetailViewProps> = ({ report, onBack }) => {
|
||||
const [buildings, setBuildings] = useState<Building[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('all');
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('grid');
|
||||
const [selectedBuilding, setSelectedBuilding] = useState<string | null>(null);
|
||||
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null);
|
||||
const [sections, setSections] = useState<Record<string, Building[]>>({});
|
||||
const [residentReportIds, setResidentReportIds] = useState<Record<string, number>>({});
|
||||
const [aggregatedData, setAggregatedData] = useState<any>(null);
|
||||
const [districtAggregatedData, setDistrictAggregatedData] = useState<any>(null);
|
||||
const [selectedBuildingReport, setSelectedBuildingReport] = useState<any>(null);
|
||||
const [buildingAggregatedData, setBuildingAggregatedData] = useState<any>(null);
|
||||
const [loadingAggregated, setLoadingAggregated] = useState(false);
|
||||
const [selectedPeriodStart, setSelectedPeriodStart] = useState<string>('');
|
||||
const [selectedPeriodEnd, setSelectedPeriodEnd] = useState<string>('');
|
||||
const [buildingReportViewMode, setBuildingReportViewMode] = useState<'aggregated' | 'detailed'>('detailed');
|
||||
const [report76Rows, setReport76Rows] = useState<any[]>([]);
|
||||
const [loading76Rows, setLoading76Rows] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (report.reportType === 'balance_sheet_76') {
|
||||
setLoading(true);
|
||||
fetchReport76Rows();
|
||||
} else {
|
||||
fetchBuildings();
|
||||
if (report.reportType === 'balance_sheet') {
|
||||
fetchResidentReportIds();
|
||||
}
|
||||
}
|
||||
}, [report.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'all' && report.reportType === 'balance_sheet') {
|
||||
fetchAggregatedData();
|
||||
} else {
|
||||
setAggregatedData(null);
|
||||
}
|
||||
}, [viewMode, report.id, selectedPeriodStart, selectedPeriodEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDistrict && report.reportType === 'balance_sheet') {
|
||||
fetchDistrictAggregatedData(selectedDistrict);
|
||||
} else {
|
||||
setDistrictAggregatedData(null);
|
||||
}
|
||||
}, [selectedDistrict, report.id, selectedPeriodStart, selectedPeriodEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBuilding && report.reportType === 'balance_sheet') {
|
||||
if (buildingReportViewMode === 'aggregated') {
|
||||
fetchBuildingAggregatedReport(selectedBuilding);
|
||||
} else {
|
||||
fetchBuildingReport(selectedBuilding);
|
||||
}
|
||||
} else {
|
||||
setBuildingAggregatedData(null);
|
||||
setSelectedBuildingReport(null);
|
||||
}
|
||||
}, [selectedBuilding, selectedPeriodStart, selectedPeriodEnd, buildingReportViewMode]);
|
||||
|
||||
const fetchResidentReportIds = async () => {
|
||||
try {
|
||||
// Получаем период из ведомости
|
||||
const buildingsData = await authFetch(`/api/finance/buildings?reportId=${report.id}`).then(r => r.json());
|
||||
if (!buildingsData || buildingsData.length === 0) return;
|
||||
|
||||
// Берем период из первого дома
|
||||
const firstBuilding = buildingsData[0];
|
||||
const periodStart = firstBuilding.period_start;
|
||||
const periodEnd = firstBuilding.period_end;
|
||||
|
||||
const reportIds: Record<string, number> = {};
|
||||
|
||||
// Получаем все отчеты собственников
|
||||
const allReports = await apiClient.get<any[]>(`/api/pr/reports`);
|
||||
|
||||
for (const building of buildingsData) {
|
||||
// Ищем отчет для этого дома и периода
|
||||
const matchingReport = allReports.find((r: any) =>
|
||||
r.buildingId === building.id &&
|
||||
r.periodStart === periodStart &&
|
||||
r.periodEnd === periodEnd
|
||||
);
|
||||
|
||||
if (matchingReport) {
|
||||
reportIds[building.id] = matchingReport.id;
|
||||
}
|
||||
}
|
||||
|
||||
setResidentReportIds(reportIds);
|
||||
} catch (err) {
|
||||
console.warn('Не удалось загрузить ID отчетов собственников:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (buildings.length > 0) {
|
||||
// Группируем дома по участкам
|
||||
const grouped: Record<string, Building[]> = {};
|
||||
buildings.forEach(building => {
|
||||
const section = building.district_name || building.district_id || 'Без участка';
|
||||
if (!grouped[section]) {
|
||||
grouped[section] = [];
|
||||
}
|
||||
grouped[section].push(building);
|
||||
});
|
||||
setSections(grouped);
|
||||
}
|
||||
}, [buildings]);
|
||||
|
||||
const fetchReport76Rows = async () => {
|
||||
try {
|
||||
setLoading76Rows(true);
|
||||
const response = await authFetch(`/api/finance/reports/${report.id}/balance-sheet-76-rows`);
|
||||
if (!response.ok) throw new Error('Не удалось загрузить строки');
|
||||
const data = await response.json();
|
||||
setReport76Rows(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
console.error('Ошибка загрузки строк ОСВ 76:', err);
|
||||
setReport76Rows([]);
|
||||
} finally {
|
||||
setLoading76Rows(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBuildings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authFetch(`/api/finance/buildings?reportId=${report.id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить дома');
|
||||
}
|
||||
const data = await response.json();
|
||||
// Преобразуем snake_case из API в camelCase
|
||||
const formattedBuildings: Building[] = data.map((b: any) => {
|
||||
// Используем district_name, если он есть и не равен district_id, иначе используем district_id
|
||||
const districtName = b.district_name && b.district_name !== b.district_id
|
||||
? b.district_name
|
||||
: (b.district_id || undefined);
|
||||
|
||||
// Логируем для отладки
|
||||
if (b.district_id && !b.district_name) {
|
||||
console.warn(`[ReportDetailView] Участок ${b.district_id} не найден в таблице districts`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
address: b.address,
|
||||
district_id: b.district_id,
|
||||
district_name: districtName,
|
||||
total_income: parseFloat(b.total_income) || 0,
|
||||
total_expenses: parseFloat(b.total_expenses) || 0,
|
||||
balance: parseFloat(b.balance) || 0,
|
||||
reports_count: parseInt(b.reports_count) || 0
|
||||
};
|
||||
});
|
||||
setBuildings(formattedBuildings);
|
||||
} catch (err: any) {
|
||||
console.error('Ошибка загрузки домов:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAggregatedData = async () => {
|
||||
try {
|
||||
setLoadingAggregated(true);
|
||||
const params = new URLSearchParams();
|
||||
// Фильтруем по периоду только если период выбран
|
||||
if (selectedPeriodStart) params.append('periodStart', selectedPeriodStart);
|
||||
if (selectedPeriodEnd) params.append('periodEnd', selectedPeriodEnd);
|
||||
const url = `/api/finance/reports/${report.id}/aggregated${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
// Если ошибка 404, пробуем загрузить без фильтра по периоду
|
||||
if (response.status === 404 && (selectedPeriodStart || selectedPeriodEnd)) {
|
||||
const fallbackUrl = `/api/finance/reports/${report.id}/aggregated`;
|
||||
const fallbackResponse = await fetch(fallbackUrl);
|
||||
if (fallbackResponse.ok) {
|
||||
const data = await fallbackResponse.json();
|
||||
// Инициализируем период из данных
|
||||
if (data.periodStart && data.periodEnd) {
|
||||
setSelectedPeriodStart(data.periodStart);
|
||||
setSelectedPeriodEnd(data.periodEnd);
|
||||
}
|
||||
setAggregatedData({
|
||||
...data,
|
||||
title: 'Отчет по всем домам',
|
||||
subtitle: `Всего домов: ${data.buildingsCount}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('Не удалось загрузить агрегированные данные');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Инициализируем период из данных, если период еще не установлен
|
||||
if (!selectedPeriodStart && !selectedPeriodEnd && data.periodStart && data.periodEnd) {
|
||||
setSelectedPeriodStart(data.periodStart);
|
||||
setSelectedPeriodEnd(data.periodEnd);
|
||||
}
|
||||
|
||||
setAggregatedData({
|
||||
...data,
|
||||
title: 'Отчет по всем домам',
|
||||
subtitle: `Всего домов: ${data.buildingsCount}`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки агрегированных данных:', err);
|
||||
setAggregatedData(null);
|
||||
} finally {
|
||||
setLoadingAggregated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDistrictAggregatedData = async (districtId: string) => {
|
||||
try {
|
||||
setLoadingAggregated(true);
|
||||
const params = new URLSearchParams();
|
||||
if (selectedPeriodStart) params.append('periodStart', selectedPeriodStart);
|
||||
if (selectedPeriodEnd) params.append('periodEnd', selectedPeriodEnd);
|
||||
const url = `/api/finance/reports/${report.id}/districts/${districtId}/aggregated${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Не удалось загрузить данные по участку');
|
||||
const data = await response.json();
|
||||
setDistrictAggregatedData({
|
||||
...data,
|
||||
title: `Отчет по участку: ${data.districtName}`,
|
||||
subtitle: `Домов в участке: ${data.buildingsCount}`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки данных по участку:', err);
|
||||
} finally {
|
||||
setLoadingAggregated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBuildingReport = async (buildingId: string) => {
|
||||
try {
|
||||
setLoadingAggregated(true);
|
||||
|
||||
// Сначала пробуем загрузить существующий отчет из resident_reports
|
||||
const reportId = residentReportIds[buildingId];
|
||||
if (reportId) {
|
||||
try {
|
||||
const response = await fetch(`/api/pr/reports/${reportId}`);
|
||||
if (response.ok) {
|
||||
const reportData = await response.json();
|
||||
setSelectedBuildingReport(reportData);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Не удалось загрузить существующий отчет, генерируем на лету:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Если отчета нет или не удалось загрузить, генерируем на лету из данных ведомости
|
||||
const params = new URLSearchParams();
|
||||
if (selectedPeriodStart) params.append('periodStart', selectedPeriodStart);
|
||||
if (selectedPeriodEnd) params.append('periodEnd', selectedPeriodEnd);
|
||||
const url = `/api/finance/reports/${report.id}/buildings/${buildingId}/report${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить или сгенерировать отчет');
|
||||
}
|
||||
const reportData = await response.json();
|
||||
setSelectedBuildingReport(reportData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки отчета по дому:', err);
|
||||
setSelectedBuildingReport(null);
|
||||
} finally {
|
||||
setLoadingAggregated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBuildingAggregatedReport = async (buildingId: string) => {
|
||||
try {
|
||||
setLoadingAggregated(true);
|
||||
|
||||
// Загружаем агрегированный отчет по дому
|
||||
const params = new URLSearchParams();
|
||||
if (selectedPeriodStart) params.append('periodStart', selectedPeriodStart);
|
||||
if (selectedPeriodEnd) params.append('periodEnd', selectedPeriodEnd);
|
||||
const url = `/api/finance/reports/${report.id}/buildings/${buildingId}/aggregated${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить отчет по дому');
|
||||
}
|
||||
const reportData = await response.json();
|
||||
setBuildingAggregatedData(reportData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки агрегированного отчета по дому:', err);
|
||||
setBuildingAggregatedData(null);
|
||||
} finally {
|
||||
setLoadingAggregated(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const renderBuildingCard = (building: Building, onClick?: () => void) => {
|
||||
const CardContent = (
|
||||
<div className={`bg-white rounded-xl border-2 ${
|
||||
selectedBuilding === building.id
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-slate-200 hover:border-primary-400'
|
||||
} p-5 transition-all ${onClick ? 'cursor-pointer hover:shadow-lg' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Building2 className="w-5 h-5 text-primary-600 flex-shrink-0" />
|
||||
<h4 className="font-bold text-slate-800 line-clamp-2">{building.address}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
||||
Доходы:
|
||||
</span>
|
||||
<span className="font-bold text-emerald-600">
|
||||
{formatCurrency(building.total_income)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
||||
Расходы:
|
||||
</span>
|
||||
<span className="font-bold text-red-600">
|
||||
{formatCurrency(building.total_expenses)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-200">
|
||||
<span className="text-slate-700 font-medium flex items-center gap-1">
|
||||
<DollarSign className="w-4 h-4 text-primary-600" />
|
||||
Баланс:
|
||||
</span>
|
||||
<span className={`font-bold text-lg ${
|
||||
building.balance >= 0 ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}>
|
||||
{formatCurrency(building.balance)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Дополнительная статистика */}
|
||||
<div className="pt-2 border-t border-slate-100 space-y-1">
|
||||
{building.reports_count > 0 && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-500">Отчетов создано:</span>
|
||||
<span className="font-medium text-slate-700">{building.reports_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{building.total_expenses > 0 && building.total_income > 0 && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-500">Рентабельность:</span>
|
||||
<span className={`font-medium ${
|
||||
(building.total_income / building.total_expenses) >= 1 ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}>
|
||||
{Math.round((building.total_income / building.total_expenses) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{building.district_name && (
|
||||
<div className="flex items-center gap-1 text-xs text-slate-500 mb-3">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>Участок: {building.district_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка просмотра отчета собственника (если есть) */}
|
||||
{report.reportType === 'balance_sheet' && residentReportIds[building.id] && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Открываем отчет в новой вкладке
|
||||
const url = `/pr/reports/${residentReportIds[building.id]}`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
className="mt-3 w-full bg-primary-600 text-white px-4 py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-primary-700 transition-colors text-sm font-medium"
|
||||
title="Открыть отчет собственникам МКД для этого дома"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Отчет собственникам
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div key={building.id} onClick={onClick}>
|
||||
{CardContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={building.id}>{CardContent}</div>;
|
||||
};
|
||||
|
||||
if (loading && report.reportType !== 'balance_sheet_76') {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-slate-600">Загрузка данных...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (report.reportType === 'balance_sheet_76') {
|
||||
if (loading76Rows) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-slate-600">Загрузка лицевых счетов...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const formatNum = (n: number) => (n == null || Number.isNaN(n) ? '—' : Number(n).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
|
||||
<button onClick={onBack} className="p-2 hover:bg-slate-100 rounded-lg transition-colors flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-1 break-words">{report.filename}</h2>
|
||||
<p className="text-sm text-slate-500">Лицевые счета: {report76Rows.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<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 py-3 px-4 font-semibold text-slate-700">Лицевой счёт</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Сальдо на начало</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Обороты дебет</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Обороты кредит</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Сальдо на конец (дебет)</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-slate-700">Сальдо на конец (кредит)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report76Rows.map((row: any) => (
|
||||
<tr key={row.id ?? row.rowIndex} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-2 px-4 text-slate-800">{row.accountLabel ?? '—'}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.saldoStartDebet)}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.turnoverDebet)}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.turnoverCredit)}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.saldoEndDebet)}</td>
|
||||
<td className="py-2 px-4 text-right text-slate-700">{formatNum(row.saldoEndCredit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{report76Rows.length === 0 && (
|
||||
<div className="p-12 text-center text-slate-500">Нет данных по лицевым счетам</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Заголовок и навигация */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-1 break-words">{report.filename}</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{buildings.length} {buildings.length === 1 ? 'дом' : buildings.length < 5 ? 'дома' : 'домов'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Выбор периода */}
|
||||
{report.reportType === 'balance_sheet' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<label className="text-sm font-medium text-slate-700 whitespace-nowrap">Период отчета:</label>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-600 whitespace-nowrap">С:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedPeriodStart}
|
||||
onChange={(e) => setSelectedPeriodStart(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-600 whitespace-nowrap">По:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedPeriodEnd}
|
||||
onChange={(e) => setSelectedPeriodEnd(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки навигации */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('all');
|
||||
setSelectedBuilding(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
viewMode === 'all'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700 font-bold'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Grid className="w-6 h-6" />
|
||||
<span className="text-sm">Все дома</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('sections');
|
||||
setSelectedBuilding(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
viewMode === 'sections'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700 font-bold'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<List className="w-6 h-6" />
|
||||
<span className="text-sm">По участкам</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode('specific');
|
||||
setSelectedBuilding(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
viewMode === 'specific'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700 font-bold'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Home className="w-6 h-6" />
|
||||
<span className="text-sm">Конкретный дом</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Переключатель вида отображения */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setDisplayMode('grid')}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
displayMode === 'grid'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDisplayMode('list')}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
displayMode === 'list'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контент в зависимости от режима */}
|
||||
{viewMode === 'all' && (
|
||||
<>
|
||||
{loadingAggregated ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600 mx-auto mb-4" />
|
||||
<p className="text-slate-600">Загрузка агрегированного отчета...</p>
|
||||
</div>
|
||||
) : aggregatedData ? (
|
||||
<AggregatedReportView data={aggregatedData} />
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-12 text-center">
|
||||
<p className="text-slate-600">Нет данных для агрегированного отчета</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === 'sections' && (
|
||||
<>
|
||||
{selectedDistrict ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDistrict(null);
|
||||
setDistrictAggregatedData(null);
|
||||
}}
|
||||
className="mb-4 flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Назад к списку участков
|
||||
</button>
|
||||
{loadingAggregated ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600 mx-auto mb-4" />
|
||||
<p className="text-slate-600">Загрузка отчета по участку...</p>
|
||||
</div>
|
||||
) : districtAggregatedData ? (
|
||||
<AggregatedReportView data={districtAggregatedData} />
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-12 text-center">
|
||||
<p className="text-slate-600">Нет данных для отчета по участку</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(sections).map(([sectionName, sectionBuildings]) => {
|
||||
const districtId = sectionBuildings[0]?.district_id;
|
||||
const totalIncome = sectionBuildings.reduce((sum, b) => sum + b.total_income, 0);
|
||||
const totalExpenses = sectionBuildings.reduce((sum, b) => sum + b.total_expenses, 0);
|
||||
const totalBalance = sectionBuildings.reduce((sum, b) => sum + b.balance, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sectionName}
|
||||
onClick={() => districtId && setSelectedDistrict(districtId)}
|
||||
className="bg-white rounded-2xl border-2 border-slate-200 hover:border-primary-400 p-6 cursor-pointer transition-all hover:shadow-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MapPin className="w-5 h-5 text-primary-600" />
|
||||
<h3 className="text-lg font-bold text-slate-800">{sectionName}</h3>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600">Домов:</span>
|
||||
<span className="font-bold text-slate-800">{sectionBuildings.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
||||
Доходы:
|
||||
</span>
|
||||
<span className="font-bold text-emerald-600">{formatCurrency(totalIncome)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
||||
Расходы:
|
||||
</span>
|
||||
<span className="font-bold text-red-600">{formatCurrency(totalExpenses)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-200">
|
||||
<span className="text-slate-700 font-medium flex items-center gap-1">
|
||||
<DollarSign className="w-4 h-4 text-primary-600" />
|
||||
Баланс:
|
||||
</span>
|
||||
<span className={`font-bold text-lg ${
|
||||
totalBalance >= 0 ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}>
|
||||
{formatCurrency(totalBalance)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<p className="text-xs text-primary-600 font-medium text-center">
|
||||
Нажмите для просмотра отчета
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === 'specific' && (
|
||||
<>
|
||||
{selectedBuilding && (buildingAggregatedData || selectedBuildingReport) ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedBuilding(null);
|
||||
setBuildingAggregatedData(null);
|
||||
setSelectedBuildingReport(null);
|
||||
}}
|
||||
className="flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Назад к списку домов
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setBuildingReportViewMode('detailed')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
buildingReportViewMode === 'detailed'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Детальный отчет
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBuildingReportViewMode('aggregated')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
buildingReportViewMode === 'aggregated'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Агрегированный отчет
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loadingAggregated ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600 mx-auto mb-4" />
|
||||
<p className="text-slate-600">Загрузка отчета по дому...</p>
|
||||
</div>
|
||||
) : buildingReportViewMode === 'aggregated' && buildingAggregatedData ? (
|
||||
<AggregatedReportView data={buildingAggregatedData} />
|
||||
) : buildingReportViewMode === 'detailed' && selectedBuildingReport?.content ? (
|
||||
<ResidentReportView
|
||||
content={selectedBuildingReport.content}
|
||||
buildingAddress={selectedBuildingReport.address}
|
||||
period={selectedBuildingReport.periodStart && selectedBuildingReport.periodEnd
|
||||
? `${new Date(selectedBuildingReport.periodStart).toLocaleDateString('ru-RU')} - ${new Date(selectedBuildingReport.periodEnd).toLocaleDateString('ru-RU')}`
|
||||
: selectedBuildingReport.month}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-12 text-center">
|
||||
<p className="text-slate-600">Отчет по дому не найден</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4">Выберите дом</h3>
|
||||
<div className={displayMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-3'
|
||||
}>
|
||||
{buildings.map(building =>
|
||||
renderBuildingCard(building, () => setSelectedBuilding(building.id))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{buildings.length === 0 && (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-12">
|
||||
<div className="text-center mb-6">
|
||||
<Building2 className="w-12 h-12 text-amber-400 mx-auto mb-4" />
|
||||
<p className="text-slate-600 font-medium mb-2">Нет данных по домам для этого отчета</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Возможные причины:
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded-lg p-4 space-y-2 text-sm text-slate-700">
|
||||
<p>• Адреса домов в ведомости не совпадают с адресами в базе данных</p>
|
||||
<p>• Дома еще не добавлены в систему</p>
|
||||
<p>• Ошибка при парсинге ведомости</p>
|
||||
</div>
|
||||
{report.errorLog && report.errorLog.notFoundAddresses && (
|
||||
<div className="mt-4 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm font-medium text-amber-800 mb-2">
|
||||
⚠ Не найдено домов: {report.errorLog.notFoundAddresses.length}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 mb-3">
|
||||
{report.errorLog.message || 'Данные по этим адресам не были загружены. Создайте дома вручную в системе, затем загрузите отчет повторно.'}
|
||||
</p>
|
||||
<div className="text-xs text-amber-700 space-y-1 max-h-40 overflow-y-auto">
|
||||
{report.errorLog.notFoundAddresses.slice(0, 10).map((addr: string, idx: number) => (
|
||||
<div key={idx}>• {addr}</div>
|
||||
))}
|
||||
{report.errorLog.notFoundAddresses.length > 10 && (
|
||||
<div className="text-amber-600 font-medium">
|
||||
... и еще {report.errorLog.notFoundAddresses.length - 10} адресов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
282
components/finance/ReportProcessing.tsx
Executable file
282
components/finance/ReportProcessing.tsx
Executable file
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Loader2, CheckCircle2, XCircle, AlertTriangle, RefreshCw, Eye, FileText } from 'lucide-react';
|
||||
import { ProcessingJob, ProcessingError } from '../../types';
|
||||
|
||||
interface ReportProcessingProps {
|
||||
jobId: string;
|
||||
reportId?: number;
|
||||
onComplete?: () => void;
|
||||
onViewReports?: () => void;
|
||||
}
|
||||
|
||||
export const ReportProcessing: React.FC<ReportProcessingProps> = ({
|
||||
jobId,
|
||||
reportId,
|
||||
onComplete,
|
||||
onViewReports
|
||||
}) => {
|
||||
const [jobStatus, setJobStatus] = useState<ProcessingJob | null>(null);
|
||||
const [isPolling, setIsPolling] = useState(true);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId || !isPolling) return;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/finance/processing-status/${jobId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить статус обработки');
|
||||
}
|
||||
const status = await response.json();
|
||||
setJobStatus(status);
|
||||
|
||||
// Если обработка завершена, останавливаем опрос
|
||||
if (status.status === 'completed' || status.status === 'failed') {
|
||||
setIsPolling(false);
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения статуса:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Первый запрос сразу
|
||||
fetchStatus();
|
||||
|
||||
// Затем опрашиваем каждые 2 секунды
|
||||
const interval = setInterval(fetchStatus, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [jobId, isPolling, onComplete]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setIsPolling(true);
|
||||
setShowPreview(false);
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (!jobStatus) return <Loader2 className="w-6 h-6 animate-spin text-primary-600" />;
|
||||
|
||||
switch (jobStatus.status) {
|
||||
case 'processing':
|
||||
return <Loader2 className="w-6 h-6 animate-spin text-primary-600" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-6 h-6 text-emerald-600" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-6 h-6 text-red-600" />;
|
||||
default:
|
||||
return <Loader2 className="w-6 h-6 animate-spin text-slate-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!jobStatus) return 'Ожидание...';
|
||||
|
||||
switch (jobStatus.status) {
|
||||
case 'processing':
|
||||
return 'Обработка файла';
|
||||
case 'completed':
|
||||
return 'Обработка завершена успешно';
|
||||
case 'failed':
|
||||
return 'Обработка завершена с ошибками';
|
||||
default:
|
||||
return 'Ожидание...';
|
||||
}
|
||||
};
|
||||
|
||||
if (!jobStatus) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-center justify-center gap-3 py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
|
||||
<span className="text-slate-600">Загрузка статуса обработки...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Основной статус */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-slate-800">Обработка отчета</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon()}
|
||||
<span className="text-sm font-medium text-slate-700">{getStatusText()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Прогресс-бар */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-medium text-slate-600">Прогресс</span>
|
||||
<span className="text-xs font-bold text-primary-600">{jobStatus.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary-600 h-full transition-all duration-300 rounded-full"
|
||||
style={{ width: `${jobStatus.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Текущий этап */}
|
||||
{jobStatus.currentStep && (
|
||||
<div className="mb-6 p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm text-slate-700">
|
||||
<strong>Текущий этап:</strong> {jobStatus.currentStep}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Результаты */}
|
||||
{jobStatus.result && (
|
||||
<div className="mb-6 p-4 bg-emerald-50 rounded-lg border border-emerald-200">
|
||||
<h4 className="font-bold text-emerald-800 text-sm mb-2">Результаты обработки:</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-slate-600">Всего строк</p>
|
||||
<p className="font-bold text-slate-800">{jobStatus.result.totalRows}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Обработано</p>
|
||||
<p className="font-bold text-emerald-600">{jobStatus.result.processedRows}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Ошибок</p>
|
||||
<p className="font-bold text-red-600">{jobStatus.result.errorRows}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Домов найдено</p>
|
||||
<p className="font-bold text-primary-600">{jobStatus.result.buildingsFound || 0}</p>
|
||||
</div>
|
||||
{jobStatus.result.buildingsCreated > 0 && (
|
||||
<div>
|
||||
<p className="text-slate-600">Домов создано</p>
|
||||
<p className="font-bold text-emerald-600">{jobStatus.result.buildingsCreated}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Предпросмотр результатов */}
|
||||
{jobStatus.status === 'completed' && jobStatus.result && (
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="w-full flex items-center justify-center gap-2 p-3 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors text-sm font-medium text-slate-700"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
{showPreview ? 'Скрыть' : 'Показать'} предпросмотр данных
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Кнопка повтора при ошибках */}
|
||||
{jobStatus.status === 'failed' && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="w-full flex items-center justify-center gap-2 p-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-bold"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Повторить обработку
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Кнопка просмотра отчетов после успешной обработки */}
|
||||
{jobStatus.status === 'completed' && onViewReports && (
|
||||
<button
|
||||
onClick={onViewReports}
|
||||
className="w-full flex items-center justify-center gap-2 p-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-bold"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Просмотреть загруженные отчеты
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ошибки */}
|
||||
{jobStatus.errors && jobStatus.errors.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-red-200 shadow-sm p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<h3 className="text-lg font-bold text-red-800">
|
||||
Ошибки обработки ({jobStatus.errors.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{jobStatus.errors.map((error: ProcessingError, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-red-600">
|
||||
Строка {error.row}
|
||||
</span>
|
||||
{error.column && (
|
||||
<span className="text-xs text-slate-500">
|
||||
Колонка: {error.column}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-red-800 font-medium mb-2">{error.message}</p>
|
||||
{error.suggestion && (
|
||||
<p className="text-xs text-slate-600 italic">
|
||||
💡 {error.suggestion}
|
||||
</p>
|
||||
)}
|
||||
{error.data && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-slate-500 cursor-pointer">
|
||||
Показать данные строки
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-slate-100 rounded text-xs overflow-x-auto">
|
||||
{JSON.stringify(error.data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Предупреждения */}
|
||||
{jobStatus.warnings && jobStatus.warnings.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
<h3 className="text-lg font-bold text-amber-800">
|
||||
Предупреждения ({jobStatus.warnings.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{jobStatus.warnings.map((warning: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 bg-amber-50 rounded-lg border border-amber-200"
|
||||
>
|
||||
<p className="text-sm text-amber-800 font-medium mb-1">
|
||||
{typeof warning === 'string' ? warning : warning.message}
|
||||
</p>
|
||||
{typeof warning === 'object' && warning.suggestion && (
|
||||
<p className="text-xs text-amber-700 italic">
|
||||
💡 {warning.suggestion}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
141
components/finance/ReportTypesGrid.tsx
Executable file
141
components/finance/ReportTypesGrid.tsx
Executable file
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import { FileText, Receipt, CreditCard, Upload, FolderOpen, Users } from 'lucide-react';
|
||||
|
||||
interface ReportType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
reportType: 'salary' | 'balance_sheet_20' | 'balance_sheet_76' | 'balance_sheet' | 'bank_statement' | 'debtors';
|
||||
}
|
||||
|
||||
interface ReportTypesGridProps {
|
||||
onReportTypeClick: (reportType: ReportType) => void;
|
||||
onViewReportsClick?: () => void;
|
||||
totalReportsCount?: number;
|
||||
}
|
||||
|
||||
const reportTypes: ReportType[] = [
|
||||
{
|
||||
id: 'salary',
|
||||
name: 'Ведомость ЗП',
|
||||
description: 'Загрузка ведомости заработной платы из 1С',
|
||||
icon: <FileText className="w-8 h-8" />,
|
||||
color: 'text-purple-700',
|
||||
bgColor: 'bg-purple-50',
|
||||
borderColor: 'border-purple-200',
|
||||
reportType: 'salary'
|
||||
},
|
||||
{
|
||||
id: 'balance_sheet_20',
|
||||
name: 'Оборотно-сальдовая счет 20',
|
||||
description: 'Загрузка оборотно-сальдовой ведомости по счету 20',
|
||||
icon: <Receipt className="w-8 h-8" />,
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
reportType: 'balance_sheet_20'
|
||||
},
|
||||
{
|
||||
id: 'balance_sheet_76',
|
||||
name: 'Оборотно-сальдовая счет 76 субконто ЛС',
|
||||
description: 'Загрузка оборотно-сальдовой ведомости по счету 76',
|
||||
icon: <Receipt className="w-8 h-8" />,
|
||||
color: 'text-indigo-700',
|
||||
bgColor: 'bg-indigo-50',
|
||||
borderColor: 'border-indigo-200',
|
||||
reportType: 'balance_sheet_76'
|
||||
},
|
||||
{
|
||||
id: 'balance_sheet',
|
||||
name: 'Оборотно-сальдовая ведомость',
|
||||
description: 'Загрузка общей оборотно-сальдовой ведомости',
|
||||
icon: <Receipt className="w-8 h-8" />,
|
||||
color: 'text-cyan-700',
|
||||
bgColor: 'bg-cyan-50',
|
||||
borderColor: 'border-cyan-200',
|
||||
reportType: 'balance_sheet'
|
||||
},
|
||||
{
|
||||
id: 'debtors',
|
||||
name: 'Отчёт по должникам',
|
||||
description: 'Загрузка отчёта по задолженности (лицевые счета, долги)',
|
||||
icon: <Users className="w-8 h-8" />,
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
reportType: 'debtors'
|
||||
},
|
||||
{
|
||||
id: 'bank_statement',
|
||||
name: 'Выписка из банка',
|
||||
description: 'Загрузка банковской выписки',
|
||||
icon: <CreditCard className="w-8 h-8" />,
|
||||
color: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50',
|
||||
borderColor: 'border-emerald-200',
|
||||
reportType: 'bank_statement'
|
||||
}
|
||||
];
|
||||
|
||||
export const ReportTypesGrid: React.FC<ReportTypesGridProps> = ({
|
||||
onReportTypeClick,
|
||||
onViewReportsClick,
|
||||
totalReportsCount
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Кнопка просмотра загруженных отчетов */}
|
||||
{onViewReportsClick && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onViewReportsClick}
|
||||
className="bg-slate-600 text-white px-6 py-3 rounded-xl shadow-lg shadow-slate-500/30 flex items-center gap-2 font-bold hover:bg-slate-700 transition-all active:scale-95"
|
||||
>
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
Загруженные отчеты
|
||||
{totalReportsCount !== undefined && totalReportsCount > 0 && (
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded-full text-xs">
|
||||
{totalReportsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сетка типов отчетов для загрузки */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4">
|
||||
Загрузка отчетов из 1С / Банка
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{reportTypes.map((reportType) => (
|
||||
<button
|
||||
key={reportType.id}
|
||||
onClick={() => onReportTypeClick(reportType)}
|
||||
className={`${reportType.bgColor} ${reportType.borderColor} border-2 rounded-xl p-6 text-left hover:shadow-lg transition-all group`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`${reportType.color}`}>
|
||||
{reportType.icon}
|
||||
</div>
|
||||
<Upload className={`w-5 h-5 ${reportType.color} opacity-0 group-hover:opacity-100 transition-opacity`} />
|
||||
</div>
|
||||
|
||||
<h4 className={`font-bold text-lg mb-2 ${reportType.color}`}>
|
||||
{reportType.name}
|
||||
</h4>
|
||||
|
||||
<p className="text-sm text-slate-600">
|
||||
{reportType.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
343
components/finance/ReportUploader.tsx
Executable file
343
components/finance/ReportUploader.tsx
Executable file
@@ -0,0 +1,343 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, File, X, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { getAuthToken } from '../../services/apiClient';
|
||||
|
||||
interface ReportUploaderProps {
|
||||
onUploadSuccess?: (reportId: number, jobId: string) => void;
|
||||
onUploadError?: (error: string) => void;
|
||||
onClose?: () => void;
|
||||
onViewReports?: () => void;
|
||||
presetReportType?: 'salary' | 'balance_sheet_20' | 'balance_sheet_76' | 'balance_sheet' | 'bank_statement' | 'debtors' | 'other';
|
||||
reportTypeName?: string;
|
||||
}
|
||||
|
||||
export const ReportUploader: React.FC<ReportUploaderProps> = ({
|
||||
onUploadSuccess,
|
||||
onUploadError,
|
||||
onClose,
|
||||
onViewReports,
|
||||
presetReportType,
|
||||
reportTypeName
|
||||
}) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [reportType, setReportType] = useState<'debtors' | 'balance_sheet' | 'salary' | 'balance_sheet_20' | 'balance_sheet_76' | 'bank_statement' | 'other'>(
|
||||
presetReportType || 'other'
|
||||
);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'csv' || ext === 'xlsx' || ext === 'xls') {
|
||||
setSelectedFile(file);
|
||||
setUploadStatus('idle');
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
setErrorMessage('Неподдерживаемый формат файла. Разрешены только CSV и XLSX');
|
||||
setUploadStatus('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'csv' || ext === 'xlsx' || ext === 'xls') {
|
||||
setSelectedFile(file);
|
||||
setUploadStatus('idle');
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
setErrorMessage('Неподдерживаемый формат файла. Разрешены только CSV и XLSX');
|
||||
setUploadStatus('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
setUploadStatus('idle');
|
||||
setErrorMessage('');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
if (!getAuthToken()) {
|
||||
setErrorMessage('Требуется авторизация. Выполните вход в систему.');
|
||||
setUploadStatus('error');
|
||||
onUploadError?.('Требуется авторизация');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadStatus('idle');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('uploadedBy', 'Current User'); // Можно получить из контекста
|
||||
formData.append('reportType', getBackendReportType());
|
||||
// Отправляем также детальный тип для обработки на бэкенде
|
||||
if (reportType === 'balance_sheet_20' || reportType === 'balance_sheet_76' || reportType === 'salary' || reportType === 'bank_statement') {
|
||||
formData.append('detailedReportType', reportType);
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const uploadUrl = apiBase ? `${apiBase.replace(/\/$/, '')}/finance/upload-report` : '/api/finance/upload-report';
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
const msg = error.details || error.error || 'Ошибка загрузки файла';
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setUploadStatus('success');
|
||||
|
||||
if (onUploadSuccess) {
|
||||
onUploadSuccess(result.reportId, result.jobId);
|
||||
// Если есть обработка (jobId), не закрываем модальное окно - оно закроется автоматически при переходе к processing
|
||||
// Если обработки нет, закрываем через 2 секунды
|
||||
if (!result.jobId && onClose) {
|
||||
setTimeout(() => {
|
||||
handleRemoveFile();
|
||||
onClose();
|
||||
}, 2000);
|
||||
} else {
|
||||
// Очищаем файл, но не закрываем модальное окно (будет переход к processing)
|
||||
setTimeout(() => {
|
||||
handleRemoveFile();
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
// Если нет callback, просто очищаем файл
|
||||
setTimeout(() => {
|
||||
handleRemoveFile();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
setUploadStatus('error');
|
||||
setErrorMessage(error.message || 'Произошла ошибка при загрузке файла');
|
||||
if (onUploadError) {
|
||||
onUploadError(error.message);
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Маппинг типов для отправки на бэкенд
|
||||
const getBackendReportType = (): string => {
|
||||
if (reportType === 'balance_sheet_20' || reportType === 'balance_sheet_76' || reportType === 'balance_sheet') {
|
||||
return 'balance_sheet';
|
||||
}
|
||||
if (reportType === 'salary' || reportType === 'bank_statement') {
|
||||
return 'other'; // Пока что salary и bank_statement идут как 'other', можно расширить позже
|
||||
}
|
||||
return reportType;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-slate-800">
|
||||
{reportTypeName ? `Загрузить: ${reportTypeName}` : 'Загрузить отчеты из 1С / Банка'}
|
||||
</h3>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-100"
|
||||
title="Закрыть"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Выбор типа отчета (скрыт, если предустановлен) */}
|
||||
{!presetReportType && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Тип отчета
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReportType('debtors')}
|
||||
className={`p-3 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
reportType === 'debtors'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Должники
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReportType('balance_sheet')}
|
||||
className={`p-3 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
reportType === 'balance_sheet'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Оборотная сальдовая ведомость
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReportType('other')}
|
||||
className={`p-3 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
reportType === 'other'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-slate-200 hover:border-primary-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Другие отчеты
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{presetReportType && (
|
||||
<div className="mb-6 p-3 bg-primary-50 border border-primary-200 rounded-lg">
|
||||
<p className="text-sm text-primary-800">
|
||||
<strong>Тип отчета:</strong> {reportTypeName || presetReportType}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-all ${
|
||||
selectedFile
|
||||
? 'border-primary-300 bg-primary-50'
|
||||
: 'border-slate-300 hover:border-primary-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{!selectedFile ? (
|
||||
<>
|
||||
<Upload className="w-12 h-12 mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-sm font-medium text-slate-700 mb-2">
|
||||
Перетащите файл сюда или нажмите для выбора
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Поддерживаются форматы: CSV, XLSX (макс. 50 МБ)
|
||||
</p>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="bg-primary-600 text-white px-6 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Выбрать файл
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<File className="w-8 h-8 text-primary-600" />
|
||||
<div className="text-left">
|
||||
<p className="font-bold text-slate-800">{selectedFile.name}</p>
|
||||
<p className="text-xs text-slate-500">{formatFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveFile}
|
||||
className="ml-auto p-2 text-slate-400 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{uploadStatus === 'success' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-center gap-2 text-emerald-600">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Файл успешно загружен!</span>
|
||||
</div>
|
||||
{onViewReports && (
|
||||
<button
|
||||
onClick={onViewReports}
|
||||
className="w-full bg-emerald-600 text-white px-4 py-2 rounded-lg font-medium text-sm hover:bg-emerald-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Просмотреть загруженные отчеты
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'error' && (
|
||||
<div className="flex items-center justify-center gap-2 text-red-600">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || uploadStatus === 'success'}
|
||||
className="w-full bg-primary-600 text-white px-6 py-3 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Загрузка...
|
||||
</>
|
||||
) : uploadStatus === 'success' ? (
|
||||
'Загружено'
|
||||
) : (
|
||||
'Загрузить файл'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-xs text-blue-800">
|
||||
<strong>Важно:</strong> Убедитесь, что файл содержит колонку с адресами домов.
|
||||
Адреса должны совпадать с адресами домов в системе.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
292
components/finance/ReportsGrid.tsx
Executable file
292
components/finance/ReportsGrid.tsx
Executable file
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FileText, Calendar, User, AlertCircle, CheckCircle2, XCircle, Loader2, ArrowRight } from 'lucide-react';
|
||||
import { FinancialReport } from '../../types';
|
||||
import { authFetch } from '../../services/apiClient';
|
||||
import { readCache, saveCache } from '../../hooks/useCachedFetch';
|
||||
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
|
||||
|
||||
interface ReportsGridProps {
|
||||
onReportClick: (report: FinancialReport) => void;
|
||||
reportTypeFilter?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const reportTypeLabels: Record<string, string> = {
|
||||
debtors: 'Должники',
|
||||
balance_sheet: 'Оборотная сальдовая ведомость',
|
||||
balance_sheet_76: 'Лицевые счета (ОСВ 76)',
|
||||
other: 'Другие отчеты'
|
||||
};
|
||||
|
||||
const reportTypeColors: Record<string, string> = {
|
||||
debtors: 'bg-red-50 border-red-200 text-red-700',
|
||||
balance_sheet: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
balance_sheet_76: 'bg-violet-50 border-violet-200 text-violet-700',
|
||||
other: 'bg-slate-50 border-slate-200 text-slate-700'
|
||||
};
|
||||
|
||||
const CACHE_KEY = 'mkd_finance_reports_cache';
|
||||
|
||||
export const ReportsGrid: React.FC<ReportsGridProps> = ({
|
||||
onReportClick,
|
||||
reportTypeFilter,
|
||||
onBack
|
||||
}) => {
|
||||
const [filterType, setFilterType] = useState<string | null>(reportTypeFilter || null);
|
||||
const cached = readCache<FinancialReport[]>(CACHE_KEY, []);
|
||||
const [reports, setReports] = useState<FinancialReport[]>(cached);
|
||||
const [loading, setLoading] = useState(cached.length === 0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportTypeFilter) setFilterType(reportTypeFilter);
|
||||
}, [reportTypeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
}, [filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
const onRefresh = () => fetchReports(false);
|
||||
window.addEventListener(REFRESH_EVENTS.financeReports, onRefresh);
|
||||
return () => window.removeEventListener(REFRESH_EVENTS.financeReports, onRefresh);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => fetchReports(false), 10 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchReports = async (showSpinner = true) => {
|
||||
try {
|
||||
const c = readCache<FinancialReport[]>(CACHE_KEY, []);
|
||||
if (showSpinner && c.length === 0) setLoading(true);
|
||||
setError(null);
|
||||
const url = filterType
|
||||
? `/api/finance/reports?reportType=${encodeURIComponent(filterType)}`
|
||||
: '/api/finance/reports';
|
||||
|
||||
console.log('Загрузка отчетов:', url);
|
||||
const response = await authFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
|
||||
console.error('Ошибка ответа сервера:', response.status, errorData);
|
||||
throw new Error(errorData.error || `Ошибка ${response.status}: Не удалось загрузить отчеты`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Получены отчеты:', data);
|
||||
|
||||
// Преобразуем snake_case из API в camelCase для TypeScript
|
||||
const formattedReports: FinancialReport[] = data.map((r: any) => ({
|
||||
id: r.id,
|
||||
filename: r.filename,
|
||||
fileType: r.file_type,
|
||||
reportType: r.report_type,
|
||||
uploadedAt: r.uploaded_at,
|
||||
uploadedBy: r.uploaded_by,
|
||||
status: r.status,
|
||||
totalRows: r.total_rows,
|
||||
processedRows: r.processed_rows,
|
||||
errorRows: r.error_rows,
|
||||
mappingId: r.mapping_id
|
||||
}));
|
||||
setReports(formattedReports);
|
||||
saveCache(CACHE_KEY, formattedReports);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
console.error('Ошибка загрузки отчетов:', err);
|
||||
setError(err.message || 'Не удалось загрузить отчеты');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-5 h-5 text-emerald-600" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-600" />;
|
||||
case 'processing':
|
||||
return <Loader2 className="w-5 h-5 text-primary-600 animate-spin" />;
|
||||
default:
|
||||
return <AlertCircle className="w-5 h-5 text-amber-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600 mx-auto mb-4" />
|
||||
<p className="text-slate-600">Загрузка отчетов...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-red-200 shadow-sm p-6">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p className="font-medium">Ошибка: {error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Заголовок и кнопка назад */}
|
||||
{(onBack || reportTypeFilter) && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-slate-800">
|
||||
{reportTypeFilter ? reportTypeLabels[reportTypeFilter] : 'Загруженные отчеты'}
|
||||
</h3>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-primary-600 hover:text-primary-700 font-medium text-sm flex items-center gap-2"
|
||||
>
|
||||
← Назад к типам отчетов
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Фильтры */}
|
||||
{!reportTypeFilter && (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setFilterType(null)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
filterType === null
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Все отчеты
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('debtors')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
filterType === 'debtors'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Должники
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('balance_sheet')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
filterType === 'balance_sheet'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Оборотная сальдовая ведомость
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('balance_sheet_76')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
filterType === 'balance_sheet_76'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Лицевые счета (ОСВ 76)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('other')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
filterType === 'other'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Другие
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сетка отчетов */}
|
||||
{reports.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||||
<FileText className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||||
<p className="text-slate-600 font-medium mb-2">Нет загруженных отчетов</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Загрузите первый отчет на вкладке "Загрузить отчеты из 1С"
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{reports.map((report) => (
|
||||
<button
|
||||
key={report.id}
|
||||
onClick={() => onReportClick(report)}
|
||||
className="bg-white rounded-xl border-2 border-slate-200 hover:border-primary-400 hover:shadow-lg transition-all p-5 text-left group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary-600" />
|
||||
<span className={`px-2 py-1 rounded text-xs font-bold ${
|
||||
reportTypeColors[report.reportType || 'other']
|
||||
}`}>
|
||||
{reportTypeLabels[report.reportType || 'other']}
|
||||
</span>
|
||||
</div>
|
||||
{getStatusIcon(report.status)}
|
||||
</div>
|
||||
|
||||
<h4 className="font-bold text-slate-800 mb-2 line-clamp-2 group-hover:text-primary-600 transition-colors">
|
||||
{report.filename}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2 text-xs text-slate-600 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formatDate(report.uploadedAt)}</span>
|
||||
</div>
|
||||
{report.uploadedBy && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{report.uploadedBy}</span>
|
||||
</div>
|
||||
)}
|
||||
{report.totalRows && (
|
||||
<div>
|
||||
<span className="font-medium">Строк:</span> {report.totalRows} |
|
||||
<span className="text-emerald-600 font-medium ml-1">Обработано:</span> {report.processedRows}
|
||||
{report.errorRows > 0 && (
|
||||
<span className="text-red-600 font-medium ml-1">Ошибок: {report.errorRows}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-primary-600 font-medium text-sm group-hover:gap-3 transition-all">
|
||||
<span>Открыть</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user