138 lines
11 KiB
TypeScript
138 lines
11 KiB
TypeScript
import { useRef, useState, type FormEvent } from 'react';
|
|
import { AlertCircle, Loader2 } from 'lucide-react';
|
|
import { Button, Input, Select, Modal } from '../ui';
|
|
import { deliveryRequestSourceOptions, pickupOptions } from '../../constants/pickup';
|
|
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
|
|
import type { Delivery, DeliveryRequestSource, PickupLocation, PaymentStatus } from '../../types';
|
|
|
|
type DeliveryInput = Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>;
|
|
interface DeliveryFormProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (delivery: DeliveryInput) => void | Promise<void>;
|
|
initialData?: Delivery | null;
|
|
defaultDate?: string;
|
|
}
|
|
const PHONE_REGEX = /^\+7\s?\(?\d{3}\)?\s?\d{3}[\s-]?\d{2}[\s-]?\d{2}$/;
|
|
const requestSourceOptions = [{ value: '', label: 'Выберите источник заявки' }, ...deliveryRequestSourceOptions];
|
|
const paymentOptions = [{ value: 'prepaid', label: 'Предоплата' }, { value: 'paid', label: 'Полная оплата' }];
|
|
const emptyDelivery = (date: string): DeliveryInput => ({
|
|
date, pickupLocation: 'warehouse', pickupLocation2: null,
|
|
warehouseRequestSource: null, warehouseRequestSource2: null,
|
|
productName: '', productName2: '', customerName: '', address: '', street: '', house: '',
|
|
apartment: '', entrance: '', floor: '', phone: '', additionalPhone: '', hasElevator: false,
|
|
paymentStatus: 'prepaid', serviceInfo: '', comment: '', status: 'new',
|
|
});
|
|
|
|
// A fresh mount on each opening prevents edited data leaking into a new delivery.
|
|
export const DeliveryForm = (props: DeliveryFormProps) => props.isOpen
|
|
? <DeliveryFormContent {...props} key={props.initialData?.id || 'new'} /> : null;
|
|
|
|
function DeliveryFormContent({ onClose, onSubmit, initialData, defaultDate }: DeliveryFormProps) {
|
|
const [baseline] = useState<DeliveryInput>(() => initialData ? { ...initialData } : emptyDelivery(defaultDate || getTodayFrontend()));
|
|
const [data, setData] = useState<DeliveryInput>(baseline);
|
|
const [secondPickup, setSecondPickup] = useState(!!baseline.pickupLocation2);
|
|
const [isSubmitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [attempted, setAttempted] = useState(false);
|
|
const submitting = useRef(false);
|
|
const dirty = JSON.stringify(data) !== JSON.stringify(baseline);
|
|
const update = <K extends keyof DeliveryInput>(key: K, value: DeliveryInput[K]) => setData(prev => ({ ...prev, [key]: value }));
|
|
const phoneError = data.phone && !PHONE_REGEX.test(data.phone) ? 'Введите номер в формате +7 (776) 567-89-01' : undefined;
|
|
const extraPhoneError = data.additionalPhone && !PHONE_REGEX.test(data.additionalPhone) ? 'Проверьте дополнительный номер или очистите поле' : undefined;
|
|
const sourceMissing = data.pickupLocation === 'warehouse' && !data.warehouseRequestSource;
|
|
const secondSourceMissing = secondPickup && data.pickupLocation2 === 'warehouse' && !data.warehouseRequestSource2;
|
|
const requestClose = () => {
|
|
if (submitting.current) return;
|
|
if (!dirty || window.confirm('Закрыть форму? Несохранённые изменения будут потеряны.')) onClose();
|
|
};
|
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
setAttempted(true);
|
|
if (submitting.current) return;
|
|
if (phoneError || extraPhoneError || sourceMissing || secondSourceMissing) {
|
|
setError('Проверьте отмеченные поля. Данные остаются в форме.');
|
|
return;
|
|
}
|
|
submitting.current = true;
|
|
setSubmitting(true);
|
|
setError('');
|
|
const address = ['Кокшетау', `ул. ${data.street.trim()}`, `д. ${data.house.trim()}`,
|
|
data.apartment && `кв. ${data.apartment}`, data.entrance && `подъезд ${data.entrance}`].filter(Boolean).join(', ');
|
|
try {
|
|
await onSubmit({ ...data, productName: data.productName.trim(), customerName: data.customerName.trim(), street: data.street.trim(), house: data.house.trim(), address });
|
|
onClose();
|
|
} catch {
|
|
setError('Не удалось сохранить доставку. Проверьте соединение и попробуйте ещё раз. Все введённые данные сохранены в форме.');
|
|
} finally {
|
|
submitting.current = false;
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
return (
|
|
<Modal isOpen onClose={requestClose} closeDisabled={isSubmitting} title={initialData ? 'Редактировать доставку' : 'Новая доставка'}
|
|
footer={<div className="w-full">
|
|
{error && <div role="alert" className="mb-3 flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800"><AlertCircle size={18} className="mt-0.5 shrink-0" /><p>{error}</p></div>}
|
|
<div className="flex justify-end gap-3"><Button variant="ghost" onClick={requestClose} disabled={isSubmitting}>Отмена</Button>
|
|
<Button type="submit" form="delivery-form" disabled={isSubmitting}>
|
|
{isSubmitting && <Loader2 size={18} className="mr-2 animate-spin" />}
|
|
{isSubmitting ? 'Сохранение…' : initialData ? 'Сохранить' : 'Создать доставку'}
|
|
</Button>
|
|
</div>
|
|
</div>}>
|
|
<form id="delivery-form" onSubmit={handleSubmit} className="space-y-7" aria-busy={isSubmitting}>
|
|
<p className="text-sm text-[#666870]">Поля со звёздочкой обязательны.</p>
|
|
<fieldset disabled={isSubmitting} className="space-y-4">
|
|
<legend className="mb-3 text-base font-semibold">Дата и загрузка</legend>
|
|
<Input label="Дата доставки *" type="date" value={formatDateForInput(data.date)} required onChange={e => update('date', parseDateFromInput(e.target.value))} />
|
|
<Select label="Место загрузки" value={data.pickupLocation} options={pickupOptions} onChange={e => {
|
|
const pickupLocation = e.target.value as PickupLocation;
|
|
setData(prev => ({ ...prev, pickupLocation, warehouseRequestSource: pickupLocation === 'warehouse' ? prev.warehouseRequestSource : null }));
|
|
}} />
|
|
{data.pickupLocation === 'warehouse' && <Select label="От кого заявка *" value={data.warehouseRequestSource || ''} options={requestSourceOptions} required
|
|
error={attempted && sourceMissing ? 'Выберите источник заявки' : undefined}
|
|
onChange={e => update('warehouseRequestSource', e.target.value as DeliveryRequestSource || null)} />}
|
|
<Input label="Название товара *" value={data.productName} required pattern=".*\S.*" onChange={e => update('productName', e.target.value)} placeholder="Например, диван и два кресла" />
|
|
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm font-medium">
|
|
<input type="checkbox" className="h-5 w-5" checked={secondPickup} onChange={e => {
|
|
setSecondPickup(e.target.checked);
|
|
setData(prev => ({ ...prev, pickupLocation2: e.target.checked ? 'warehouse' : null, warehouseRequestSource2: null, productName2: '' }));
|
|
}} />Добавить вторую точку загрузки
|
|
</label>
|
|
{secondPickup && <div className="space-y-4 border-t border-[#e4e2e4] pt-4">
|
|
<Select label="Второе место загрузки" value={data.pickupLocation2 || 'warehouse'} options={pickupOptions} onChange={e => {
|
|
const pickupLocation2 = e.target.value as PickupLocation;
|
|
setData(prev => ({ ...prev, pickupLocation2, warehouseRequestSource2: pickupLocation2 === 'warehouse' ? prev.warehouseRequestSource2 : null }));
|
|
}} />
|
|
{data.pickupLocation2 === 'warehouse' && <Select label="От кого вторая заявка *" value={data.warehouseRequestSource2 || ''} options={requestSourceOptions} required
|
|
error={attempted && secondSourceMissing ? 'Выберите источник второй заявки' : undefined}
|
|
onChange={e => update('warehouseRequestSource2', e.target.value as DeliveryRequestSource || null)} />}
|
|
<Input label="Товар со второй точки" value={data.productName2 || ''} onChange={e => update('productName2', e.target.value)} />
|
|
</div>}
|
|
</fieldset>
|
|
<fieldset disabled={isSubmitting} className="space-y-4 border-t border-[#e4e2e4] pt-5">
|
|
<legend className="text-base font-semibold">Адрес доставки</legend>
|
|
<Input label="Улица *" value={data.street} required pattern=".*\S.*" autoComplete="address-line1" onChange={e => update('street', e.target.value)} />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input label="Дом *" value={data.house} required pattern=".*\S.*" onChange={e => update('house', e.target.value)} />
|
|
<Input label="Квартира" value={data.apartment || ''} onChange={e => update('apartment', e.target.value)} />
|
|
<Input label="Подъезд" value={data.entrance || ''} onChange={e => update('entrance', e.target.value)} />
|
|
<Input label="Этаж" value={data.floor || ''} onChange={e => update('floor', e.target.value)} />
|
|
</div>
|
|
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm font-medium"><input type="checkbox" className="h-5 w-5" checked={data.hasElevator} onChange={e => update('hasElevator', e.target.checked)} />Есть лифт</label>
|
|
</fieldset>
|
|
<fieldset disabled={isSubmitting} className="space-y-4 border-t border-[#e4e2e4] pt-5">
|
|
<legend className="text-base font-semibold">Клиент и оплата</legend>
|
|
<Input label="ФИО клиента *" value={data.customerName} required pattern=".*\S.*" autoComplete="name" onChange={e => update('customerName', e.target.value)} />
|
|
<Input label="Телефон покупателя *" type="tel" inputMode="tel" autoComplete="tel" value={data.phone} required placeholder="+7 (776) 567-89-01" error={phoneError}
|
|
onChange={e => update('phone', e.target.value)} />
|
|
<Input label="Дополнительный телефон" type="tel" inputMode="tel" value={data.additionalPhone || ''} placeholder="Необязательно" error={extraPhoneError} onChange={e => update('additionalPhone', e.target.value)} />
|
|
<Select label="Оплата" value={data.paymentStatus} options={paymentOptions} onChange={e => update('paymentStatus', e.target.value as PaymentStatus)} />
|
|
<Input label="Услуги: сборка, подъём" value={data.serviceInfo || ''} placeholder="Например, сборка 5000 тг" onChange={e => update('serviceInfo', e.target.value)} />
|
|
<Input label="Комментарий" value={data.comment} placeholder="Что важно знать доставщику" onChange={e => update('comment', e.target.value)} />
|
|
</fieldset>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|