diff --git a/frontend/UI-REVIEW.md b/frontend/UI-REVIEW.md
new file mode 100644
index 0000000..a350357
--- /dev/null
+++ b/frontend/UI-REVIEW.md
@@ -0,0 +1,64 @@
+# Просмотр UI/UX правок
+
+Ветка: `codex/ui-ux-guided-tour`.
+
+## Локальный просмотр с вымышленными данными
+
+Из папки `frontend`:
+
+```sh
+npm run build
+npm run preview:demo
+```
+
+Откройте http://127.0.0.1:4174. Демо-вход: `demo` / `demo123`.
+Это отдельный сервер с заявками в памяти; он не подключается к рабочему backend.
+Перезапуск сбрасывает демонстрационные изменения. Номера телефонов вымышленные.
+Остановить сервер: Ctrl+C. Демо-сервер не входит в production bundle.
+
+## Что проверить
+
+- Главная страница: быстрый переход к сегодняшним заявкам, календарь, ближайшие даты.
+- Список: соседние дни, поиск, фильтры места загрузки/статуса, сброс фильтров.
+- На телефоне дополнительные фильтры раскрываются кнопкой «Фильтры».
+- Карточки: адрес и контакты первыми, крупные действия, условия доставки.
+- Таблица: товар с загрузкой и клиент с контактами в одной ячейке.
+- Изменение статуса сохраняет представление и соседние заявки; блокируется только текущая доставка.
+- Перенос заявки сохраняет открытый день: перенесённая заявка исчезает из него, новая дата указана в подтверждении.
+- Форма: отдельные группы, прокрутка содержимого, закреплённые действия; ошибки не стирают введённые данные.
+- Escape закрывает диалог; с изменёнными данными запрашивается подтверждение. Во время сохранения закрытие и повторная отправка блокируются.
+
+## Обучение
+
+Два коротких тура: план доставок (4 шага), список (до 6 шагов).
+Тур автоматически появляется при первом открытии соответствующего экрана после обновления.
+Если заявок нет, шаги про конкретную заявку пропускаются.
+«Пропустить», крестик и Escape завершают показ; «Помощь» повторяет тур текущего экрана.
+
+Результат хранится в localStorage отдельно для каждого пользователя, версии тура и экрана.
+На другом устройстве подсказки появятся снова. При недоступном хранилище тур остаётся работоспособным,
+но после перезагрузки может появиться повторно.
+
+Для следующего изменения рабочего процесса увеличьте `TOUR_VERSION`
+в `src/components/ui/GuidedTour.tsx` и обновите соответствующие шаги.
+Версия приложения сама по себе не запускает обучение заново.
+
+## Проверки
+
+```sh
+npm run lint
+npm test
+npm run build
+```
+
+Регрессионные тесты проверяют перенос даты, проброс ошибки сохранения, независимость
+обновления счётчиков от сохранения, локальное состояние смены статуса и гонку запросов разных дат.
+
+Для проверки ошибки в демо перед следующим сохранением:
+
+```sh
+curl -X POST http://127.0.0.1:4174/__demo__/fail-next
+```
+
+Первое сохранение завершится ошибкой, повторное с теми же данными — успешно.
+Этот служебный адрес существует только в демо-сервере.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 5e6b472..9727b56 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(['dist', 'dev-dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
diff --git a/frontend/index.html b/frontend/index.html
index 26a95b3..fc2cf04 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
-
+
diff --git a/frontend/package.json b/frontend/package.json
index 72c2df9..f91a516 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "node --test tests/*.test.mjs",
+ "preview:demo": "node scripts/preview-demo.mjs"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
diff --git a/frontend/scripts/preview-demo.mjs b/frontend/scripts/preview-demo.mjs
new file mode 100644
index 0000000..88e2095
--- /dev/null
+++ b/frontend/scripts/preview-demo.mjs
@@ -0,0 +1,66 @@
+// Local, in-memory review environment. It serves the production build and
+// fictional API data on its own loopback port, never contacts the real backend.
+import { createServer } from 'node:http';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { resolve, sep } from 'node:path';
+import { randomUUID } from 'node:crypto';
+
+const root = resolve(fileURLToPath(new URL('../dist/', import.meta.url)));
+const dateKey = date => [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0')].join('-');
+const today = dateKey(new Date());
+const tomorrowDate = new Date(); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
+const tomorrow = dateKey(tomorrowDate);
+const base = { date: today, pickup_location: 'symbat', pickup_location_2: null, warehouse_request_source: null, warehouse_request_source_2: null, product_name_2: null, address: 'Кокшетау, Тестовая улица, 1', street: 'Тестовая', house: '1', apartment: '12', entrance: '2', floor: '4', phone: '+7 (000) 000-00-00', additional_phone: null, has_elevator: true, payment_status: 'prepaid', service_info: null, comment: '', status: 'new', created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
+let deliveries = [
+ { ...base, id: 'demo-1', customer_name: 'Демо-клиент Анна', product_name: 'Диван «Комфорт»', service_info: 'Сборка после доставки', comment: 'Позвонить за 30 минут до приезда.' },
+ { ...base, id: 'demo-2', customer_name: 'Демо-клиент Иван', product_name: 'Обеденный стол и четыре стула', street: 'Примерная', house: '24А', apartment: null, entrance: '1', floor: '2', pickup_location: 'warehouse', warehouse_request_source: 'nursaya', has_elevator: false, payment_status: 'paid' },
+ { ...base, id: 'demo-3', customer_name: 'Демо-клиент Мария', product_name: 'Кровать и матрас', street: 'Учебная', house: '7', status: 'delivered', payment_status: 'paid' },
+ { ...base, id: 'demo-4', date: tomorrow, customer_name: 'Демо-клиент Алексей', product_name: 'Шкаф', pickup_location: 'galaktika' },
+];
+deliveries = deliveries.map(d => ({ ...d, address: `Кокшетау, ул. ${d.street}, д. ${d.house}` }));
+let failNext = false;
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+const json = (response, status, data) => { response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); response.end(JSON.stringify(data)); };
+const toDate = value => { const [day, month, year] = value.split('-'); return `${year}-${month}-${day}`; };
+const server = createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url, 'http://localhost');
+ if (url.pathname.startsWith('/api/') || url.pathname === '/__demo__/fail-next') {
+ let body = ''; for await (const chunk of request) body += chunk;
+ const data = body ? JSON.parse(body) : {};
+ if (url.pathname === '/__demo__/fail-next') { failNext = true; return json(response, 200, { ok: true }); }
+ if (url.pathname === '/api/auth/login') {
+ if (data.username !== 'demo' || data.password !== 'demo123') return json(response, 401, { error: 'Демо-вход: demo / demo123' });
+ return json(response, 200, { token: `demo.${Buffer.from(JSON.stringify({ sub: 'preview-demo-user' })).toString('base64')}.demo` });
+ }
+ if (url.pathname === '/api/ws') return json(response, 501, { error: 'No websocket in demo' });
+ if (request.method === 'GET' && url.pathname === '/api/deliveries/count') {
+ const dates = [...new Set(deliveries.map(d => d.date))];
+ return json(response, 200, { counts: dates.map(date => ({ date, count: deliveries.filter(d => d.date === date).length })) });
+ }
+ if (request.method === 'GET' && url.pathname === '/api/deliveries') return json(response, 200, { deliveries: deliveries.filter(d => d.date === toDate(url.searchParams.get('date'))) });
+ await sleep(500);
+ if (failNext) { failNext = false; return json(response, 500, { error: 'Simulated failure' }); }
+ if (request.method === 'POST' && url.pathname === '/api/deliveries') {
+ const id = randomUUID(); deliveries.push({ ...base, ...data, id, date: toDate(data.date) }); return json(response, 200, { id, message: 'created' });
+ }
+ const id = url.pathname.split('/')[3];
+ const existing = deliveries.find(d => d.id === id);
+ if (!existing) return json(response, 404, { error: 'Not found' });
+ if (request.method === 'DELETE') deliveries = deliveries.filter(d => d.id !== id);
+ else if (url.pathname.endsWith('/status')) existing.status = existing.status === 'new' ? 'delivered' : 'new';
+ else Object.assign(existing, data, { date: toDate(data.date), updated_at: new Date().toISOString() });
+ return json(response, 200, { message: 'ok' });
+ }
+ if (url.pathname === '/sw.js') { response.writeHead(404); return response.end(); }
+ const path = resolve(root, `.${decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)}`);
+ if (!path.startsWith(root + sep)) { response.writeHead(403); return response.end(); }
+ let content = await readFile(path);
+ const type = path.endsWith('.html') ? 'text/html' : path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : path.endsWith('.svg') ? 'image/svg+xml' : path.endsWith('.json') ? 'application/json' : 'application/octet-stream';
+ if (type === 'text/html') content = Buffer.from(content.toString().replace('
', 'Демо-просмотр · Вымышленные заявки · Изменения не затрагивают рабочую базу
'));
+ response.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' }); response.end(content);
+ } catch { response.writeHead(404); response.end('Not found'); }
+});
+server.listen(4174, '127.0.0.1', () => console.log('Demo UI: http://127.0.0.1:4174 — login demo / demo123'));
+process.on('SIGINT', () => server.close(() => process.exit(0)));
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 45529ca..435015b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,162 +1,64 @@
import { useState, useEffect, lazy, Suspense } from 'react';
-import { Truck, Loader2, LogOut } from 'lucide-react';
+import { Truck, Loader2, LogOut, CircleHelp } from 'lucide-react';
import { DeliveryForm } from './components/delivery/DeliveryForm';
import { LoginForm } from './components/auth/LoginForm';
import { ToastContainer } from './components/ui/Toast';
import { Button } from './components/ui/Button';
import { UpdatePrompt } from './components/ui/UpdatePrompt';
+import { GuidedTour } from './components/ui/GuidedTour';
import { useDeliveryStore } from './stores/deliveryStore';
import { useAuthStore } from './stores/authStore';
+import { useToastStore } from './stores/toastStore';
import { useWebSocket } from './hooks/useWebSocket';
+import { getTodayFrontend } from './utils/date';
-// Lazy load pages for code splitting
const Dashboard = lazy(() => import('./pages/Dashboard'));
const DeliveryListPage = lazy(() => import('./pages/DeliveryListPage'));
-
-// Fallback loading component
-const PageLoader = () => (
-
-
-
-);
+const PageLoader = () => Загрузка…
;
function App() {
const [view, setView] = useState<'dashboard' | 'delivery-list'>('dashboard');
- const [selectedDate, setSelectedDate] = useState('');
- const [isFormOpen, setIsFormOpen] = useState(false);
- const [formDate, setFormDate] = useState('');
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- const { isAuthenticated, isAuthChecking, restoreAuth, logout } = useAuthStore();
- const addDelivery = useDeliveryStore(state => state.addDelivery);
- const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
-
+ const [selectedDate, setSelectedDate] = useState(getTodayFrontend);
+ const [formOpen, setFormOpen] = useState(false);
+ const [tourReplay, setTourReplay] = useState(0);
+ const { isAuthenticated, isAuthChecking, user, restoreAuth, logout } = useAuthStore();
+ const { addDelivery, isLoading, isLoadingCounts, error, countsError, currentDate } = useDeliveryStore();
useWebSocket();
-
- // Restore auth on mount
- useEffect(() => {
- restoreAuth();
- }, [restoreAuth]);
-
- // Refresh counts when form closes (only when authenticated)
- useEffect(() => {
- if (isAuthenticated && !isFormOpen) {
- fetchDeliveryCounts();
- }
- }, [isAuthenticated, isFormOpen, fetchDeliveryCounts]);
-
- const handleDateSelect = (date: string) => {
- setSelectedDate(date);
- setView('delivery-list');
+ useEffect(() => { restoreAuth(); }, [restoreAuth]);
+ const selectDate = (date: string) => { setSelectedDate(date); setView('delivery-list'); };
+ const submitDelivery = async (data: Parameters[0]) => {
+ await addDelivery(data);
+ useToastStore.getState().addToast('Доставка создана', 'success');
+ selectDate(data.date);
};
-
- const handleBackToDashboard = () => {
- setView('dashboard');
- setSelectedDate('');
- };
-
- const handleAddDelivery = () => {
- const today = new Date().toLocaleDateString('ru-RU').split('.').join('-');
- setFormDate(today);
- setIsFormOpen(true);
- };
-
- const handleFormSubmit = async (data: Parameters[0]) => {
- setIsSubmitting(true);
- try {
- await addDelivery(data);
- setIsFormOpen(false);
-
- // If created for different date, navigate to that date
- const today = new Date().toLocaleDateString('ru-RU').split('.').join('-');
- if (data.date !== today) {
- setSelectedDate(data.date);
- setView('delivery-list');
- }
- } catch {
- // Error is handled by store
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // Show loading while checking auth
- if (isAuthChecking) {
- return (
-
-
-
- );
- }
-
- // Show login form if not authenticated
- if (!isAuthenticated) {
- return (
- <>
-
-
-
- >
- );
- }
-
+ if (isAuthChecking) return ;
+ if (!isAuthenticated) return <>>;
return (
setPassword(e.target.value)}
diff --git a/frontend/src/components/delivery/DeliveryCard.tsx b/frontend/src/components/delivery/DeliveryCard.tsx
index 66fc4fd..326dd43 100644
--- a/frontend/src/components/delivery/DeliveryCard.tsx
+++ b/frontend/src/components/delivery/DeliveryCard.tsx
@@ -1,212 +1,55 @@
import { memo } from 'react';
-import { MapPin, Phone, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare, User, Wrench, CalendarPlus } from 'lucide-react';
+import { MapPin, Phone, Store, CheckCircle2, RotateCcw, Pencil, Trash2, Loader2, User, Wrench } from 'lucide-react';
import type { Delivery } from '../../types';
import { formatPickupLocation } from '../../types';
import { StatusBadge } from './StatusBadge';
import { PaymentBadge } from './PaymentBadge';
-import { Card } from '../ui/Card';
import { WhatsAppIcon } from '../ui/WhatsAppIcon';
import { getWhatsAppUrl } from '../../utils/phone';
-const CITY = 'kokshetau';
-const createdAtFormatter = new Intl.DateTimeFormat('ru-RU', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
-});
-
-const formatCreatedAt = (timestamp: number) => createdAtFormatter.format(new Date(timestamp));
-
interface DeliveryCardProps {
delivery: Delivery;
onStatusChange: (id: string) => void;
onEdit: (delivery: Delivery) => void;
onDelete: (id: string) => void;
+ pending?: boolean;
}
-
-export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => {
- const handleAddressClick = () => {
- const encodedAddress = encodeURIComponent(delivery.address);
- window.open(`https://2gis.kz/${CITY}/search/${encodedAddress}`, '_blank');
- };
-
- const handlePhoneClick = () => {
- window.location.href = `tel:${delivery.phone}`;
- };
-
- const handleWhatsAppClick = (phone: string) => {
- window.location.href = getWhatsAppUrl(phone);
- };
-
- return (
-
-
-
-
onStatusChange(delivery.id)}
- size="md"
- />
-
-
- Создано {formatCreatedAt(delivery.createdAt)}
-
-
-
-
-
-
+export const DeliveryCard = memo(({ delivery: d, onStatusChange, onEdit, onDelete, pending }: DeliveryCardProps) => (
+
+
+
+
+
+
-
-
-
-
- {delivery.date}
-
-
- {/* Pickup locations paired with products */}
-
-
-
-
- {formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)}
- —
- {delivery.productName}
-
- {delivery.pickupLocation2 && (
-
- {formatPickupLocation(delivery.pickupLocation2, delivery.warehouseRequestSource2)}
- —
- {delivery.productName2 || '—'}
-
- )}
-
-
-
-
-
-
-
- {delivery.customerName}
-
-
-
-
-
-
-
- {delivery.additionalPhone && (
-
-
-
-
- )}
-
-
-
-
- {delivery.hasElevator ? 'Есть лифт' : 'Нет лифта'}
-
-
-
-
-
- {delivery.serviceInfo && (
-
-
- {delivery.serviceInfo}
-
- )}
-
- {delivery.comment && (
-
-
- {delivery.comment}
-
- )}
-
-
-
-
-
-
- );
-});
-
+
+
+ ул. {d.street}, д. {d.house}{d.apartment ? `, кв. ${d.apartment}` : ''}
+
+
+ {[d.entrance && `Подъезд ${d.entrance}`, d.floor && `этаж ${d.floor}`, d.hasElevator ? 'есть лифт' : 'нет лифта'].filter(Boolean).join(' · ')}
+
+
+
{d.productName}
{formatPickupLocation(d.pickupLocation, d.warehouseRequestSource)}
+ {d.pickupLocation2 &&
{d.productName2 || 'Вторая загрузка'}
{formatPickupLocation(d.pickupLocation2, d.warehouseRequestSource2)}
}
+
+ {d.customerName}
+
+ {d.additionalPhone && }
+
+ {d.serviceInfo && {d.serviceInfo}
}
+ {d.comment && {d.comment}
}
+
+
+
+
+));
DeliveryCard.displayName = 'DeliveryCard';
diff --git a/frontend/src/components/delivery/DeliveryForm.tsx b/frontend/src/components/delivery/DeliveryForm.tsx
index 025a7c8..3ed85a1 100644
--- a/frontend/src/components/delivery/DeliveryForm.tsx
+++ b/frontend/src/components/delivery/DeliveryForm.tsx
@@ -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
;
interface DeliveryFormProps {
isOpen: boolean;
onClose: () => void;
- onSubmit: (delivery: Omit) => void | Promise;
+ onSubmit: (delivery: DeliveryInput) => void | Promise;
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
+ ? : 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(() => initialData ? { ...initialData } : emptyDelivery(defaultDate || getTodayFrontend()));
+ const [data, setData] = useState(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 = (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) => {
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 (
-
-
-
-