Improve delivery workflows and add versioned guided tours

This commit is contained in:
2026-10-08 01:35:24 +06:00
parent 1860e52b88
commit 55b5ca49e2
25 changed files with 933 additions and 1431 deletions
+110 -390
View File
@@ -1,417 +1,137 @@
import { useState, useEffect, useCallback } from 'react';
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, DeliveryStatus, PaymentStatus } from '../../types';
import type { Delivery, DeliveryRequestSource, PickupLocation, PaymentStatus } from '../../types';
type DeliveryInput = Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>;
interface DeliveryFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => void | Promise<void>;
onSubmit: (delivery: DeliveryInput) => void | Promise<void>;
initialData?: Delivery | null;
defaultDate?: string;
isSubmitting?: boolean;
}
// Phone validation regex for Kazakhstan numbers
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',
});
// City is not shown in UI but is included in the saved address (used for 2GIS search).
const CITY_LABEL = 'Кокшетау';
const requestSourceOptions = [
{ value: '', label: 'Выберите источник заявки' },
...deliveryRequestSourceOptions,
];
const paymentStatusOptions = [
{ value: 'prepaid', label: 'Предоплата' },
{ value: 'paid', label: 'Полная оплата' },
];
// 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;
const buildAddressString = (
street: string,
house: string,
apartment: string,
entrance: string,
): string => {
const parts: string[] = [CITY_LABEL];
if (street) parts.push(`ул. ${street}`);
if (house) parts.push(`д. ${house}`);
if (apartment) parts.push(`кв. ${apartment}`);
if (entrance) parts.push(`подъезд ${entrance}`);
return parts.join(', ');
};
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate, isSubmitting }: DeliveryFormProps) => {
const [formData, setFormData] = useState({
date: defaultDate || getTodayFrontend(),
pickupLocation: 'warehouse' as PickupLocation,
pickupLocation2: null as PickupLocation | null,
warehouseRequestSource: null as DeliveryRequestSource | null,
warehouseRequestSource2: null as DeliveryRequestSource | null,
productName: '',
productName2: '',
customerName: '',
address: '',
street: '',
house: '',
apartment: '',
entrance: '',
floor: '',
phone: '',
additionalPhone: '',
hasElevator: false,
paymentStatus: 'prepaid' as PaymentStatus,
serviceInfo: '',
comment: '',
status: 'new' as DeliveryStatus,
});
const [showSecondPickup, setShowSecondPickup] = useState(false);
useEffect(() => {
if (initialData) {
setFormData({
date: initialData.date,
pickupLocation: initialData.pickupLocation,
pickupLocation2: initialData.pickupLocation2 || null,
warehouseRequestSource: initialData.warehouseRequestSource || null,
warehouseRequestSource2: initialData.warehouseRequestSource2 || null,
productName: initialData.productName,
productName2: initialData.productName2 || '',
customerName: initialData.customerName,
address: initialData.address,
street: initialData.street,
house: initialData.house,
apartment: initialData.apartment || '',
entrance: initialData.entrance || '',
floor: initialData.floor || '',
phone: initialData.phone,
additionalPhone: initialData.additionalPhone || '',
hasElevator: initialData.hasElevator,
paymentStatus: initialData.paymentStatus || 'prepaid',
serviceInfo: initialData.serviceInfo || '',
comment: initialData.comment,
status: initialData.status,
});
setShowSecondPickup(!!initialData.pickupLocation2);
} else if (defaultDate) {
setFormData(prev => ({ ...prev, date: defaultDate }));
}
}, [initialData, defaultDate, isOpen]);
const validatePhone = useCallback((phone: string): boolean => {
if (!phone) return false;
return PHONE_REGEX.test(phone);
}, []);
const isPhoneValid = !formData.phone || validatePhone(formData.phone);
const isAdditionalPhoneValid = !formData.additionalPhone || validatePhone(formData.additionalPhone);
const isWarehouseRequestSourceValid = formData.pickupLocation !== 'warehouse' || !!formData.warehouseRequestSource;
const isWarehouseRequestSource2Valid = !showSecondPickup || formData.pickupLocation2 !== 'warehouse' || !!formData.warehouseRequestSource2;
const isFormValid = formData.productName && formData.phone && isPhoneValid && formData.customerName && formData.street && formData.house && isWarehouseRequestSourceValid && isWarehouseRequestSource2Valid;
const handlePickupLocationChange = (pickupLocation: PickupLocation) => {
setFormData({
...formData,
pickupLocation,
warehouseRequestSource: pickupLocation === 'warehouse' ? formData.warehouseRequestSource : 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 handlePickupLocation2Change = (pickupLocation2: PickupLocation) => {
setFormData({
...formData,
pickupLocation2,
warehouseRequestSource2: pickupLocation2 === 'warehouse' ? formData.warehouseRequestSource2 : null,
});
};
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!isFormValid) return;
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 {
const payload = {
...formData,
address: buildAddressString(formData.street, formData.house, formData.apartment, formData.entrance),
};
await onSubmit(payload);
if (!initialData) {
setFormData({
date: defaultDate || getTodayFrontend(),
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',
});
setShowSecondPickup(false);
}
await onSubmit({ ...data, productName: data.productName.trim(), customerName: data.customerName.trim(), street: data.street.trim(), house: data.house.trim(), address });
onClose();
} catch {
// Error is handled by parent, keep form open
setError('Не удалось сохранить доставку. Проверьте соединение и попробуйте ещё раз. Все введённые данные сохранены в форме.');
} finally {
submitting.current = false;
setSubmitting(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={initialData ? 'Редактировать доставку' : 'Новая доставка'}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
Отмена
<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>
<Button type="submit" form="delivery-form" disabled={isSubmitting || !isFormValid}>
{isSubmitting ? 'Сохранение...' : initialData ? 'Сохранить' : 'Создать'}
</Button>
</>
}
>
<form id="delivery-form" onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-[#1b1b1d] mb-1">
Дата доставки
</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>
<input
type="date"
value={formatDateForInput(formData.date)}
onChange={(e) => setFormData({ ...formData, date: parseDateFromInput(e.target.value) })}
className="w-full px-3 py-2 bg-[#f5f3f5] border border-[#c5c6cd] rounded-md text-[#1b1b1d] focus:outline-none focus:ring-2 focus:ring-[#1B263B] focus:border-transparent transition-colors"
required
/>
</div>
<Select
label="Место загрузки"
value={formData.pickupLocation}
onChange={(e) => handlePickupLocationChange(e.target.value as PickupLocation)}
options={pickupOptions}
/>
{formData.pickupLocation === 'warehouse' && (
<Select
label="От кого заявка *"
value={formData.warehouseRequestSource || ''}
onChange={(e) => setFormData({ ...formData, warehouseRequestSource: e.target.value ? e.target.value as DeliveryRequestSource : null })}
options={requestSourceOptions}
required
error={!isWarehouseRequestSourceValid ? 'Выберите, от кого исходит заявка' : undefined}
/>
)}
<Input
label="Название товара"
value={formData.productName}
onChange={(e) => setFormData({ ...formData, productName: e.target.value })}
placeholder="Введите название товара"
required
/>
{/* Address fields */}
<div className="bg-[#f5f3f5] rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-[#1b1b1d]">Адрес доставки</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[#75777d] mb-1">Улица *</label>
<input
type="text"
value={formData.street}
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
required
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Дом *</label>
<input
type="text"
value={formData.house}
onChange={(e) => setFormData({ ...formData, house: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
required
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Квартира</label>
<input
type="text"
value={formData.apartment}
onChange={(e) => setFormData({ ...formData, apartment: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Подъезд</label>
<input
type="text"
value={formData.entrance}
onChange={(e) => setFormData({ ...formData, entrance: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Этаж</label>
<input
type="text"
value={formData.floor}
onChange={(e) => setFormData({ ...formData, floor: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
/>
</div>
</div>
</div>
<Input
label="ФИО клиента *"
value={formData.customerName}
onChange={(e) => setFormData({ ...formData, customerName: e.target.value })}
placeholder="Иванов Иван Иванович"
required
/>
<Input
label="Телефон покупателя *"
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
onFocus={(e) => {
if (!e.target.value) {
setFormData({ ...formData, phone: '+7' });
}
}}
placeholder="+7 (776)-567-89-01"
required
aria-invalid={!isPhoneValid}
aria-describedby={!isPhoneValid ? 'phone-error' : undefined}
/>
{!isPhoneValid && formData.phone && (
<p id="phone-error" className="text-sm text-red-500 mt-1">
Введите корректный номер: +7 (XXX) XXX-XX-XX
</p>
)}
<Input
label="Дополнительный номер телефона"
type="tel"
value={formData.additionalPhone}
onChange={(e) => setFormData({ ...formData, additionalPhone: e.target.value })}
onFocus={(e) => {
if (!e.target.value) {
setFormData({ ...formData, additionalPhone: '+7' });
}
}}
placeholder="+7 (776)-567-89-01"
aria-invalid={!isAdditionalPhoneValid}
aria-describedby={!isAdditionalPhoneValid ? 'additional-phone-error' : undefined}
/>
{!isAdditionalPhoneValid && formData.additionalPhone && (
<p id="additional-phone-error" className="text-sm text-red-500 mt-1">
Введите корректный номер: +7 (XXX) XXX-XX-XX
</p>
)}
{/* Second pickup location */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="hasSecondPickup"
checked={showSecondPickup}
onChange={(e) => {
const checked = e.target.checked;
setShowSecondPickup(checked);
setFormData({
...formData,
pickupLocation2: checked ? formData.pickupLocation2 || 'warehouse' : null,
warehouseRequestSource2: checked ? formData.warehouseRequestSource2 : null,
productName2: checked ? formData.productName2 : '',
});
}}
className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]"
/>
<label htmlFor="hasSecondPickup" className="text-sm text-[#1b1b1d]">
Добавить вторую точку загрузки
</label>
</div>
{showSecondPickup && (
<div className="bg-[#f5f3f5] rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-[#1b1b1d]">Вторая точка загрузки</p>
<Select
label="Место загрузки 2"
value={formData.pickupLocation2 || ''}
onChange={(e) => handlePickupLocation2Change(e.target.value as PickupLocation)}
options={pickupOptions}
/>
{formData.pickupLocation2 === 'warehouse' && (
<Select
label="От кого заявка 2 *"
value={formData.warehouseRequestSource2 || ''}
onChange={(e) => setFormData({ ...formData, warehouseRequestSource2: e.target.value ? e.target.value as DeliveryRequestSource : null })}
options={requestSourceOptions}
required
error={!isWarehouseRequestSource2Valid ? 'Выберите, от кого исходит заявка' : undefined}
/>
)}
<Input
label="Название товара 2"
value={formData.productName2}
onChange={(e) => setFormData({ ...formData, productName2: e.target.value })}
placeholder="Название товара со второй точки"
/>
{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>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="hasElevator"
checked={formData.hasElevator}
onChange={(e) => setFormData({ ...formData, hasElevator: e.target.checked })}
className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]"
/>
<label htmlFor="hasElevator" className="text-sm text-[#1b1b1d]">
Наличие лифта
</label>
</div>
<Select
label="Оплата"
value={formData.paymentStatus}
onChange={(e) => setFormData({ ...formData, paymentStatus: e.target.value as PaymentStatus })}
options={paymentStatusOptions}
required
/>
<Input
label="Услуги (сборка, подъём)"
value={formData.serviceInfo}
onChange={(e) => setFormData({ ...formData, serviceInfo: e.target.value })}
placeholder="Сборка 5000 тг, подъём на этаж 3000 тг"
/>
<Input
label="Комментарий"
value={formData.comment}
onChange={(e) => setFormData({ ...formData, comment: 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={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>
);
};
}