Files
mkd/components/finance/PaymentInvoiceForm.tsx
2026-02-04 00:17:04 +05:00

966 lines
47 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};