245 lines
9.7 KiB
TypeScript
245 lines
9.7 KiB
TypeScript
|
|
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>
|
|||
|
|
);
|
|||
|
|
};
|