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

318 lines
12 KiB
TypeScript
Executable File
Raw 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 {
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>
);
};