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