From dee912edb898cc75d386e31ef11a90e672cb9d73 Mon Sep 17 00:00:00 2001 From: Egor Pozharov Date: Wed, 7 Oct 2026 19:49:10 +0000 Subject: [PATCH] Improve delivery workflows and add versioned guided tours (#1) Reviewed-on: https://gitea.chedius.ru/chedius/delivery-tracker/pulls/1 Co-authored-by: Egor Pozharov --- frontend/UI-REVIEW.md | 64 +++ frontend/eslint.config.js | 2 +- frontend/index.html | 2 +- frontend/package.json | 4 +- frontend/scripts/preview-demo.mjs | 66 +++ frontend/src/App.tsx | 164 ++---- frontend/src/components/auth/LoginForm.tsx | 4 + .../src/components/delivery/DeliveryCard.tsx | 235 ++------ .../src/components/delivery/DeliveryForm.tsx | 500 ++++-------------- .../src/components/delivery/DeliveryList.tsx | 155 ++---- .../src/components/delivery/DeliveryRow.tsx | 124 +---- .../src/components/delivery/StatusBadge.tsx | 33 +- frontend/src/components/ui/Button.tsx | 10 +- frontend/src/components/ui/Card.tsx | 2 +- frontend/src/components/ui/GuidedTour.tsx | 142 +++++ frontend/src/components/ui/Input.tsx | 25 +- frontend/src/components/ui/Modal.tsx | 61 +-- frontend/src/components/ui/Select.tsx | 37 +- frontend/src/components/ui/Toast.tsx | 6 +- frontend/src/index.css | 54 ++ frontend/src/pages/Dashboard.tsx | 256 +++------ frontend/src/pages/DeliveryListPage.tsx | 195 +++---- frontend/src/stores/deliveryStore.ts | 132 ++--- frontend/src/utils/errors.ts | 12 + frontend/tests/deliveryStore.test.mjs | 79 +++ 25 files changed, 933 insertions(+), 1431 deletions(-) create mode 100644 frontend/UI-REVIEW.md create mode 100644 frontend/scripts/preview-demo.mjs create mode 100644 frontend/src/components/ui/GuidedTour.tsx create mode 100644 frontend/src/utils/errors.ts create mode 100644 frontend/tests/deliveryStore.test.mjs 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 (
+ Перейти к содержимому -
-
-
-
-
- -
-

Delivery Tracker

-
-
-
- {view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`} -
- -
+
+
+ +
+ +
- -
+
}> - {view === 'dashboard' ? ( - - ) : ( - - )} + {view === 'dashboard' ? setFormOpen(true)} /> + : setView('dashboard')} />}
- - setIsFormOpen(false)} - onSubmit={handleFormSubmit} - defaultDate={formDate} - isSubmitting={isSubmitting} - /> - + setFormOpen(false)} onSubmit={submitDelivery} defaultDate={getTodayFrontend()} /> +
); } - export default App; diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index f9fc316..0dd5fe5 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -42,6 +42,8 @@ export const LoginForm = () => {
setUsername(e.target.value)} @@ -58,6 +60,8 @@ export const LoginForm = () => {
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.phone} + +
+ {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 ( - - + - - - } - > -
-
-
+
}> + +

Поля со звёздочкой обязательны.

+
+ Дата и загрузка + update('date', parseDateFromInput(e.target.value))} /> + update('warehouseRequestSource', e.target.value as DeliveryRequestSource || null)} />} + update('productName', e.target.value)} placeholder="Например, диван и два кресла" /> + - 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 - /> - - - setFormData({ ...formData, warehouseRequestSource: e.target.value ? e.target.value as DeliveryRequestSource : null })} - options={requestSourceOptions} - required - error={!isWarehouseRequestSourceValid ? 'Выберите, от кого исходит заявка' : undefined} - /> - )} - - setFormData({ ...formData, productName: e.target.value })} - placeholder="Введите название товара" - required - /> - - {/* Address fields */} -
-

Адрес доставки

-
-
- - setFormData({ ...formData, street: e.target.value })} - className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm" - required - /> -
-
- - setFormData({ ...formData, house: e.target.value })} - className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm" - required - /> -
-
- - setFormData({ ...formData, apartment: e.target.value })} - className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm" - /> -
-
- - setFormData({ ...formData, entrance: e.target.value })} - className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm" - /> -
-
- - setFormData({ ...formData, floor: e.target.value })} - className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm" - /> -
-
-
- - setFormData({ ...formData, customerName: e.target.value })} - placeholder="Иванов Иван Иванович" - required - /> - - 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 && ( -

- Введите корректный номер: +7 (XXX) XXX-XX-XX -

- )} - - 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 && ( -

- Введите корректный номер: +7 (XXX) XXX-XX-XX -

- )} - - {/* Second pickup location */} -
- { - 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]" - /> - -
- - {showSecondPickup && ( -
-

Вторая точка загрузки

- setFormData({ ...formData, warehouseRequestSource2: e.target.value ? e.target.value as DeliveryRequestSource : null })} - options={requestSourceOptions} - required - error={!isWarehouseRequestSource2Valid ? 'Выберите, от кого исходит заявка' : undefined} - /> - )} - setFormData({ ...formData, productName2: e.target.value })} - placeholder="Название товара со второй точки" - /> + {secondPickup &&
+ update('warehouseRequestSource2', e.target.value as DeliveryRequestSource || null)} />} + update('productName2', e.target.value)} /> +
} +
+
+ Адрес доставки + update('street', e.target.value)} /> +
+ update('house', e.target.value)} /> + update('apartment', e.target.value)} /> + update('entrance', e.target.value)} /> + update('floor', e.target.value)} />
- )} - -
- setFormData({ ...formData, hasElevator: e.target.checked })} - className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]" - /> - -
- - setFormData({ ...formData, serviceInfo: e.target.value })} - placeholder="Сборка 5000 тг, подъём на этаж 3000 тг" - /> - - setFormData({ ...formData, comment: e.target.value })} - placeholder="Дополнительная информация..." - /> + +
+
+ Клиент и оплата + update('customerName', e.target.value)} /> + update('phone', e.target.value)} /> + update('additionalPhone', e.target.value)} /> + update('serviceInfo', e.target.value)} /> + update('comment', e.target.value)} /> +
); -}; +} diff --git a/frontend/src/components/delivery/DeliveryList.tsx b/frontend/src/components/delivery/DeliveryList.tsx index 5681d0d..569de7d 100644 --- a/frontend/src/components/delivery/DeliveryList.tsx +++ b/frontend/src/components/delivery/DeliveryList.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo } from 'react'; -import { Plus, LayoutGrid, Table as TableIcon } from 'lucide-react'; +import { useState } from 'react'; +import { LayoutGrid, Table as TableIcon, PackageSearch, Plus } from 'lucide-react'; import { DeliveryCard } from './DeliveryCard'; import { DeliveryRow } from './DeliveryRow'; import { Button } from '../ui/Button'; @@ -7,132 +7,59 @@ import type { Delivery } from '../../types'; interface DeliveryListProps { deliveries: Delivery[]; + total: number; + filtered: boolean; + pendingIds: string[]; + onResetFilters: () => void; onStatusChange: (id: string) => void; onEdit: (delivery: Delivery) => void; onDelete: (id: string) => void; onAdd: () => void; date: string; } - -export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => { - const [viewMode, setViewMode] = useState<'kanban' | 'table'>('kanban'); - - const newDeliveries = useMemo(() => deliveries.filter(d => d.status === 'new'), [deliveries]); - const deliveredDeliveries = useMemo(() => deliveries.filter(d => d.status === 'delivered'), [deliveries]); - +export const DeliveryList = ({ deliveries, total, filtered, pendingIds, onResetFilters, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => { + const [viewMode, setViewMode] = useState<'cards' | 'table'>(() => { + try { return localStorage.getItem('delivery-view') === 'table' ? 'table' : 'cards'; } catch { return 'cards'; } + }); + const changeView = (view: 'cards' | 'table') => { + setViewMode(view); + try { localStorage.setItem('delivery-view', view); } catch { /* Storage may be unavailable. */ } + }; + const renderCard = (delivery: Delivery) => ; return (
-
-

- Доставки на {date} -

-
-
- - -
- +
+

{filtered ? `Показано ${deliveries.length} из ${total}` : `Всего за день: ${total}`}

+
+ +
- {deliveries.length === 0 ? ( -
-

Нет доставок на эту дату

- +
+ +

{filtered ? 'Нет подходящих доставок' : 'На этот день доставок пока нет'}

+

{filtered ? 'Измените поиск или фильтры, чтобы увидеть другие заявки.' : 'Создайте заявку — здесь появятся адрес, товары и контакты клиента.'}

+
- ) : viewMode === 'kanban' ? ( -
-
-
-

- - Новые - ({newDeliveries.length}) -

-
-
- {newDeliveries.map(delivery => ( - - ))} -
-
- -
-
-

- - Доставлено - ({deliveredDeliveries.length}) -

-
-
- {deliveredDeliveries.map(delivery => ( - - ))} -
-
+ ) : viewMode === 'cards' ? ( +
+ {(['new', 'delivered'] as const).map(status => { + const items = deliveries.filter(d => d.status === status); + return items.length > 0 &&
+

{status === 'new' ? 'В работе' : 'Доставлено'}{items.length}

+
{items.map(renderCard)}
+
; + })}
) : ( -
- - - - - - - - - - - - - - - - - {deliveries.map(delivery => ( - - ))} - +
+
СтатусДатаЗагрузкаТоварКлиентАдресТелефонОплатаКомментарийДействия
+ + + {['Статус', 'Товар и загрузка', 'Клиент и связь', 'Адрес', 'Оплата', 'Комментарий', 'Действия'].map(label => )} + + {deliveries.map(delivery => )}
Доставки на {date}
{label}
)} diff --git a/frontend/src/components/delivery/DeliveryRow.tsx b/frontend/src/components/delivery/DeliveryRow.tsx index 2a4e0e9..ecfd55d 100644 --- a/frontend/src/components/delivery/DeliveryRow.tsx +++ b/frontend/src/components/delivery/DeliveryRow.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { MapPin, Phone } from 'lucide-react'; +import { MapPin, Pencil, Trash2 } from 'lucide-react'; import type { Delivery } from '../../types'; import { formatPickupLocation } from '../../types'; import { StatusBadge } from './StatusBadge'; @@ -7,108 +7,34 @@ import { PaymentBadge } from './PaymentBadge'; import { WhatsAppIcon } from '../ui/WhatsAppIcon'; import { getWhatsAppUrl } from '../../utils/phone'; -const CITY = 'kokshetau'; - interface DeliveryRowProps { delivery: Delivery; onStatusChange: (id: string) => void; onEdit: (delivery: Delivery) => void; onDelete: (id: string) => void; + pending?: boolean; } - -export const DeliveryRow = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryRowProps) => { - const handleAddressClick = (e: React.MouseEvent) => { - e.stopPropagation(); - const encodedAddress = encodeURIComponent(delivery.address); - window.open(`https://2gis.kz/${CITY}/search/${encodedAddress}`, '_blank'); - }; - - const handlePhoneClick = (e: React.MouseEvent) => { - e.stopPropagation(); - window.location.href = `tel:${delivery.phone}`; - }; - - const handleWhatsAppClick = (e: React.MouseEvent) => { - e.stopPropagation(); - window.location.href = getWhatsAppUrl(delivery.phone); - }; - - return ( - - - onStatusChange(delivery.id)} - size="sm" - /> - - {delivery.date} - - {delivery.pickupLocation2 - ? `${formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)} + ${formatPickupLocation(delivery.pickupLocation2, delivery.warehouseRequestSource2)}` - : formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)} - - - {delivery.productName} - {delivery.productName2 && + {delivery.productName2}} - - {delivery.customerName} - - - - -
- - -
- - - - - - {delivery.comment || '-'} - - -
- - -
- - - ); -}); - +export const DeliveryRow = memo(({ delivery: d, onStatusChange, onEdit, onDelete, pending }: DeliveryRowProps) => ( + + onStatusChange(d.id)} disabled={pending} size="sm" /> + +

{d.productName}

{formatPickupLocation(d.pickupLocation, d.warehouseRequestSource)}

+ {d.pickupLocation2 &&

{d.productName2 || 'Вторая загрузка'}

{formatPickupLocation(d.pickupLocation2, d.warehouseRequestSource2)}

} + +

{d.customerName}

+ + {d.additionalPhone && } + + + ул. {d.street}, д. {d.house}{d.apartment ? `, кв. ${d.apartment}` : ''} +

{[d.entrance && `Подъезд ${d.entrance}`, d.floor && `этаж ${d.floor}`, d.hasElevator ? 'есть лифт' : 'нет лифта'].filter(Boolean).join(' · ')}

+ + + {d.serviceInfo &&

{d.serviceInfo}

}{d.comment || '—'} +
+ + +
+ +)); DeliveryRow.displayName = 'DeliveryRow'; diff --git a/frontend/src/components/delivery/StatusBadge.tsx b/frontend/src/components/delivery/StatusBadge.tsx index 7bab109..277603a 100644 --- a/frontend/src/components/delivery/StatusBadge.tsx +++ b/frontend/src/components/delivery/StatusBadge.tsx @@ -5,30 +5,13 @@ interface StatusBadgeProps { status: DeliveryStatus; onClick?: () => void; size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; } - -export const StatusBadge = ({ status, onClick, size = 'md' }: StatusBadgeProps) => { - const baseStyles = 'inline-flex items-center justify-center font-medium rounded-full transition-colors'; - - const variants = { - new: 'bg-[#ffdcc3] text-[#6e3900]', - delivered: 'bg-[#dcfce7] text-[#166534]', - }; - - const sizes = { - sm: 'px-2 py-0.5 text-xs', - md: 'px-3 py-1 text-sm', - lg: 'px-4 py-2 text-base', - }; - - const clickableStyles = onClick ? 'cursor-pointer hover:opacity-80 active:scale-95' : ''; - - return ( - - {statusLabels[status]} - - ); +export const StatusBadge = ({ status, onClick, size = 'md', disabled }: StatusBadgeProps) => { + const className = `inline-flex items-center justify-center gap-1.5 rounded-full font-medium ${status === 'new' ? 'bg-[#ffdcc3] text-[#6e3900]' : 'bg-[#dcfce7] text-[#166534]'} ${size === 'sm' ? 'px-2.5 py-1 text-xs' : 'px-3 py-1.5 text-sm'}`; + const content = <>
+

{step.title}

{step.description}

+
+ +
{index > 0 && } + +
+
+
+ , document.body, + ); +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx index 3741236..42a8858 100644 --- a/frontend/src/components/ui/Input.tsx +++ b/frontend/src/components/ui/Input.tsx @@ -1,25 +1,20 @@ -import type { InputHTMLAttributes } from 'react'; +import { useId, type InputHTMLAttributes } from 'react'; interface InputProps extends InputHTMLAttributes { label?: string; error?: string; } -export const Input = ({ label, error, className = '', ...props }: InputProps) => { +export const Input = ({ label, error, className = '', id, ...props }: InputProps) => { + const generatedId = useId(); + const inputId = id || generatedId; + const description = [props['aria-describedby'], error && `${inputId}-error`].filter(Boolean).join(' ') || undefined; return ( -
- {label && ( - - )} - - {error && ( -

{error}

- )} +
+ {label && } + + {error &&

{error}

}
); }; diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index d36a194..b78e4e4 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -1,5 +1,6 @@ +import { useEffect, useId, useRef, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; -import type { ReactNode } from 'react'; interface ModalProps { isOpen: boolean; @@ -7,38 +8,38 @@ interface ModalProps { title: string; children: ReactNode; footer?: ReactNode; + closeDisabled?: boolean; } -export const Modal = ({ isOpen, onClose, title, children, footer }: ModalProps) => { - if (!isOpen) return null; - - return ( -
-
-
-
-
-

{title}

- -
-
- {children} -
- {footer && ( -
- {footer} -
- )} +export const Modal = ({ isOpen, onClose, title, children, footer, closeDisabled = false }: ModalProps) => { + const ref = useRef(null); + const titleId = useId(); + useEffect(() => { + if (!isOpen) return; + const dialog = ref.current; + const previousFocus = document.activeElement as HTMLElement | null; + const overflow = document.body.style.overflow; + dialog?.showModal(); + document.body.style.overflow = 'hidden'; + return () => { + dialog?.close(); + document.body.style.overflow = overflow; + if (previousFocus?.isConnected) previousFocus.focus(); + }; + }, [isOpen]); + + return createPortal( + { e.preventDefault(); if (!closeDisabled) onClose(); }} + onClick={e => { if (e.target === e.currentTarget && !closeDisabled) onClose(); }}> +
+
+

{title}

+
+
{children}
+ {footer &&
{footer}
}
-
+ , document.body, ); }; diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx index 229a31c..29fd3f3 100644 --- a/frontend/src/components/ui/Select.tsx +++ b/frontend/src/components/ui/Select.tsx @@ -1,37 +1,24 @@ -import type { SelectHTMLAttributes } from 'react'; - -interface SelectOption { - value: string; - label: string; -} +import { useId, type SelectHTMLAttributes } from 'react'; +interface SelectOption { value: string; label: string } interface SelectProps extends SelectHTMLAttributes { label?: string; options: SelectOption[]; error?: string; } -export const Select = ({ label, options, error, className = '', ...props }: SelectProps) => { +export const Select = ({ label, options, error, className = '', id, ...props }: SelectProps) => { + const generatedId = useId(); + const selectId = id || generatedId; + const description = [props['aria-describedby'], error && `${selectId}-error`].filter(Boolean).join(' ') || undefined; return ( -
- {label && ( - - )} - + {options.map(option => )} - {error && ( -

{error}

- )} + {error &&

{error}

}
); }; diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx index 9771a1e..cc80fd3 100644 --- a/frontend/src/components/ui/Toast.tsx +++ b/frontend/src/components/ui/Toast.tsx @@ -19,20 +19,20 @@ export const ToastContainer = () => { if (toasts.length === 0) return null; return ( -
+
{toasts.map((toast) => { const Icon = icons[toast.type]; return (

{toast.message}

+
+

План доставок

Рабочий день и заявки на ближайшие даты

+
- - -
-

- - {format(currentMonth, 'LLLL yyyy', { locale: ru })} -

-
- - - + + {countsError &&

{countsError}

} +
+
+
+

{format(currentMonth, 'LLLL yyyy', { locale: ru })}

+
+ + + +
-
- -
- {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => ( -
- {day} +
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day =>
{day}
)}
+
+ {Array.from({ length: (getDay(startOfMonth(currentMonth)) + 6) % 7 }).map((_, i) =>
)} + {days.map(day => { + const count = getCount(day); + const today = isToday(day); + return ; + })} +
+

{isLoadingCounts ? <>Обновляем календарь… : countsError ? 'Количество доставок временно недоступно. Дни можно открывать.' : 'Число под датой — количество доставок'}

+ +
+

Ближайшие 7 дней

+ {isLoadingCounts ?

Загружаем план доставок…

: countsError ?

Обновите календарь, чтобы увидеть ближайшие даты.

: upcoming.length === 0 ?

На неделю пока нет заявок

Выберите день в календаре или создайте доставку.

: upcoming.map(day => ( +
+ +
))} -
- -
- {Array.from({ length: (getDay(startOfMonth(currentMonth)) + 6) % 7 }).map((_, i) => ( -
- ))} - {days.map((day) => { - const count = getCountForDate(day); - const isTodayDate = isToday(day); - - return ( - - ); - })} -
- - -
-

Ближайшие даты с доставками

- {upcomingDays - .filter(day => getCountForDate(day) > 0) - .map(day => { - const count = getCountForDate(day); - return ( - -
-
-
- {format(day, 'd')} -
-
- {format(day, 'MMM', { locale: ru })} -
-
-
-
- {count} {count === 1 ? 'доставка' : count < 5 ? 'доставки' : 'доставок'} -
-
- {format(day, 'EEEE', { locale: ru })} -
-
-
-
- - -
-
- ); - })} - - {upcomingDays.filter(day => getCountForDate(day) > 0).length === 0 && ( - -

Нет запланированных доставок

- -
- )} +
); }; - export default Dashboard; diff --git a/frontend/src/pages/DeliveryListPage.tsx b/frontend/src/pages/DeliveryListPage.tsx index efc6f08..d24d9fc 100644 --- a/frontend/src/pages/DeliveryListPage.tsx +++ b/frontend/src/pages/DeliveryListPage.tsx @@ -1,148 +1,79 @@ -import { useState, useEffect, useMemo } from 'react'; -import { ArrowLeft, Filter, Loader2, AlertCircle } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { ArrowLeft, Plus, ChevronLeft, ChevronRight, Search, AlertCircle, Loader2, Filter } from 'lucide-react'; +import { addDays, format, parse } from 'date-fns'; +import { ru } from 'date-fns/locale'; import { useDeliveryStore } from '../stores/deliveryStore'; -import { DeliveryList as DeliveryListComponent } from '../components/delivery/DeliveryList'; +import { useToastStore } from '../stores/toastStore'; +import { DeliveryList } from '../components/delivery/DeliveryList'; import { DeliveryForm } from '../components/delivery/DeliveryForm'; import { Button } from '../components/ui/Button'; +import { Input } from '../components/ui/Input'; import { Select } from '../components/ui/Select'; import { pickupFilterOptions } from '../constants/pickup'; -import type { Delivery, PickupLocation } from '../types'; - -interface DeliveryListPageProps { - selectedDate: string; - onBack: () => void; -} - -const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps) => { - const { - deliveries, - isLoading, - error, - fetchDeliveriesByDate, - toggleStatus, - deleteDelivery, - updateDelivery, - addDelivery, - clearError, - } = useDeliveryStore(); - - // Fetch deliveries when date changes - useEffect(() => { - fetchDeliveriesByDate(selectedDate); - }, [selectedDate, fetchDeliveriesByDate]); - - const [isFormOpen, setIsFormOpen] = useState(false); - const [editingDelivery, setEditingDelivery] = useState(null); - const [pickupFilter, setPickupFilter] = useState('all'); - - // Use all deliveries from store (already filtered by API) - const filteredDeliveries = useMemo(() => { - if (pickupFilter === 'all') return deliveries; - return deliveries.filter(d => d.pickupLocation === pickupFilter || d.pickupLocation2 === pickupFilter); - }, [deliveries, pickupFilter]); - - const handleStatusChange = async (id: string) => { - const delivery = deliveries.find(d => d.id === id); - if (delivery) { - await toggleStatus(id, delivery.status); - } - }; - - const handleEdit = (delivery: Delivery) => { - setEditingDelivery(delivery); - setIsFormOpen(true); - }; - - const handleDelete = async (id: string) => { - if (confirm('Удалить эту доставку?')) { - try { - await deleteDelivery(id); - } catch { - // Error is handled by store - } - } - }; +import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../utils/date'; +import type { Delivery, DeliveryStatus, PickupLocation } from '../types'; +interface DeliveryListPageProps { selectedDate: string; onBack: () => void; onDateSelect: (date: string) => void } +const DeliveryListPage = ({ selectedDate, onBack, onDateSelect }: DeliveryListPageProps) => { + const { deliveries, currentDate, isLoading, error, pendingIds, fetchDeliveriesByDate, toggleStatus, deleteDelivery, updateDelivery, addDelivery } = useDeliveryStore(); + const [isFormOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [pickup, setPickup] = useState('all'); + const [status, setStatus] = useState('all'); + const [search, setSearch] = useState(''); + const [filtersOpen, setFiltersOpen] = useState(false); + useEffect(() => { void fetchDeliveriesByDate(selectedDate); }, [selectedDate, fetchDeliveriesByDate]); + const dayDeliveries = useMemo(() => currentDate === selectedDate ? deliveries : [], [currentDate, selectedDate, deliveries]); + const filtered = useMemo(() => { + const query = search.trim().toLocaleLowerCase('ru'); + const digits = query.replace(/\D/g, ''); + return dayDeliveries.filter(d => (pickup === 'all' || d.pickupLocation === pickup || d.pickupLocation2 === pickup) + && (status === 'all' || d.status === status) + && (!query || [d.customerName, d.street, d.house, d.productName, d.productName2 || '', d.phone, d.additionalPhone || ''].some(value => value.toLocaleLowerCase('ru').includes(query)) + || (digits.length >= 3 && [d.phone, d.additionalPhone || ''].some(value => value.replace(/\D/g, '').includes(digits))))); + }, [dayDeliveries, pickup, status, search]); + const resetFilters = () => { setSearch(''); setPickup('all'); setStatus('all'); }; + const hasFilters = !!search.trim() || pickup !== 'all' || status !== 'all'; const handleSubmit = async (data: Omit) => { - try { - if (editingDelivery) { - await updateDelivery(editingDelivery.id, data); - } else { - await addDelivery(data); - } - setEditingDelivery(null); - } catch { - // Error is handled by store - } + if (editing) await updateDelivery(editing.id, data); + else await addDelivery(data); + useToastStore.getState().addToast(data.date !== selectedDate ? `Доставка сохранена на ${data.date}` : editing ? 'Изменения сохранены' : 'Доставка создана', 'success'); }; - - const handleAdd = () => { - setEditingDelivery(null); - setIsFormOpen(true); - }; - - const handleCloseForm = () => { - setIsFormOpen(false); - setEditingDelivery(null); - }; - + const date = parse(selectedDate, 'dd-MM-yyyy', new Date()); + const shiftDay = (offset: number) => onDateSelect(format(addDays(date, offset), 'dd-MM-yyyy')); return ( -
-
-
- -
- - Всего: {filteredDeliveries.length} -
-
-
- { if (e.target.value) onDateSelect(parseDateFromInput(e.target.value)); }} />
+ + +
+
+
+
setSearch(e.target.value)} className="pl-10" />
+ +
+ setStatus(e.target.value as DeliveryStatus | 'all')} options={[{ value: 'all', label: 'Все доставки' }, { value: 'new', label: 'В работе' }, { value: 'delivered', label: 'Доставлено' }]} />
-
- ) : ( - - )} - - + {hasFilters && } +
+ {isLoading || currentDate !== selectedDate ?
Загружаем доставки…
+ : error ?

{error}

+ : { const d = dayDeliveries.find(item => item.id === id); if (d) void toggleStatus(id, d.status).catch(() => {}); }} + onEdit={d => { setEditing(d); setFormOpen(true); }} + onDelete={id => { const d = dayDeliveries.find(item => item.id === id); if (window.confirm(`Удалить доставку для ${d?.customerName || 'клиента'}?`)) void deleteDelivery(id).catch(() => {}); }} + onAdd={() => { setEditing(null); setFormOpen(true); }} />} + { setFormOpen(false); setEditing(null); }} onSubmit={handleSubmit} initialData={editing} defaultDate={selectedDate} />
); }; - export default DeliveryListPage; diff --git a/frontend/src/stores/deliveryStore.ts b/frontend/src/stores/deliveryStore.ts index fe3de7f..5f53945 100644 --- a/frontend/src/stores/deliveryStore.ts +++ b/frontend/src/stores/deliveryStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { deliveriesApi } from '../api'; import { useToastStore } from './toastStore'; +import { deliveryErrorMessage } from '../utils/errors'; import type { Delivery, DeliveryStatus } from '../types'; interface DeliveryState { @@ -13,6 +14,8 @@ interface DeliveryState { isLoading: boolean; isLoadingCounts: boolean; error: string | null; + countsError: string | null; + pendingIds: string[]; // Actions fetchDeliveriesByDate: (date: string) => Promise; @@ -33,6 +36,9 @@ interface DeliveryState { handleWsDeliveryDeleted: (id: string) => void; } +let listRequest = 0; +let countsRequest = 0; + export const useDeliveryStore = create()((set, get) => ({ // Initial state deliveries: [], @@ -41,127 +47,91 @@ export const useDeliveryStore = create()((set, get) => ({ isLoading: false, isLoadingCounts: false, error: null, + countsError: null, + pendingIds: [], - // Fetch deliveries for a specific date + // Ignore stale responses when users switch days quickly. fetchDeliveriesByDate: async (date: string) => { - set({ isLoading: true, error: null, currentDate: date }); + const request = ++listRequest; + set({ isLoading: true, error: null, currentDate: date, deliveries: get().currentDate === date ? get().deliveries : [] }); try { const deliveries = await deliveriesApi.getByDate(date); - set({ deliveries, isLoading: false }); + if (request === listRequest) set({ deliveries, isLoading: false }); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to fetch deliveries'; - set({ - error: message, - isLoading: false, - }); - useToastStore.getState().addToast(message, 'error'); + if (request !== listRequest) return; + set({ error: deliveryErrorMessage(err, 'Не удалось загрузить доставки.'), isLoading: false }); } }, - // Fetch delivery counts for calendar fetchDeliveryCounts: async () => { - set({ isLoadingCounts: true, error: null }); + const request = ++countsRequest; + set({ isLoadingCounts: true, countsError: null }); try { const counts = await deliveriesApi.getCounts(); - set({ deliveryCounts: counts, isLoadingCounts: false }); + if (request === countsRequest) set({ deliveryCounts: counts, isLoadingCounts: false }); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to fetch counts'; - set({ - error: message, - isLoadingCounts: false, - }); - useToastStore.getState().addToast(message, 'error'); + if (request === countsRequest) set({ countsError: deliveryErrorMessage(err, 'Не удалось обновить календарь.'), isLoadingCounts: false }); } }, - // Add new delivery - addDelivery: async (delivery) => { - set({ isLoading: true, error: null }); + // Mutations keep the current day and list mounted. Count refresh failures + // must not turn a successfully saved delivery into a failed submission. + addDelivery: async delivery => { try { - await deliveriesApi.create(delivery); - // Refresh deliveries for that date - await get().fetchDeliveriesByDate(delivery.date); - // Refresh counts - await get().fetchDeliveryCounts(); - set({ isLoading: false }); + const id = await deliveriesApi.create(delivery); + get().handleWsDeliveryCreated({ ...delivery, id, createdAt: Date.now(), updatedAt: Date.now() }); + void get().fetchDeliveryCounts(); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to create delivery'; - set({ - error: message, - isLoading: false, - }); - useToastStore.getState().addToast(message, 'error'); + useToastStore.getState().addToast(deliveryErrorMessage(err, 'Не удалось создать доставку.'), 'error'); throw err; } }, - // Update delivery updateDelivery: async (id, updates) => { - set({ isLoading: true, error: null }); + if (get().pendingIds.includes(id)) throw new Error('Delivery is already being updated'); + set(state => ({ pendingIds: [...state.pendingIds, id] })); try { await deliveriesApi.update(id, updates); - // Refresh deliveries for that date - await get().fetchDeliveriesByDate(updates.date); - // Refresh counts (in case date changed) - await get().fetchDeliveryCounts(); - set({ isLoading: false }); + const existing = get().deliveries.find(d => d.id === id); + get().handleWsDeliveryUpdated({ ...updates, id, createdAt: existing?.createdAt || Date.now(), updatedAt: Date.now() }); + void get().fetchDeliveryCounts(); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to update delivery'; - set({ - error: message, - isLoading: false, - }); - useToastStore.getState().addToast(message, 'error'); + useToastStore.getState().addToast(deliveryErrorMessage(err, 'Не удалось сохранить доставку.'), 'error'); throw err; + } finally { + set(state => ({ pendingIds: state.pendingIds.filter(pending => pending !== id) })); } }, - // Delete delivery - deleteDelivery: async (id) => { - set({ isLoading: true, error: null }); + deleteDelivery: async id => { + if (get().pendingIds.includes(id)) return; + set(state => ({ pendingIds: [...state.pendingIds, id] })); try { await deliveriesApi.delete(id); - // Remove from local state - set((state) => ({ - deliveries: state.deliveries.filter((d) => d.id !== id), - isLoading: false, - })); - // Refresh counts - await get().fetchDeliveryCounts(); + get().handleWsDeliveryDeleted(id); + void get().fetchDeliveryCounts(); + useToastStore.getState().addToast('Доставка удалена', 'success'); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to delete delivery'; - set({ - error: message, - isLoading: false, - }); - useToastStore.getState().addToast(message, 'error'); + useToastStore.getState().addToast(deliveryErrorMessage(err, 'Не удалось удалить доставку.'), 'error'); throw err; + } finally { + set(state => ({ pendingIds: state.pendingIds.filter(pending => pending !== id) })); } }, - // Toggle delivery status toggleStatus: async (id, currentStatus) => { - set({ isLoading: true, error: null }); + if (get().pendingIds.includes(id)) return; + set(state => ({ pendingIds: [...state.pendingIds, id] })); try { - const newStatus = currentStatus === 'new' ? 'delivered' : 'new'; - await deliveriesApi.updateStatus(id, newStatus); - // Update local state - set((state) => ({ - deliveries: state.deliveries.map((d) => - d.id === id - ? { ...d, status: newStatus, updatedAt: Date.now() } - : d - ), - isLoading: false, - })); + const status = currentStatus === 'new' ? 'delivered' : 'new'; + await deliveriesApi.updateStatus(id, status); + get().handleWsStatusChanged(id, status); + useToastStore.getState().addToast(status === 'delivered' ? 'Доставка отмечена выполненной' : 'Доставка возвращена в новые', 'success'); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to update status'; - set({ - error: message, - isLoading: false, - }); - useToastStore.getState().addToast(message, 'error'); + useToastStore.getState().addToast(deliveryErrorMessage(err, 'Не удалось изменить статус.'), 'error'); throw err; + } finally { + set(state => ({ pendingIds: state.pendingIds.filter(pending => pending !== id) })); } }, diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts new file mode 100644 index 0000000..a8f509e --- /dev/null +++ b/frontend/src/utils/errors.ts @@ -0,0 +1,12 @@ +import { ApiError } from '../api/client'; + +export function deliveryErrorMessage(error: unknown, action: string): string { + if (error instanceof ApiError) { + if (error.status === 401) return 'Сессия истекла. Войдите снова.'; + if (error.status === 403) return 'Недостаточно прав для этого действия.'; + if (error.status === 404) return 'Доставка больше не найдена. Обновите список.'; + if (error.status === 400) return `${action} Проверьте данные доставки.`; + if (error.status === 429) return `${action} Слишком много запросов. Попробуйте чуть позже.`; + } + return `${action} Проверьте соединение и попробуйте ещё раз.`; +} diff --git a/frontend/tests/deliveryStore.test.mjs b/frontend/tests/deliveryStore.test.mjs new file mode 100644 index 0000000..9a2863b --- /dev/null +++ b/frontend/tests/deliveryStore.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, test } from 'node:test'; +import { createServer } from 'vite'; + +// Load the real TypeScript store with Vite's existing transformer; no test +// dependency or production authentication bypass is required. +const vite = await createServer({ configFile: false, root: new URL('..', import.meta.url).pathname, server: { middlewareMode: true, hmr: false, watch: null, ws: false }, appType: 'custom', optimizeDeps: { noDiscovery: true, include: [] } }); +const { useDeliveryStore: store } = await vite.ssrLoadModule('/src/stores/deliveryStore.ts'); +const { deliveriesApi: api } = await vite.ssrLoadModule('/src/api/deliveries.ts'); +const original = { ...api }; +const day = '08-10-2026'; +const nextDay = '09-10-2026'; +const delivery = { id: 'one', date: day, productName: 'Диван', pickupLocation: 'symbat', customerName: 'Тестовый клиент', street: 'Тестовая', house: '1', address: 'Тестовый адрес', phone: '+77761234567', hasElevator: true, paymentStatus: 'paid', comment: '', status: 'new', createdAt: 1, updatedAt: 1 }; +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; }; +const settle = () => new Promise(resolve => setImmediate(resolve)); +beforeEach(() => { + Object.assign(api, original, { getCounts: async () => ({ [day]: 1 }) }); + store.setState({ deliveries: [{ ...delivery }], currentDate: day, isLoading: false, isLoadingCounts: false, error: null, countsError: null, pendingIds: [] }); +}); +after(async () => { Object.assign(api, original); await vite.close(); }); + +test('moving a delivery keeps the open day and removes only the moved delivery', async () => { + api.update = async () => {}; + let fetchedList = false; + api.getByDate = async () => { fetchedList = true; return []; }; + await store.getState().updateDelivery('one', { ...delivery, date: nextDay }); + assert.equal(store.getState().currentDate, day); + assert.deepEqual(store.getState().deliveries, []); + assert.equal(fetchedList, false); + assert.equal(store.getState().isLoading, false); +}); + +test('failed save rejects its callback and leaves existing data intact', async () => { + api.create = async () => { throw new Error('network down'); }; + await assert.rejects(store.getState().addDelivery(delivery)); + assert.deepEqual(store.getState().deliveries, [delivery]); + assert.equal(store.getState().isLoading, false); +}); + +test('count refresh failure cannot turn successful creation into a failed save', async () => { + api.create = async () => 'two'; + api.getCounts = async () => { throw new Error('count failure'); }; + await store.getState().addDelivery({ ...delivery, productName: 'Кресло' }); + await settle(); + assert.equal(store.getState().deliveries.length, 2); + assert.equal(store.getState().error, null); + assert.match(store.getState().countsError, /календарь/); +}); + +test('status mutation has local pending state and suppresses repeated requests', async () => { + const wait = deferred(); + let requests = 0; + api.updateStatus = async () => { requests++; await wait.promise; }; + const first = store.getState().toggleStatus('one', 'new'); + assert.deepEqual(store.getState().pendingIds, ['one']); + assert.equal(store.getState().isLoading, false); + assert.equal(store.getState().deliveries.length, 1); + await store.getState().toggleStatus('one', 'new'); + assert.equal(requests, 1); + wait.resolve(); + await first; + assert.equal(store.getState().deliveries[0].status, 'delivered'); + assert.deepEqual(store.getState().pendingIds, []); +}); + +test('late response from an old day cannot overwrite the selected day', async () => { + const old = deferred(); + const recent = deferred(); + api.getByDate = date => date === day ? old.promise : recent.promise; + const first = store.getState().fetchDeliveriesByDate(day); + const second = store.getState().fetchDeliveriesByDate(nextDay); + recent.resolve([{ ...delivery, id: 'two', date: nextDay }]); + await second; + old.resolve([delivery]); + await first; + assert.equal(store.getState().currentDate, nextDay); + assert.equal(store.getState().deliveries[0].id, 'two'); + assert.equal(store.getState().isLoading, false); +});