Improve delivery workflows and add versioned guided tours #1

Merged
chedius merged 1 commits from codex/ui-ux-guided-tour into master 2026-10-07 19:49:11 +00:00
25 changed files with 933 additions and 1431 deletions
+64
View File
@@ -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
```
Первое сохранение завершится ошибкой, повторное с теми же данными — успешно.
Этот служебный адрес существует только в демо-сервере.
+1 -1
View File
@@ -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: [
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#1B263B" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+3 -1
View File
@@ -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",
+66
View File
@@ -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('<body>', '<body><div style="background:#fff1e5;color:#6e3900;padding:8px 16px;text-align:center;font:13px system-ui">Демо-просмотр · Вымышленные заявки · Изменения не затрагивают рабочую базу</div>'));
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)));
+33 -131
View File
@@ -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 = () => (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
</div>
);
const PageLoader = () => <div role="status" className="flex items-center justify-center gap-3 py-20 text-[#666870]"><Loader2 size={24} className="animate-spin" />Загрузка…</div>;
function App() {
const [view, setView] = useState<'dashboard' | 'delivery-list'>('dashboard');
const [selectedDate, setSelectedDate] = useState<string>('');
const [isFormOpen, setIsFormOpen] = useState(false);
const [formDate, setFormDate] = useState<string>('');
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<typeof addDelivery>[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<typeof addDelivery>[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 (
<div className="min-h-screen flex items-center justify-center bg-[#fbf8fb]">
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
</div>
);
}
// Show login form if not authenticated
if (!isAuthenticated) {
return (
<>
<UpdatePrompt />
<LoginForm />
<ToastContainer />
</>
);
}
if (isAuthChecking) return <PageLoader />;
if (!isAuthenticated) return <><UpdatePrompt /><LoginForm /><ToastContainer /></>;
return (
<div className="min-h-screen bg-[#fbf8fb]">
<a href="#main-content" className="skip-link">Перейти к содержимому</a>
<UpdatePrompt />
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/10 rounded-lg">
<Truck size={24} className="text-white" />
</div>
<h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1>
</div>
<div className="flex items-center gap-4">
<div className="text-sm text-white/70">
{view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`}
</div>
<Button
variant="ghost"
size="sm"
onClick={logout}
className="text-white hover:bg-white/10"
>
<LogOut size={18} className="mr-1" />
Выйти
</Button>
</div>
<header className="sticky top-0 z-40 bg-[#1B263B] text-white">
<div className="mx-auto flex min-h-16 max-w-7xl items-center justify-between gap-2 px-4 sm:px-6 lg:px-8">
<button type="button" onClick={() => setView('dashboard')} className="flex min-h-11 items-center gap-2 text-left" aria-label="Delivery Tracker — открыть план доставок">
<Truck size={25} className="shrink-0" /><span className="text-sm font-semibold sm:text-lg">Delivery Tracker</span>
</button>
<div className="flex items-center gap-1 sm:gap-3">
<Button variant="ghost" size="sm" data-tour="tour-help" onClick={() => setTourReplay(value => value + 1)} className="text-white hover:bg-white/10" aria-label="Помощь: обзор текущего экрана"><CircleHelp size={19} /><span className="ml-2 hidden sm:inline">Помощь</span></Button>
<Button variant="ghost" size="sm" onClick={logout} className="text-white hover:bg-white/10" aria-label="Выйти из системы"><LogOut size={18} /><span className="ml-2 hidden sm:inline">Выйти</span></Button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<main id="main-content" tabIndex={-1} className="mx-auto max-w-7xl px-4 py-5 sm:px-6 sm:py-7 lg:px-8">
<Suspense fallback={<PageLoader />}>
{view === 'dashboard' ? (
<Dashboard
onDateSelect={handleDateSelect}
onAddDelivery={handleAddDelivery}
/>
) : (
<DeliveryListPage
selectedDate={selectedDate}
onBack={handleBackToDashboard}
/>
)}
{view === 'dashboard' ? <Dashboard onDateSelect={selectDate} onAddDelivery={() => setFormOpen(true)} />
: <DeliveryListPage selectedDate={selectedDate} onDateSelect={selectDate} onBack={() => setView('dashboard')} />}
</Suspense>
</main>
<DeliveryForm
isOpen={isFormOpen}
onClose={() => setIsFormOpen(false)}
onSubmit={handleFormSubmit}
defaultDate={formDate}
isSubmitting={isSubmitting}
/>
<DeliveryForm isOpen={formOpen} onClose={() => setFormOpen(false)} onSubmit={submitDelivery} defaultDate={getTodayFrontend()} />
<GuidedTour key={`${user?.id || user?.username}:${view}`} scope={view} userKey={user?.id || user?.username || 'user'} replay={tourReplay}
ready={!formOpen && (view === 'dashboard' ? !isLoadingCounts && !countsError : !isLoading && !error && currentDate === selectedDate)} />
<ToastContainer />
</div>
);
}
export default App;
@@ -42,6 +42,8 @@ export const LoginForm = () => {
</div>
<Input
type="text"
aria-label="Имя пользователя"
autoComplete="username"
placeholder="Имя пользователя"
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -58,6 +60,8 @@ export const LoginForm = () => {
</div>
<Input
type="password"
aria-label="Пароль"
autoComplete="current-password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
+39 -196
View File
@@ -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 (
<Card className="relative">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<StatusBadge
status={delivery.status}
onClick={() => onStatusChange(delivery.id)}
size="md"
/>
<div className="flex items-center gap-2 text-xs text-[#75777d]">
<CalendarPlus size={14} className="shrink-0" />
<span className="truncate">Создано {formatCreatedAt(delivery.createdAt)}</span>
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => onEdit(delivery)}
className="p-1.5 rounded-md hover:bg-[#f5f3f5] text-[#75777d] transition-colors"
title="Редактировать"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
<button
onClick={() => onDelete(delivery.id)}
className="p-1.5 rounded-md hover:bg-red-50 text-red-500 transition-colors"
title="Удалить"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>
</div>
export const DeliveryCard = memo(({ delivery: d, onStatusChange, onEdit, onDelete, pending }: DeliveryCardProps) => (
<article className="delivery-card flex min-w-0 flex-col rounded-xl border border-[#dedde2] bg-white p-4 sm:p-5" aria-busy={pending} data-tour="delivery-card">
<div className="mb-3 flex items-center justify-between gap-2">
<StatusBadge status={d.status} />
<div className="flex gap-1">
<button type="button" onClick={() => onEdit(d)} disabled={pending} className="icon-button" aria-label={`Редактировать доставку для ${d.customerName}`} title="Редактировать"><Pencil size={18} /></button>
<button type="button" onClick={() => onDelete(d.id)} disabled={pending} className="icon-button text-red-700 hover:bg-red-50" aria-label={`Удалить доставку для ${d.customerName}`} title="Удалить"><Trash2 size={18} /></button>
</div>
<div className="space-y-2.5">
<div className="flex items-center gap-2 text-sm">
<Calendar size={16} className="text-[#75777d]" />
<span className="text-[#1b1b1d] font-medium">{delivery.date}</span>
</div>
{/* Pickup locations paired with products */}
<div className="flex items-start gap-2 text-sm">
<Store size={16} className="text-[#75777d] mt-0.5 shrink-0" />
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-[#1b1b1d] font-medium">{formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)}</span>
<span className="text-[#75777d]">—</span>
<span className="text-[#1b1b1d]">{delivery.productName}</span>
</div>
{delivery.pickupLocation2 && (
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-[#1b1b1d] font-medium">{formatPickupLocation(delivery.pickupLocation2, delivery.warehouseRequestSource2)}</span>
<span className="text-[#75777d]">—</span>
<span className="text-[#1b1b1d]">{delivery.productName2 || '—'}</span>
</div>
)}
</div>
</div>
<button
onClick={handleAddressClick}
className="flex items-start gap-2 text-sm w-full text-left hover:bg-[#f5f3f5] -mx-1 px-1 py-0.5 rounded transition-colors"
>
<MapPin size={16} className="text-[#F28C28] mt-0.5 shrink-0" />
<div className="flex flex-col">
<span className="text-[#1B263B] underline decoration-[#F28C28]/30 underline-offset-2">
ул. {delivery.street}, д. {delivery.house}{delivery.apartment ? `, кв. ${delivery.apartment}` : ''}
</span>
{(delivery.entrance || delivery.floor) && (
<span className="text-[#75777d] text-xs">
{delivery.entrance && `Подъезд ${delivery.entrance}`}
{delivery.entrance && delivery.floor && ', '}
{delivery.floor && `этаж ${delivery.floor}`}
</span>
)}
</div>
</button>
<div className="flex items-center gap-2 text-sm">
<User size={16} className="text-[#75777d]" />
<span className="text-[#1b1b1d]">{delivery.customerName}</span>
</div>
<div className="flex items-center gap-2 -mx-1">
<button
onClick={handlePhoneClick}
className="flex min-w-0 items-center gap-2 text-sm text-left hover:bg-[#f5f3f5] px-1 py-0.5 rounded transition-colors"
>
<Phone size={16} className="text-[#16a34a] shrink-0" />
<span className="text-[#16a34a] font-medium">
{delivery.phone}
</span>
</button>
<button
onClick={() => handleWhatsAppClick(delivery.phone)}
className="p-1.5 rounded-md text-[#16a34a] hover:bg-[#f5f3f5] transition-colors"
title="Открыть WhatsApp"
aria-label={`Открыть WhatsApp для номера ${delivery.phone}`}
>
<WhatsAppIcon className="h-5 w-5" />
</button>
</div>
{delivery.additionalPhone && (
<div className="flex items-center gap-2 -mx-1">
<button
onClick={() => window.location.href = `tel:${delivery.additionalPhone}`}
className="flex min-w-0 items-center gap-2 text-sm text-left hover:bg-[#f5f3f5] px-1 py-0.5 rounded transition-colors"
>
<Phone size={16} className="text-[#75777d] shrink-0" />
<span className="text-[#75777d]">
{delivery.additionalPhone}
</span>
</button>
<button
onClick={() => handleWhatsAppClick(delivery.additionalPhone ?? '')}
className="p-1.5 rounded-md text-[#16a34a] hover:bg-[#f5f3f5] transition-colors"
title="Открыть WhatsApp"
aria-label={`Открыть WhatsApp для номера ${delivery.additionalPhone}`}
>
<WhatsAppIcon className="h-5 w-5" />
</button>
</div>
)}
<div className="flex items-center gap-2 text-sm">
<CheckSquare size={16} className={delivery.hasElevator ? 'text-[#16a34a]' : 'text-[#75777d]'} />
<span className="text-[#1b1b1d]">
{delivery.hasElevator ? 'Есть лифт' : 'Нет лифта'}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<PaymentBadge status={delivery.paymentStatus} />
</div>
{delivery.serviceInfo && (
<div className="flex items-start gap-2 text-sm">
<Wrench size={16} className="text-[#F28C28] mt-0.5 shrink-0" />
<span className="text-[#45474d]">{delivery.serviceInfo}</span>
</div>
)}
{delivery.comment && (
<div className="flex items-start gap-2 text-sm">
<MessageSquare size={16} className="text-[#75777d] mt-0.5 shrink-0" />
<span className="text-[#45474d]">{delivery.comment}</span>
</div>
)}
</div>
<div className="mt-4 pt-3 border-t border-[#e4e2e4]">
<button
onClick={() => onStatusChange(delivery.id)}
className="w-full flex items-center justify-center gap-2 py-2 rounded-md bg-[#f5f3f5] hover:bg-[#e4e2e4] transition-colors text-sm font-medium text-[#1b1b1d]"
>
{delivery.status === 'new' ? (
<>
<CheckCircle2 size={16} className="text-[#16a34a]" />
Отметить доставленным
</>
) : (
<>
<Circle size={16} className="text-[#F28C28]" />
Вернуть в "Новые"
</>
)}
</button>
</div>
</Card>
);
});
</div>
<a href={`https://2gis.kz/kokshetau/search/${encodeURIComponent(d.address)}`} target="_blank" rel="noopener noreferrer"
className="mb-1 flex min-h-11 items-start gap-2 text-lg font-semibold leading-snug text-[#1B263B] underline-offset-4 hover:underline">
<MapPin size={20} className="mt-1 shrink-0" /><span className="break-words">ул. {d.street}, д. {d.house}{d.apartment ? `, кв. ${d.apartment}` : ''}</span>
</a>
<p className="mb-4 pl-7 text-sm text-[#666870]">
{[d.entrance && `Подъезд ${d.entrance}`, d.floor && `этаж ${d.floor}`, d.hasElevator ? 'есть лифт' : 'нет лифта'].filter(Boolean).join(' · ')}
</p>
<div className="space-y-2.5 border-y border-[#e4e2e4] py-3">
<div className="flex gap-2"><Store size={18} className="mt-0.5 shrink-0 text-[#666870]" /><div className="min-w-0"><p className="break-words font-medium">{d.productName}</p><p className="mt-0.5 text-sm text-[#666870]">{formatPickupLocation(d.pickupLocation, d.warehouseRequestSource)}</p></div></div>
{d.pickupLocation2 && <div className="flex gap-2"><Store size={18} className="mt-0.5 shrink-0 text-[#666870]" /><div className="min-w-0"><p className="break-words font-medium">{d.productName2 || 'Вторая загрузка'}</p><p className="mt-0.5 text-sm text-[#666870]">{formatPickupLocation(d.pickupLocation2, d.warehouseRequestSource2)}</p></div></div>}
</div>
<div className="mt-3 flex items-start gap-2"><User size={18} className="mt-0.5 shrink-0 text-[#666870]" /><span className="break-words">{d.customerName}</span></div>
<div className="mt-2 flex flex-wrap items-center gap-2" data-tour="delivery-contact">
<a href={`tel:${d.phone}`} className="flex min-h-11 items-center gap-2 rounded-lg px-2 text-base font-medium text-[#166534] hover:bg-green-50"><Phone size={18} />{d.phone}</a>
<a href={getWhatsAppUrl(d.phone)} className="icon-button text-[#166534] hover:bg-green-50" aria-label={`Написать в WhatsApp: ${d.phone}`} title="WhatsApp"><WhatsAppIcon className="h-5 w-5" /></a>
</div>
{d.additionalPhone && <div className="flex flex-wrap items-center gap-2"><a href={`tel:${d.additionalPhone}`} className="flex min-h-11 items-center gap-2 px-2 text-sm text-[#666870]"><Phone size={16} />{d.additionalPhone}</a><a href={getWhatsAppUrl(d.additionalPhone)} className="icon-button text-[#166534]" aria-label={`WhatsApp: ${d.additionalPhone}`}><WhatsAppIcon className="h-5 w-5" /></a></div>}
<div className="mt-3"><PaymentBadge status={d.paymentStatus} /></div>
{d.serviceInfo && <p className="mt-3 flex items-start gap-2 text-sm text-[#45474d]"><Wrench size={16} className="mt-0.5 shrink-0" /><span className="break-words">{d.serviceInfo}</span></p>}
{d.comment && <p className="mt-3 break-words rounded-lg bg-[#f5f3f5] p-3 text-sm text-[#45474d]">{d.comment}</p>}
<div className="mt-auto pt-4">
<button type="button" onClick={() => onStatusChange(d.id)} disabled={pending} data-tour="delivery-status"
className={`flex min-h-11 w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:opacity-60 ${d.status === 'new' ? 'bg-[#1B263B] text-white hover:bg-[#2a3a52]' : 'bg-[#f0edef] text-[#1b1b1d] hover:bg-[#e4e2e4]'}`}>
{pending ? <Loader2 size={18} className="animate-spin" /> : d.status === 'new' ? <CheckCircle2 size={18} /> : <RotateCcw size={18} />}
{pending ? 'Обновление…' : d.status === 'new' ? 'Отметить доставленным' : 'Вернуть в новые'}
</button>
</div>
</article>
));
DeliveryCard.displayName = 'DeliveryCard';
+110 -390
View File
@@ -1,417 +1,137 @@
import { useState, useEffect, useCallback } from 'react';
import { useRef, useState, type FormEvent } from 'react';
import { AlertCircle, Loader2 } from 'lucide-react';
import { Button, Input, Select, Modal } from '../ui';
import { deliveryRequestSourceOptions, pickupOptions } from '../../constants/pickup';
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
import type { Delivery, DeliveryRequestSource, PickupLocation, DeliveryStatus, PaymentStatus } from '../../types';
import type { Delivery, DeliveryRequestSource, PickupLocation, PaymentStatus } from '../../types';
type DeliveryInput = Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>;
interface DeliveryFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => void | Promise<void>;
onSubmit: (delivery: DeliveryInput) => void | Promise<void>;
initialData?: Delivery | null;
defaultDate?: string;
isSubmitting?: boolean;
}
// Phone validation regex for Kazakhstan numbers
const PHONE_REGEX = /^\+7\s?\(?\d{3}\)?\s?\d{3}[\s-]?\d{2}[\s-]?\d{2}$/;
const requestSourceOptions = [{ value: '', label: 'Выберите источник заявки' }, ...deliveryRequestSourceOptions];
const paymentOptions = [{ value: 'prepaid', label: 'Предоплата' }, { value: 'paid', label: 'Полная оплата' }];
const emptyDelivery = (date: string): DeliveryInput => ({
date, pickupLocation: 'warehouse', pickupLocation2: null,
warehouseRequestSource: null, warehouseRequestSource2: null,
productName: '', productName2: '', customerName: '', address: '', street: '', house: '',
apartment: '', entrance: '', floor: '', phone: '', additionalPhone: '', hasElevator: false,
paymentStatus: 'prepaid', serviceInfo: '', comment: '', status: 'new',
});
// City is not shown in UI but is included in the saved address (used for 2GIS search).
const CITY_LABEL = 'Кокшетау';
const requestSourceOptions = [
{ value: '', label: 'Выберите источник заявки' },
...deliveryRequestSourceOptions,
];
const paymentStatusOptions = [
{ value: 'prepaid', label: 'Предоплата' },
{ value: 'paid', label: 'Полная оплата' },
];
// A fresh mount on each opening prevents edited data leaking into a new delivery.
export const DeliveryForm = (props: DeliveryFormProps) => props.isOpen
? <DeliveryFormContent {...props} key={props.initialData?.id || 'new'} /> : null;
const buildAddressString = (
street: string,
house: string,
apartment: string,
entrance: string,
): string => {
const parts: string[] = [CITY_LABEL];
if (street) parts.push(`ул. ${street}`);
if (house) parts.push(`д. ${house}`);
if (apartment) parts.push(`кв. ${apartment}`);
if (entrance) parts.push(`подъезд ${entrance}`);
return parts.join(', ');
};
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate, isSubmitting }: DeliveryFormProps) => {
const [formData, setFormData] = useState({
date: defaultDate || getTodayFrontend(),
pickupLocation: 'warehouse' as PickupLocation,
pickupLocation2: null as PickupLocation | null,
warehouseRequestSource: null as DeliveryRequestSource | null,
warehouseRequestSource2: null as DeliveryRequestSource | null,
productName: '',
productName2: '',
customerName: '',
address: '',
street: '',
house: '',
apartment: '',
entrance: '',
floor: '',
phone: '',
additionalPhone: '',
hasElevator: false,
paymentStatus: 'prepaid' as PaymentStatus,
serviceInfo: '',
comment: '',
status: 'new' as DeliveryStatus,
});
const [showSecondPickup, setShowSecondPickup] = useState(false);
useEffect(() => {
if (initialData) {
setFormData({
date: initialData.date,
pickupLocation: initialData.pickupLocation,
pickupLocation2: initialData.pickupLocation2 || null,
warehouseRequestSource: initialData.warehouseRequestSource || null,
warehouseRequestSource2: initialData.warehouseRequestSource2 || null,
productName: initialData.productName,
productName2: initialData.productName2 || '',
customerName: initialData.customerName,
address: initialData.address,
street: initialData.street,
house: initialData.house,
apartment: initialData.apartment || '',
entrance: initialData.entrance || '',
floor: initialData.floor || '',
phone: initialData.phone,
additionalPhone: initialData.additionalPhone || '',
hasElevator: initialData.hasElevator,
paymentStatus: initialData.paymentStatus || 'prepaid',
serviceInfo: initialData.serviceInfo || '',
comment: initialData.comment,
status: initialData.status,
});
setShowSecondPickup(!!initialData.pickupLocation2);
} else if (defaultDate) {
setFormData(prev => ({ ...prev, date: defaultDate }));
}
}, [initialData, defaultDate, isOpen]);
const validatePhone = useCallback((phone: string): boolean => {
if (!phone) return false;
return PHONE_REGEX.test(phone);
}, []);
const isPhoneValid = !formData.phone || validatePhone(formData.phone);
const isAdditionalPhoneValid = !formData.additionalPhone || validatePhone(formData.additionalPhone);
const isWarehouseRequestSourceValid = formData.pickupLocation !== 'warehouse' || !!formData.warehouseRequestSource;
const isWarehouseRequestSource2Valid = !showSecondPickup || formData.pickupLocation2 !== 'warehouse' || !!formData.warehouseRequestSource2;
const isFormValid = formData.productName && formData.phone && isPhoneValid && formData.customerName && formData.street && formData.house && isWarehouseRequestSourceValid && isWarehouseRequestSource2Valid;
const handlePickupLocationChange = (pickupLocation: PickupLocation) => {
setFormData({
...formData,
pickupLocation,
warehouseRequestSource: pickupLocation === 'warehouse' ? formData.warehouseRequestSource : null,
});
function DeliveryFormContent({ onClose, onSubmit, initialData, defaultDate }: DeliveryFormProps) {
const [baseline] = useState<DeliveryInput>(() => initialData ? { ...initialData } : emptyDelivery(defaultDate || getTodayFrontend()));
const [data, setData] = useState<DeliveryInput>(baseline);
const [secondPickup, setSecondPickup] = useState(!!baseline.pickupLocation2);
const [isSubmitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [attempted, setAttempted] = useState(false);
const submitting = useRef(false);
const dirty = JSON.stringify(data) !== JSON.stringify(baseline);
const update = <K extends keyof DeliveryInput>(key: K, value: DeliveryInput[K]) => setData(prev => ({ ...prev, [key]: value }));
const phoneError = data.phone && !PHONE_REGEX.test(data.phone) ? 'Введите номер в формате +7 (776) 567-89-01' : undefined;
const extraPhoneError = data.additionalPhone && !PHONE_REGEX.test(data.additionalPhone) ? 'Проверьте дополнительный номер или очистите поле' : undefined;
const sourceMissing = data.pickupLocation === 'warehouse' && !data.warehouseRequestSource;
const secondSourceMissing = secondPickup && data.pickupLocation2 === 'warehouse' && !data.warehouseRequestSource2;
const requestClose = () => {
if (submitting.current) return;
if (!dirty || window.confirm('Закрыть форму? Несохранённые изменения будут потеряны.')) onClose();
};
const handlePickupLocation2Change = (pickupLocation2: PickupLocation) => {
setFormData({
...formData,
pickupLocation2,
warehouseRequestSource2: pickupLocation2 === 'warehouse' ? formData.warehouseRequestSource2 : null,
});
};
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!isFormValid) return;
setAttempted(true);
if (submitting.current) return;
if (phoneError || extraPhoneError || sourceMissing || secondSourceMissing) {
setError('Проверьте отмеченные поля. Данные остаются в форме.');
return;
}
submitting.current = true;
setSubmitting(true);
setError('');
const address = ['Кокшетау', `ул. ${data.street.trim()}`, `д. ${data.house.trim()}`,
data.apartment && `кв. ${data.apartment}`, data.entrance && `подъезд ${data.entrance}`].filter(Boolean).join(', ');
try {
const payload = {
...formData,
address: buildAddressString(formData.street, formData.house, formData.apartment, formData.entrance),
};
await onSubmit(payload);
if (!initialData) {
setFormData({
date: defaultDate || getTodayFrontend(),
pickupLocation: 'warehouse',
pickupLocation2: null,
warehouseRequestSource: null,
warehouseRequestSource2: null,
productName: '',
productName2: '',
customerName: '',
address: '',
street: '',
house: '',
apartment: '',
entrance: '',
floor: '',
phone: '',
additionalPhone: '',
hasElevator: false,
paymentStatus: 'prepaid',
serviceInfo: '',
comment: '',
status: 'new',
});
setShowSecondPickup(false);
}
await onSubmit({ ...data, productName: data.productName.trim(), customerName: data.customerName.trim(), street: data.street.trim(), house: data.house.trim(), address });
onClose();
} catch {
// Error is handled by parent, keep form open
setError('Не удалось сохранить доставку. Проверьте соединение и попробуйте ещё раз. Все введённые данные сохранены в форме.');
} finally {
submitting.current = false;
setSubmitting(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={initialData ? 'Редактировать доставку' : 'Новая доставка'}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
Отмена
<Modal isOpen onClose={requestClose} closeDisabled={isSubmitting} title={initialData ? 'Редактировать доставку' : 'Новая доставка'}
footer={<div className="w-full">
{error && <div role="alert" className="mb-3 flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800"><AlertCircle size={18} className="mt-0.5 shrink-0" /><p>{error}</p></div>}
<div className="flex justify-end gap-3"><Button variant="ghost" onClick={requestClose} disabled={isSubmitting}>Отмена</Button>
<Button type="submit" form="delivery-form" disabled={isSubmitting}>
{isSubmitting && <Loader2 size={18} className="mr-2 animate-spin" />}
{isSubmitting ? 'Сохранение…' : initialData ? 'Сохранить' : 'Создать доставку'}
</Button>
<Button type="submit" form="delivery-form" disabled={isSubmitting || !isFormValid}>
{isSubmitting ? 'Сохранение...' : initialData ? 'Сохранить' : 'Создать'}
</Button>
</>
}
>
<form id="delivery-form" onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-[#1b1b1d] mb-1">
Дата доставки
</div>
</div>}>
<form id="delivery-form" onSubmit={handleSubmit} className="space-y-7" aria-busy={isSubmitting}>
<p className="text-sm text-[#666870]">Поля со звёздочкой обязательны.</p>
<fieldset disabled={isSubmitting} className="space-y-4">
<legend className="mb-3 text-base font-semibold">Дата и загрузка</legend>
<Input label="Дата доставки *" type="date" value={formatDateForInput(data.date)} required onChange={e => update('date', parseDateFromInput(e.target.value))} />
<Select label="Место загрузки" value={data.pickupLocation} options={pickupOptions} onChange={e => {
const pickupLocation = e.target.value as PickupLocation;
setData(prev => ({ ...prev, pickupLocation, warehouseRequestSource: pickupLocation === 'warehouse' ? prev.warehouseRequestSource : null }));
}} />
{data.pickupLocation === 'warehouse' && <Select label="От кого заявка *" value={data.warehouseRequestSource || ''} options={requestSourceOptions} required
error={attempted && sourceMissing ? 'Выберите источник заявки' : undefined}
onChange={e => update('warehouseRequestSource', e.target.value as DeliveryRequestSource || null)} />}
<Input label="Название товара *" value={data.productName} required pattern=".*\S.*" onChange={e => update('productName', e.target.value)} placeholder="Например, диван и два кресла" />
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm font-medium">
<input type="checkbox" className="h-5 w-5" checked={secondPickup} onChange={e => {
setSecondPickup(e.target.checked);
setData(prev => ({ ...prev, pickupLocation2: e.target.checked ? 'warehouse' : null, warehouseRequestSource2: null, productName2: '' }));
}} />Добавить вторую точку загрузки
</label>
<input
type="date"
value={formatDateForInput(formData.date)}
onChange={(e) => setFormData({ ...formData, date: parseDateFromInput(e.target.value) })}
className="w-full px-3 py-2 bg-[#f5f3f5] border border-[#c5c6cd] rounded-md text-[#1b1b1d] focus:outline-none focus:ring-2 focus:ring-[#1B263B] focus:border-transparent transition-colors"
required
/>
</div>
<Select
label="Место загрузки"
value={formData.pickupLocation}
onChange={(e) => handlePickupLocationChange(e.target.value as PickupLocation)}
options={pickupOptions}
/>
{formData.pickupLocation === 'warehouse' && (
<Select
label="От кого заявка *"
value={formData.warehouseRequestSource || ''}
onChange={(e) => setFormData({ ...formData, warehouseRequestSource: e.target.value ? e.target.value as DeliveryRequestSource : null })}
options={requestSourceOptions}
required
error={!isWarehouseRequestSourceValid ? 'Выберите, от кого исходит заявка' : undefined}
/>
)}
<Input
label="Название товара"
value={formData.productName}
onChange={(e) => setFormData({ ...formData, productName: e.target.value })}
placeholder="Введите название товара"
required
/>
{/* Address fields */}
<div className="bg-[#f5f3f5] rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-[#1b1b1d]">Адрес доставки</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[#75777d] mb-1">Улица *</label>
<input
type="text"
value={formData.street}
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
required
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Дом *</label>
<input
type="text"
value={formData.house}
onChange={(e) => setFormData({ ...formData, house: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
required
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Квартира</label>
<input
type="text"
value={formData.apartment}
onChange={(e) => setFormData({ ...formData, apartment: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Подъезд</label>
<input
type="text"
value={formData.entrance}
onChange={(e) => setFormData({ ...formData, entrance: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-[#75777d] mb-1">Этаж</label>
<input
type="text"
value={formData.floor}
onChange={(e) => setFormData({ ...formData, floor: e.target.value })}
className="w-full px-2 py-1.5 bg-white border border-[#c5c6cd] rounded text-sm"
/>
</div>
</div>
</div>
<Input
label="ФИО клиента *"
value={formData.customerName}
onChange={(e) => setFormData({ ...formData, customerName: e.target.value })}
placeholder="Иванов Иван Иванович"
required
/>
<Input
label="Телефон покупателя *"
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
onFocus={(e) => {
if (!e.target.value) {
setFormData({ ...formData, phone: '+7' });
}
}}
placeholder="+7 (776)-567-89-01"
required
aria-invalid={!isPhoneValid}
aria-describedby={!isPhoneValid ? 'phone-error' : undefined}
/>
{!isPhoneValid && formData.phone && (
<p id="phone-error" className="text-sm text-red-500 mt-1">
Введите корректный номер: +7 (XXX) XXX-XX-XX
</p>
)}
<Input
label="Дополнительный номер телефона"
type="tel"
value={formData.additionalPhone}
onChange={(e) => setFormData({ ...formData, additionalPhone: e.target.value })}
onFocus={(e) => {
if (!e.target.value) {
setFormData({ ...formData, additionalPhone: '+7' });
}
}}
placeholder="+7 (776)-567-89-01"
aria-invalid={!isAdditionalPhoneValid}
aria-describedby={!isAdditionalPhoneValid ? 'additional-phone-error' : undefined}
/>
{!isAdditionalPhoneValid && formData.additionalPhone && (
<p id="additional-phone-error" className="text-sm text-red-500 mt-1">
Введите корректный номер: +7 (XXX) XXX-XX-XX
</p>
)}
{/* Second pickup location */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="hasSecondPickup"
checked={showSecondPickup}
onChange={(e) => {
const checked = e.target.checked;
setShowSecondPickup(checked);
setFormData({
...formData,
pickupLocation2: checked ? formData.pickupLocation2 || 'warehouse' : null,
warehouseRequestSource2: checked ? formData.warehouseRequestSource2 : null,
productName2: checked ? formData.productName2 : '',
});
}}
className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]"
/>
<label htmlFor="hasSecondPickup" className="text-sm text-[#1b1b1d]">
Добавить вторую точку загрузки
</label>
</div>
{showSecondPickup && (
<div className="bg-[#f5f3f5] rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-[#1b1b1d]">Вторая точка загрузки</p>
<Select
label="Место загрузки 2"
value={formData.pickupLocation2 || ''}
onChange={(e) => handlePickupLocation2Change(e.target.value as PickupLocation)}
options={pickupOptions}
/>
{formData.pickupLocation2 === 'warehouse' && (
<Select
label="От кого заявка 2 *"
value={formData.warehouseRequestSource2 || ''}
onChange={(e) => setFormData({ ...formData, warehouseRequestSource2: e.target.value ? e.target.value as DeliveryRequestSource : null })}
options={requestSourceOptions}
required
error={!isWarehouseRequestSource2Valid ? 'Выберите, от кого исходит заявка' : undefined}
/>
)}
<Input
label="Название товара 2"
value={formData.productName2}
onChange={(e) => setFormData({ ...formData, productName2: e.target.value })}
placeholder="Название товара со второй точки"
/>
{secondPickup && <div className="space-y-4 border-t border-[#e4e2e4] pt-4">
<Select label="Второе место загрузки" value={data.pickupLocation2 || 'warehouse'} options={pickupOptions} onChange={e => {
const pickupLocation2 = e.target.value as PickupLocation;
setData(prev => ({ ...prev, pickupLocation2, warehouseRequestSource2: pickupLocation2 === 'warehouse' ? prev.warehouseRequestSource2 : null }));
}} />
{data.pickupLocation2 === 'warehouse' && <Select label="От кого вторая заявка *" value={data.warehouseRequestSource2 || ''} options={requestSourceOptions} required
error={attempted && secondSourceMissing ? 'Выберите источник второй заявки' : undefined}
onChange={e => update('warehouseRequestSource2', e.target.value as DeliveryRequestSource || null)} />}
<Input label="Товар со второй точки" value={data.productName2 || ''} onChange={e => update('productName2', e.target.value)} />
</div>}
</fieldset>
<fieldset disabled={isSubmitting} className="space-y-4 border-t border-[#e4e2e4] pt-5">
<legend className="text-base font-semibold">Адрес доставки</legend>
<Input label="Улица *" value={data.street} required pattern=".*\S.*" autoComplete="address-line1" onChange={e => update('street', e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="Дом *" value={data.house} required pattern=".*\S.*" onChange={e => update('house', e.target.value)} />
<Input label="Квартира" value={data.apartment || ''} onChange={e => update('apartment', e.target.value)} />
<Input label="Подъезд" value={data.entrance || ''} onChange={e => update('entrance', e.target.value)} />
<Input label="Этаж" value={data.floor || ''} onChange={e => update('floor', e.target.value)} />
</div>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="hasElevator"
checked={formData.hasElevator}
onChange={(e) => setFormData({ ...formData, hasElevator: e.target.checked })}
className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]"
/>
<label htmlFor="hasElevator" className="text-sm text-[#1b1b1d]">
Наличие лифта
</label>
</div>
<Select
label="Оплата"
value={formData.paymentStatus}
onChange={(e) => setFormData({ ...formData, paymentStatus: e.target.value as PaymentStatus })}
options={paymentStatusOptions}
required
/>
<Input
label="Услуги (сборка, подъём)"
value={formData.serviceInfo}
onChange={(e) => setFormData({ ...formData, serviceInfo: e.target.value })}
placeholder="Сборка 5000 тг, подъём на этаж 3000 тг"
/>
<Input
label="Комментарий"
value={formData.comment}
onChange={(e) => setFormData({ ...formData, comment: e.target.value })}
placeholder="Дополнительная информация..."
/>
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm font-medium"><input type="checkbox" className="h-5 w-5" checked={data.hasElevator} onChange={e => update('hasElevator', e.target.checked)} />Есть лифт</label>
</fieldset>
<fieldset disabled={isSubmitting} className="space-y-4 border-t border-[#e4e2e4] pt-5">
<legend className="text-base font-semibold">Клиент и оплата</legend>
<Input label="ФИО клиента *" value={data.customerName} required pattern=".*\S.*" autoComplete="name" onChange={e => update('customerName', e.target.value)} />
<Input label="Телефон покупателя *" type="tel" inputMode="tel" autoComplete="tel" value={data.phone} required placeholder="+7 (776) 567-89-01" error={phoneError}
onChange={e => update('phone', e.target.value)} />
<Input label="Дополнительный телефон" type="tel" inputMode="tel" value={data.additionalPhone || ''} placeholder="Необязательно" error={extraPhoneError} onChange={e => update('additionalPhone', e.target.value)} />
<Select label="Оплата" value={data.paymentStatus} options={paymentOptions} onChange={e => update('paymentStatus', e.target.value as PaymentStatus)} />
<Input label="Услуги: сборка, подъём" value={data.serviceInfo || ''} placeholder="Например, сборка 5000 тг" onChange={e => update('serviceInfo', e.target.value)} />
<Input label="Комментарий" value={data.comment} placeholder="Что важно знать доставщику" onChange={e => update('comment', e.target.value)} />
</fieldset>
</form>
</Modal>
);
};
}
+41 -114
View File
@@ -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) => <DeliveryCard key={delivery.id} delivery={delivery} pending={pendingIds.includes(delivery.id)} onStatusChange={onStatusChange} onEdit={onEdit} onDelete={onDelete} />;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-[#1b1b1d]">
Доставки на {date}
</h2>
<div className="flex items-center gap-2">
<div className="flex bg-[#f0edef] rounded-lg p-1">
<button
onClick={() => setViewMode('kanban')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'kanban' ? 'bg-white shadow-sm text-[#1b1b1d]' : 'text-[#75777d] hover:text-[#1b1b1d]'
}`}
>
<LayoutGrid size={16} />
<span className="hidden sm:inline">Канбан</span>
</button>
<button
onClick={() => setViewMode('table')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'table' ? 'bg-white shadow-sm text-[#1b1b1d]' : 'text-[#75777d] hover:text-[#1b1b1d]'
}`}
>
<TableIcon size={16} />
<span className="hidden sm:inline">Таблица</span>
</button>
</div>
<Button onClick={onAdd} size="sm">
<Plus size={16} className="mr-1" />
<span className="hidden sm:inline">Добавить</span>
</Button>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-[#666870]" aria-live="polite">{filtered ? `Показано ${deliveries.length} из ${total}` : `Всего за день: ${total}`}</p>
<div className="inline-flex rounded-lg bg-[#eae7e9] p-1" role="group" aria-label="Вид списка" data-tour="view-switch">
<button type="button" onClick={() => changeView('cards')} aria-pressed={viewMode === 'cards'} className={`flex min-h-11 items-center gap-2 rounded-md px-3 text-sm font-medium ${viewMode === 'cards' ? 'bg-white text-[#1B263B]' : 'text-[#575963]'}`}><LayoutGrid size={17} />Карточки</button>
<button type="button" onClick={() => changeView('table')} aria-pressed={viewMode === 'table'} className={`flex min-h-11 items-center gap-2 rounded-md px-3 text-sm font-medium ${viewMode === 'table' ? 'bg-white text-[#1B263B]' : 'text-[#575963]'}`}><TableIcon size={17} />Таблица</button>
</div>
</div>
{deliveries.length === 0 ? (
<div className="text-center py-12 bg-[#f5f3f5] rounded-lg">
<p className="text-[#75777d]">Нет доставок на эту дату</p>
<Button onClick={onAdd} variant="ghost" className="mt-2">
Создать первую доставку
</Button>
<div className="rounded-xl border border-dashed border-[#c5c6cd] px-5 py-12 text-center">
<PackageSearch size={28} className="mx-auto mb-3 text-[#666870]" />
<h2 className="text-lg font-semibold">{filtered ? 'Нет подходящих доставок' : 'На этот день доставок пока нет'}</h2>
<p className="mx-auto mt-2 max-w-sm text-sm text-[#666870]">{filtered ? 'Измените поиск или фильтры, чтобы увидеть другие заявки.' : 'Создайте заявку — здесь появятся адрес, товары и контакты клиента.'}</p>
<Button onClick={filtered ? onResetFilters : onAdd} variant="secondary" className="mt-5">{!filtered && <Plus size={18} className="mr-2" />}{filtered ? 'Сбросить фильтры' : 'Создать доставку'}</Button>
</div>
) : viewMode === 'kanban' ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-[#1b1b1d] flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#F28C28]"></span>
Новые
<span className="text-sm text-[#75777d] font-normal">({newDeliveries.length})</span>
</h3>
</div>
<div className="space-y-3">
{newDeliveries.map(delivery => (
<DeliveryCard
key={delivery.id}
delivery={delivery}
onStatusChange={onStatusChange}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-[#1b1b1d] flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#16a34a]"></span>
Доставлено
<span className="text-sm text-[#75777d] font-normal">({deliveredDeliveries.length})</span>
</h3>
</div>
<div className="space-y-3">
{deliveredDeliveries.map(delivery => (
<DeliveryCard
key={delivery.id}
delivery={delivery}
onStatusChange={onStatusChange}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</div>
) : viewMode === 'cards' ? (
<div className="space-y-7">
{(['new', 'delivered'] as const).map(status => {
const items = deliveries.filter(d => d.status === status);
return items.length > 0 && <section key={status} aria-label={status === 'new' ? 'В работе' : 'Доставлено'}>
<h2 className="mb-3 flex items-center gap-2 font-semibold">{status === 'new' ? 'В работе' : 'Доставлено'}<span className="text-sm font-normal text-[#666870]">{items.length}</span></h2>
<div className="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-2 xl:grid-cols-3">{items.map(renderCard)}</div>
</section>;
})}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full bg-white rounded-lg shadow-sm border border-[#e4e2e4]">
<thead className="bg-[#f5f3f5]">
<tr className="text-left text-xs font-semibold text-[#75777d] uppercase tracking-wider">
<th className="px-4 py-3">Статус</th>
<th className="px-4 py-3">Дата</th>
<th className="px-4 py-3">Загрузка</th>
<th className="px-4 py-3">Товар</th>
<th className="px-4 py-3">Клиент</th>
<th className="px-4 py-3">Адрес</th>
<th className="px-4 py-3">Телефон</th>
<th className="px-4 py-3">Оплата</th>
<th className="px-4 py-3">Комментарий</th>
<th className="px-4 py-3">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-[#e4e2e4]">
{deliveries.map(delivery => (
<DeliveryRow
key={delivery.id}
delivery={delivery}
onStatusChange={onStatusChange}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</tbody>
<div className="overflow-x-auto rounded-xl border border-[#dedde2] bg-white" role="region" aria-label="Таблица доставок, можно прокручивать горизонтально" tabIndex={0}>
<table className="w-full text-left">
<caption className="sr-only">Доставки на {date}</caption>
<thead className="bg-[#f5f3f5] text-sm text-[#575963]"><tr>
{['Статус', 'Товар и загрузка', 'Клиент и связь', 'Адрес', 'Оплата', 'Комментарий', 'Действия'].map(label => <th scope="col" key={label} className="whitespace-nowrap px-3 py-3 font-medium">{label}</th>)}
</tr></thead>
<tbody className="divide-y divide-[#e4e2e4]">{deliveries.map(delivery => <DeliveryRow key={delivery.id} delivery={delivery} pending={pendingIds.includes(delivery.id)} onStatusChange={onStatusChange} onEdit={onEdit} onDelete={onDelete} />)}</tbody>
</table>
</div>
)}
@@ -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 (
<tr className="hover:bg-[#f5f3f5] transition-colors border-b border-[#e4e2e4] last:border-b-0">
<td className="px-4 py-3">
<StatusBadge
status={delivery.status}
onClick={() => onStatusChange(delivery.id)}
size="sm"
/>
</td>
<td className="px-4 py-3 text-sm text-[#1b1b1d]">{delivery.date}</td>
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
{delivery.pickupLocation2
? `${formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)} + ${formatPickupLocation(delivery.pickupLocation2, delivery.warehouseRequestSource2)}`
: formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)}
</td>
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
{delivery.productName}
{delivery.productName2 && <span className="block text-xs text-[#75777d]">+ {delivery.productName2}</span>}
</td>
<td className="px-4 py-3 text-sm text-[#1b1b1d]">{delivery.customerName}</td>
<td className="px-4 py-3">
<button
onClick={handleAddressClick}
className="flex items-center gap-1.5 text-sm text-[#1B263B] hover:text-[#F28C28] transition-colors text-left"
>
<MapPin size={14} />
<span className="max-w-[200px] truncate">
ул. {delivery.street}, д. {delivery.house}{delivery.apartment ? `, кв. ${delivery.apartment}` : ''}
</span>
</button>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={handlePhoneClick}
className="flex items-center gap-1.5 text-sm text-[#16a34a] hover:underline transition-colors"
>
<Phone size={14} />
{delivery.phone}
</button>
<button
onClick={handleWhatsAppClick}
className="p-1.5 rounded-md text-[#16a34a] hover:bg-[#f5f3f5] transition-colors"
title="Открыть WhatsApp"
aria-label={`Открыть WhatsApp для номера ${delivery.phone}`}
>
<WhatsAppIcon className="h-5 w-5" />
</button>
</div>
</td>
<td className="px-4 py-3 text-sm text-[#45474d] max-w-[200px] truncate">
<PaymentBadge status={delivery.paymentStatus} size="sm" />
</td>
<td className="px-4 py-3 text-sm text-[#45474d] max-w-[200px] truncate">
{delivery.comment || '-'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={() => onEdit(delivery)}
className="p-1.5 rounded-md hover:bg-[#e4e2e4] text-[#75777d] transition-colors"
title="Редактировать"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
<button
onClick={() => onDelete(delivery.id)}
className="p-1.5 rounded-md hover:bg-red-50 text-red-500 transition-colors"
title="Удалить"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>
</div>
</td>
</tr>
);
});
export const DeliveryRow = memo(({ delivery: d, onStatusChange, onEdit, onDelete, pending }: DeliveryRowProps) => (
<tr data-tour="delivery-card" aria-busy={pending} className="align-top transition-colors hover:bg-[#f5f3f5]">
<td className="px-3 py-3"><StatusBadge status={d.status} onClick={() => onStatusChange(d.id)} disabled={pending} size="sm" /></td>
<td className="min-w-[140px] max-w-[220px] px-3 py-3 text-sm">
<p className="break-words font-medium">{d.productName}</p><p className="mt-1 text-[#666870]">{formatPickupLocation(d.pickupLocation, d.warehouseRequestSource)}</p>
{d.pickupLocation2 && <div className="mt-2"><p className="break-words font-medium">{d.productName2 || 'Вторая загрузка'}</p><p className="mt-1 text-[#666870]">{formatPickupLocation(d.pickupLocation2, d.warehouseRequestSource2)}</p></div>}
</td>
<td className="min-w-[190px] px-3 py-3 text-sm"><p className="font-medium">{d.customerName}</p>
<div className="flex items-center gap-1"><a href={`tel:${d.phone}`} className="flex min-h-11 items-center whitespace-nowrap text-[#166534] hover:underline">{d.phone}</a><a href={getWhatsAppUrl(d.phone)} className="icon-button text-[#166534]" aria-label={`WhatsApp: ${d.phone}`}><WhatsAppIcon className="h-5 w-5" /></a></div>
{d.additionalPhone && <div className="flex items-center gap-1"><a href={`tel:${d.additionalPhone}`} className="flex min-h-11 items-center whitespace-nowrap text-[#666870]">{d.additionalPhone}</a><a href={getWhatsAppUrl(d.additionalPhone)} className="icon-button" aria-label={`WhatsApp: ${d.additionalPhone}`}><WhatsAppIcon className="h-5 w-5" /></a></div>}
</td>
<td className="min-w-[160px] max-w-[220px] px-3 py-3 text-sm">
<a href={`https://2gis.kz/kokshetau/search/${encodeURIComponent(d.address)}`} target="_blank" rel="noopener noreferrer" className="flex min-h-11 items-start gap-1.5 text-[#1B263B] underline-offset-4 hover:underline"><MapPin size={16} className="mt-0.5 shrink-0" /><span className="break-words">ул. {d.street}, д. {d.house}{d.apartment ? `, кв. ${d.apartment}` : ''}</span></a>
<p className="mt-1 text-[#666870]">{[d.entrance && `Подъезд ${d.entrance}`, d.floor && `этаж ${d.floor}`, d.hasElevator ? 'есть лифт' : 'нет лифта'].filter(Boolean).join(' · ')}</p>
</td>
<td className="px-3 py-3"><PaymentBadge status={d.paymentStatus} size="sm" /></td>
<td className="min-w-[130px] max-w-[210px] break-words px-3 py-3 text-sm text-[#45474d]">{d.serviceInfo && <p className="mb-2 font-medium">{d.serviceInfo}</p>}{d.comment || '—'}</td>
<td className="px-3 py-3"><div className="flex gap-1">
<button type="button" disabled={pending} onClick={() => onEdit(d)} className="icon-button" title="Редактировать" aria-label={`Редактировать доставку для ${d.customerName}`}><Pencil size={18} /></button>
<button type="button" disabled={pending} onClick={() => onDelete(d.id)} className="icon-button text-red-700" title="Удалить" aria-label={`Удалить доставку для ${d.customerName}`}><Trash2 size={18} /></button>
</div></td>
</tr>
));
DeliveryRow.displayName = 'DeliveryRow';
@@ -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 (
<span
className={`${baseStyles} ${variants[status]} ${sizes[size]} ${clickableStyles}`}
onClick={onClick}
>
{statusLabels[status]}
</span>
);
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 = <><span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${status === 'new' ? 'bg-[#a75200]' : 'bg-[#166534]'}`} />{statusLabels[status]}</>;
return onClick
? <button type="button" data-tour="delivery-status" className={`${className} min-h-11 disabled:opacity-50`} disabled={disabled} onClick={onClick}
aria-label={status === 'new' ? 'Отметить доставленным' : 'Вернуть в новые'}>{content}</button>
: <span className={className}>{content}</span>;
};
+6 -4
View File
@@ -11,25 +11,27 @@ export const Button = ({
size = 'md',
children,
className = '',
type = 'button',
...props
}: ButtonProps) => {
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-[#1B263B] text-white hover:bg-[#2a3a52] focus:ring-[#1B263B]',
secondary: 'bg-[#f0edef] text-[#1b1b1d] hover:bg-[#e4e2e4] focus:ring-[#75777d]',
tertiary: 'bg-[#F28C28] text-white hover:bg-[#d97a1f] focus:ring-[#F28C28]',
tertiary: 'bg-[#F28C28] text-[#1B263B] hover:bg-[#d97a1f] focus:ring-[#F28C28]',
ghost: 'bg-transparent text-[#1b1b1d] hover:bg-[#f5f3f5] focus:ring-[#75777d]',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
sm: 'min-h-11 px-3 py-2 text-sm',
md: 'min-h-11 px-4 py-2.5 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<button
type={type}
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
+1 -1
View File
@@ -15,7 +15,7 @@ export const Card = ({ children, className = '', padding = 'md' }: CardProps) =>
};
return (
<div className={`bg-white rounded-lg shadow-sm border border-[#e4e2e4] ${paddings[padding]} ${className}`}>
<div className={`bg-white rounded-xl border border-[#e4e2e4] ${paddings[padding]} ${className}`}>
{children}
</div>
);
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import { Button } from './Button';
interface TourStep { target: string; title: string; description: string; optional?: boolean }
interface GuidedTourProps {
scope: 'dashboard' | 'delivery-list';
userKey: string;
ready: boolean;
replay: number;
}
// Increment only when a release changes the workflow. Ordinary releases do not
// replay training. Completion/dismissal is stored per account, device and surface.
const TOUR_VERSION = 'workflow-2026-10-v1';
const stepsByScope: Record<GuidedTourProps['scope'], TourStep[]> = {
dashboard: [
{ target: 'today-deliveries', title: 'Откройте рабочий день', description: 'Кнопка «Доставки на сегодня» сразу открывает заявки текущего дня. Это быстрый старт для менеджера и доставщика.' },
{ target: 'calendar', title: 'Выберите другую дату', description: 'В календаре числа рядом с датами показывают количество доставок. Нажмите на день, чтобы открыть его заявки.' },
{ target: 'new-delivery', title: 'Добавьте заявку', description: 'Здесь создаётся доставка: дата, загрузка, адрес и контакты клиента. При ошибке сохранения введённые данные остаются в форме.' },
{ target: 'tour-help', title: 'Подсказки всегда под рукой', description: 'Кнопка «Помощь» повторяет обзор текущего экрана. В списке доставок вас ждут подсказки о поиске, карточках и статусах.' },
],
'delivery-list': [
{ target: 'day-navigation', title: 'Переключайте дни здесь', description: 'Стрелки открывают соседний день. Поле даты позволяет выбрать любой день, а «Сегодня» возвращает к текущему.' },
{ target: 'delivery-filters', title: 'Найдите нужную доставку', description: 'Ищите по клиенту, адресу, товару или телефону. Место загрузки и статус сужают список. На телефоне они находятся в разделе «Фильтры». «Сбросить фильтры» возвращает все заявки.' },
{ target: 'view-switch', title: 'Выберите удобный вид', description: 'Карточки удобны в дороге, таблица — для работы за компьютером. Ваш выбор сохраняется и не сбрасывается после изменения статуса.' },
{ target: 'delivery-card', title: 'Адрес и контакты на первом месте', description: 'Адрес открывает 2ГИС. Телефон позволяет позвонить, а значок WhatsApp — написать клиенту. Ниже указаны товары, загрузка и условия доставки.', optional: true },
{ target: 'delivery-status', title: 'Отметьте результат', description: 'После доставки нажмите «Отметить доставленным». Если нажали случайно, верните заявку в новые. Остальные доставки остаются на экране.', optional: true },
{ target: 'new-delivery', title: 'Создайте или измените заявку', description: 'Новая доставка создаётся на выбранную дату. Для изменения существующей используйте карандаш в её карточке или строке таблицы. Обзор можно повторить через «Помощь».' },
],
};
const wasSeen = (key: string) => { try { return localStorage.getItem(key) === 'seen'; } catch { return false; } };
export function GuidedTour({ scope, userKey, ready, replay }: GuidedTourProps) {
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
const [steps, setSteps] = useState<TourStep[]>([]);
const [rect, setRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const dialogRef = useRef<HTMLDialogElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const lastReplay = useRef(replay);
const key = `delivery-tour:${userKey}:${TOUR_VERSION}:${scope}`;
const titleId = useId();
const descriptionId = useId();
const maskId = useId().replace(/:/g, '');
const finish = () => {
try { localStorage.setItem(key, 'seen'); } catch { /* Training still works without persistent storage. */ }
setOpen(false);
};
useEffect(() => {
const requested = replay !== lastReplay.current;
if ((!ready && !requested) || (wasSeen(key) && !requested)) return;
const timer = window.setTimeout(() => {
if (!document.querySelector(`[data-tour="${stepsByScope[scope][0].target}"]`)) return;
lastReplay.current = replay;
const available = stepsByScope[scope].filter(step => !step.optional || document.querySelector(`[data-tour="${step.target}"]`));
setSteps(available);
setIndex(0);
setOpen(true);
}, requested ? 0 : 650);
return () => clearTimeout(timer);
}, [scope, key, ready, replay]);
useEffect(() => {
if (!open) return;
const dialog = dialogRef.current;
const previous = document.activeElement as HTMLElement | null;
dialog?.showModal();
return () => { dialog?.close(); if (previous?.isConnected) previous.focus({ preventScroll: true }); };
}, [open]);
useEffect(() => {
if (!open || !steps[index]) return;
const target = document.querySelector<HTMLElement>(`[data-tour="${steps[index].target}"]`);
target?.scrollIntoView({ block: 'center', behavior: 'instant' });
const measure = () => {
const bounds = target?.getBoundingClientRect();
const width = window.innerWidth;
const height = window.innerHeight;
const panelHeight = panelRef.current?.offsetHeight || 250;
const panelWidth = Math.min(368, width - 32);
const margin = 16;
if (bounds) {
const headerTarget = !!target?.closest('header');
const mobile = width < 640;
const panelTop = height - panelHeight - margin;
// Keep small targets above the mobile sheet. Tall surfaces (calendar,
// filter block, card) spotlight their visible portion instead of sitting
// behind the explanation. Scroll only toward the current target.
if (mobile && !headerTarget && bounds.top < 88 && window.scrollY > 0) {
window.scrollBy({ top: bounds.top - 96, behavior: 'instant' });
return;
}
if (mobile && !headerTarget && bounds.bottom + 16 > panelTop && bounds.height < panelTop - 112) {
const delta = Math.min(bounds.bottom + 16 - panelTop, Math.max(0, bounds.top - 96));
if (delta > 1) { window.scrollBy({ top: delta, behavior: 'instant' }); return; }
}
const spotlightTop = Math.max(headerTarget ? 4 : 76, bounds.top - 6);
const spotlightBottom = Math.min(bounds.bottom + 6, mobile ? panelTop - 12 : height - margin);
setRect({ top: spotlightTop, left: Math.max(6, bounds.left - 6), width: Math.min(bounds.width + 12, width - 12), height: Math.max(20, spotlightBottom - spotlightTop) });
const below = bounds.bottom + 18;
const above = bounds.top - panelHeight - 18;
const top = mobile ? panelTop : below + panelHeight < height - margin ? below : above >= margin ? above : height - panelHeight - margin;
setPosition({ top: Math.max(margin, top), left: mobile ? margin : Math.max(margin, Math.min(bounds.left, width - panelWidth - margin)) });
} else {
setRect(null);
setPosition({ top: Math.max(margin, (height - panelHeight) / 2), left: Math.max(margin, (width - panelWidth) / 2) });
}
};
const frame = requestAnimationFrame(measure);
const observer = new ResizeObserver(measure);
if (target) observer.observe(target);
if (panelRef.current) observer.observe(panelRef.current);
window.addEventListener('resize', measure);
window.addEventListener('scroll', measure, true);
return () => { cancelAnimationFrame(frame); observer.disconnect(); window.removeEventListener('resize', measure); window.removeEventListener('scroll', measure, true); };
}, [open, index, steps]);
if (!open || !steps[index]) return null;
const step = steps[index];
return createPortal(
<dialog ref={dialogRef} className="tour-dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId}
onCancel={e => { e.preventDefault(); finish(); }}>
<svg className="pointer-events-none fixed inset-0 h-full w-full" aria-hidden="true">
<defs><mask id={maskId}><rect width="100%" height="100%" fill="white" />{rect && <rect width={rect.width} height={rect.height} x={rect.left} y={rect.top} rx="12" fill="black" />}</mask></defs>
<rect width="100%" height="100%" fill="#101a2d" fillOpacity="0.72" mask={`url(#${maskId})`} />
{rect && <rect x={rect.left} y={rect.top} width={rect.width} height={rect.height} rx="12" fill="none" stroke="white" strokeWidth="2" />}
</svg>
<div ref={panelRef} className="tour-panel fixed w-[calc(100%-32px)] max-w-[368px] rounded-xl bg-white p-5 text-[#1b1b1d] shadow-xl" style={position}>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-sm text-[#666870]" aria-live="polite">Шаг {index + 1} из {steps.length}</p>
<button type="button" onClick={finish} className="icon-button -mr-2" aria-label="Закрыть обучение"><X size={18} /></button>
</div>
<div aria-live="polite" aria-atomic="true"><h2 id={titleId} className="text-xl font-semibold leading-snug">{step.title}</h2><p id={descriptionId} className="mt-2 text-base leading-relaxed text-[#575963]">{step.description}</p></div>
<div className="mt-5 flex items-center justify-between gap-2">
<button type="button" onClick={finish} className="min-h-11 text-sm text-[#575963] underline underline-offset-4">Пропустить</button>
<div className="flex gap-2">{index > 0 && <Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)} aria-label="Предыдущая подсказка"><ChevronLeft size={18} /></Button>}
<Button size="sm" onClick={() => index === steps.length - 1 ? finish() : setIndex(index + 1)}>{index === steps.length - 1 ? 'Готово' : 'Далее'}{index < steps.length - 1 && <ChevronRight size={18} className="ml-1" />}</Button>
</div>
</div>
</div>
</dialog>, document.body,
);
}
+10 -15
View File
@@ -1,25 +1,20 @@
import type { InputHTMLAttributes } from 'react';
import { useId, type InputHTMLAttributes } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
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 (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-[#1b1b1d] mb-1">
{label}
</label>
)}
<input
className={`w-full px-3 py-2 bg-[#f5f3f5] border border-[#c5c6cd] rounded-md text-[#1b1b1d] placeholder-[#75777d] focus:outline-none focus:ring-2 focus:ring-[#1B263B] focus:border-transparent transition-colors ${className}`}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
<div className="w-full min-w-0">
{label && <label htmlFor={inputId} className="mb-1.5 block text-sm font-medium text-[#1b1b1d]">{label}</label>}
<input {...props} id={inputId} aria-invalid={error ? true : props['aria-invalid']} aria-describedby={description}
className={`min-h-11 w-full rounded-lg border bg-white px-3 py-2 text-base text-[#1b1b1d] placeholder:text-[#666870] disabled:opacity-60 ${error ? 'border-red-500' : 'border-[#c5c6cd]'} ${className}`} />
{error && <p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-700">{error}</p>}
</div>
);
};
+30 -29
View File
@@ -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;
export const Modal = ({ isOpen, onClose, title, children, footer, closeDisabled = false }: ModalProps) => {
const ref = useRef<HTMLDialogElement>(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 (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<div
className="fixed inset-0 bg-black/30 transition-opacity"
onClick={onClose}
/>
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all w-full max-w-lg">
<div className="flex items-center justify-between border-b border-[#e4e2e4] px-4 py-3">
<h3 className="text-lg font-semibold text-[#1b1b1d]">{title}</h3>
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-[#f5f3f5] transition-colors"
>
<X size={20} className="text-[#75777d]" />
</button>
</div>
<div className="px-4 py-4">
{children}
</div>
{footer && (
<div className="border-t border-[#e4e2e4] px-4 py-3 flex justify-end gap-2">
{footer}
</div>
)}
return createPortal(
<dialog ref={ref} aria-labelledby={titleId} aria-modal="true" className="app-dialog"
onCancel={e => { e.preventDefault(); if (!closeDisabled) onClose(); }}
onClick={e => { if (e.target === e.currentTarget && !closeDisabled) onClose(); }}>
<div className="dialog-panel">
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-[#e4e2e4] px-5 py-4">
<h2 id={titleId} className="text-xl font-semibold">{title}</h2>
<button type="button" onClick={onClose} disabled={closeDisabled} aria-label="Закрыть окно" className="icon-button"><X size={20} /></button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">{children}</div>
{footer && <div className="dialog-footer flex shrink-0 justify-end gap-3 border-t border-[#e4e2e4] bg-white px-5 py-4">{footer}</div>}
</div>
</div>
</dialog>, document.body,
);
};
+12 -25
View File
@@ -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<HTMLSelectElement> {
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 (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-[#1b1b1d] mb-1">
{label}
</label>
)}
<select
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 ${className}`}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
<div className="w-full min-w-0">
{label && <label htmlFor={selectId} className="mb-1.5 block text-sm font-medium text-[#1b1b1d]">{label}</label>}
<select {...props} id={selectId} aria-invalid={error ? true : props['aria-invalid']} aria-describedby={description}
className={`min-h-11 w-full rounded-lg border bg-white px-3 py-2 text-base text-[#1b1b1d] ${error ? 'border-red-500' : 'border-[#c5c6cd]'} ${className}`}>
{options.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
{error && <p id={`${selectId}-error`} className="mt-1.5 text-sm text-red-700">{error}</p>}
</div>
);
};
+3 -3
View File
@@ -19,20 +19,20 @@ export const ToastContainer = () => {
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<div className="fixed bottom-4 left-4 right-4 sm:left-auto z-50 max-w-sm flex flex-col gap-2">
{toasts.map((toast) => {
const Icon = icons[toast.type];
return (
<div
key={toast.id}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[300px] animate-in slide-in-from-right ${styles[toast.type]}`}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg w-full animate-in slide-in-from-right ${styles[toast.type]}`}
role="alert"
>
<Icon size={20} />
<p className="flex-1 text-sm">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="p-1 hover:bg-black/5 rounded transition-colors"
className="icon-button hover:bg-black/5"
aria-label="Закрыть"
>
<X size={16} />
+54
View File
@@ -32,3 +32,57 @@ body {
min-height: 100vh;
width: 100%;
}
:root { color-scheme: light; }
::selection { background: #ffdcc3; color: #1B263B; }
input, select, textarea { caret-color: #1B263B; accent-color: #1B263B; }
button, a, input, select, textarea, [tabindex] { -webkit-tap-highlight-color: transparent; }
:where(button, a, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid #1B263B;
outline-offset: 3px;
}
header :focus-visible { outline-color: #ffdcc3; }
button:not(:disabled), summary { cursor: pointer; }
button:disabled { cursor: not-allowed; }
.icon-button {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border-radius: 8px;
color: #575963;
}
.icon-button:hover { background-color: #f0edef; }
.icon-button:disabled { opacity: .5; }
.skip-link { position: fixed; top: 8px; left: 16px; z-index: 100; transform: translateY(-150%); padding: 12px 16px; background: white; border-radius: 8px; color: #1B263B; }
.skip-link:focus { transform: translateY(0); }
.app-dialog {
position: fixed;
inset: 0;
width: 100%;
max-width: 600px;
max-height: calc(100dvh - 32px);
margin: auto;
padding: 0;
border: 0;
border-radius: 16px;
background: white;
color: #1b1b1d;
box-shadow: 0 16px 48px #101a2d33;
}
.app-dialog::backdrop { background: #101a2d99; }
.dialog-panel { display: flex; flex-direction: column; max-height: calc(100dvh - 32px); }
.dialog-footer { padding-bottom: max(16px, env(safe-area-inset-bottom)); }
.tour-dialog { position: fixed; inset: 0; width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; padding: 0; border: 0; background: transparent; overflow: hidden; }
.tour-dialog::backdrop { background: transparent; }
.tour-panel { max-height: calc(100dvh - 32px); overflow-y: auto; animation: tour-enter 180ms ease-out; }
@keyframes tour-enter { from { translate: 0 8px; } to { translate: 0 0; } }
@media (max-width: 639px) {
.app-dialog { max-width: none; max-height: 100dvh; height: 100dvh; border-radius: 0; }
.dialog-panel { height: 100dvh; max-height: 100dvh; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
}
+74 -182
View File
@@ -1,59 +1,40 @@
import { useState, useEffect, useMemo } from 'react';
import { Plus, Printer, ChevronRight, CalendarDays } from 'lucide-react';
import { addDays, format, startOfDay, startOfMonth, endOfMonth, eachDayOfInterval, isToday, getDay } from 'date-fns';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Printer, ChevronRight, ChevronLeft, CalendarDays, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
import { addDays, addMonths, format, startOfDay, startOfMonth, endOfMonth, eachDayOfInterval, isToday, getDay } from 'date-fns';
import { ru } from 'date-fns/locale';
import { useDeliveryStore } from '../stores/deliveryStore';
import { useToastStore } from '../stores/toastStore';
import { deliveriesApi } from '../api';
import type { Delivery } from '../types';
import { formatPickupLocation } from '../types';
import { Button } from '../components/ui/Button';
import { Card } from '../components/ui/Card';
import { getTodayFrontend } from '../utils/date';
interface DashboardProps {
onDateSelect: (date: string) => void;
onAddDelivery: () => void;
}
interface DashboardProps { onDateSelect: (date: string) => void; onAddDelivery: () => void }
const plural = (count: number) => count % 10 === 1 && count % 100 !== 11 ? 'доставка' : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 12 || count % 100 > 14) ? 'доставки' : 'доставок';
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]!));
const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
const deliveryCounts = useDeliveryStore(state => state.deliveryCounts);
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
const [currentMonth, setCurrentMonth] = useState(new Date());
// Fetch counts on mount
useEffect(() => {
fetchDeliveryCounts();
}, [fetchDeliveryCounts]);
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
return eachDayOfInterval({ start: monthStart, end: monthEnd });
}, [currentMonth]);
const upcomingDays = useMemo(() => {
const today = startOfDay(new Date());
return eachDayOfInterval({ start: today, end: addDays(today, 6) });
}, []);
const getCountForDate = (date: Date) => {
const { deliveryCounts, isLoadingCounts, countsError, fetchDeliveryCounts } = useDeliveryStore();
const [currentMonth, setCurrentMonth] = useState(() => startOfMonth(new Date()));
const [printingDate, setPrintingDate] = useState<string | null>(null);
useEffect(() => { void fetchDeliveryCounts(); }, [fetchDeliveryCounts]);
const days = useMemo(() => eachDayOfInterval({ start: startOfMonth(currentMonth), end: endOfMonth(currentMonth) }), [currentMonth]);
const upcomingDays = eachDayOfInterval({ start: startOfDay(new Date()), end: addDays(new Date(), 6) });
const getCount = (date: Date) => deliveryCounts[format(date, 'dd-MM-yyyy')] || 0;
const upcoming = upcomingDays.filter(day => getCount(day) > 0);
const handlePrint = async (date: Date) => {
const dateStr = format(date, 'dd-MM-yyyy');
return deliveryCounts[dateStr] || 0;
};
const handlePrintDay = (date: Date) => {
const dateStr = format(date, 'dd-MM-yyyy');
const fetchDeliveriesByDate = useDeliveryStore.getState().fetchDeliveriesByDate;
// Fetch and print
fetchDeliveriesByDate(dateStr).then(() => {
const deliveries = useDeliveryStore.getState().deliveries;
printDeliveries(date, deliveries);
});
};
const printDeliveries = (date: Date, dayDeliveries: Delivery[]) => {
const printWindow = window.open('', '_blank');
if (!printWindow) return;
if (!printWindow) { useToastStore.getState().addToast('Разрешите всплывающие окна, чтобы распечатать доставки.', 'info'); return; }
printWindow.document.body.textContent = 'Загружаем доставки для печати…';
setPrintingDate(dateStr);
try { printDeliveries(date, await deliveriesApi.getByDate(dateStr), printWindow); }
catch { printWindow.close(); useToastStore.getState().addToast('Не удалось загрузить доставки для печати. Попробуйте ещё раз.', 'error'); }
finally { setPrintingDate(null); }
};
const printDeliveries = (date: Date, dayDeliveries: Delivery[], printWindow: Window) => {
const html = `
<!DOCTYPE html>
@@ -81,7 +62,9 @@ const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
<th>Услуги</th>
<th>Комментарий</th>
</tr>
${dayDeliveries.map((d: Delivery) => `
${dayDeliveries.map((delivery: Delivery) => {
const d = Object.fromEntries(Object.entries(delivery).map(([key, value]) => [key, typeof value === 'string' ? escapeHtml(value) : value])) as unknown as Delivery;
return `
<tr>
<td>${d.pickupLocation2 ? formatPickupLocation(d.pickupLocation, d.warehouseRequestSource) + ' + ' + formatPickupLocation(d.pickupLocation2, d.warehouseRequestSource2) : formatPickupLocation(d.pickupLocation, d.warehouseRequestSource)}</td>
<td>${d.productName}${d.productName2 ? '<br><small>+ ' + d.productName2 + '</small>' : ''}</td>
@@ -94,7 +77,7 @@ const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
<td>${d.serviceInfo || '-'}</td>
<td>${d.comment || '-'}</td>
</tr>
`).join('')}
`; }).join('')}
</table>
</body>
</html>
@@ -105,148 +88,57 @@ const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
printWindow?.print();
};
const navigateMonth = (direction: 'prev' | 'next') => {
setCurrentMonth(prev => {
const newDate = new Date(prev);
newDate.setMonth(prev.getMonth() + (direction === 'next' ? 1 : -1));
return newDate;
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[#1b1b1d]">Панель управления</h1>
<p className="text-[#75777d] mt-1">Выберите дату для просмотра доставок</p>
</div>
<Button onClick={onAddDelivery}>
<Plus size={18} className="mr-2" />
Новая доставка
</Button>
<div className="flex flex-wrap items-center justify-between gap-4">
<div><h1 className="text-2xl font-bold sm:text-3xl">План доставок</h1><p className="mt-1 text-[#666870]">Рабочий день и заявки на ближайшие даты</p></div>
<Button onClick={onAddDelivery} data-tour="new-delivery"><Plus size={18} className="mr-2" />Новая доставка</Button>
</div>
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-[#1b1b1d] flex items-center gap-2">
<CalendarDays size={20} className="text-[#1B263B]" />
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
</h2>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => navigateMonth('prev')}>
←
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentMonth(new Date())}
>
Сегодня
</Button>
<Button variant="ghost" size="sm" onClick={() => navigateMonth('next')}>
→
</Button>
<button type="button" onClick={() => onDateSelect(getTodayFrontend())} data-tour="today-deliveries"
className="flex min-h-20 w-full items-center justify-between gap-4 rounded-xl bg-[#1B263B] px-5 py-4 text-left text-white hover:bg-[#2a3a52]">
<div><span className="block text-lg font-semibold">Доставки на сегодня</span><span className="mt-1 block text-sm text-white/85">{format(new Date(), 'd MMMM, EEEE', { locale: ru })}{!isLoadingCounts && !countsError ? ` · ${getCount(new Date())} ${plural(getCount(new Date()))}` : ''}</span></div><ArrowRight size={22} className="shrink-0" />
</button>
{countsError && <div role="alert" className="flex flex-wrap items-center gap-3 rounded-xl bg-red-50 p-4 text-red-800"><AlertCircle size={20} /><p className="min-w-0 flex-1">{countsError}</p><Button variant="secondary" size="sm" onClick={() => fetchDeliveryCounts()}>Повторить</Button></div>}
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<section data-tour="calendar" aria-label="Календарь доставок" className="rounded-xl border border-[#dedde2] bg-white p-3 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-lg font-semibold capitalize"><CalendarDays size={20} className="shrink-0 text-[#1B263B]" />{format(currentMonth, 'LLLL yyyy', { locale: ru })}</h2>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" aria-label="Предыдущий месяц" onClick={() => setCurrentMonth(month => addMonths(month, -1))}><ChevronLeft size={18} /></Button>
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(startOfMonth(new Date()))}>Сегодня</Button>
<Button variant="ghost" size="sm" aria-label="Следующий месяц" onClick={() => setCurrentMonth(month => addMonths(month, 1))}><ChevronRight size={18} /></Button>
</div>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
<div key={day} className="text-center text-xs font-medium text-[#75777d] py-2">
{day}
<div className="mb-1 grid grid-cols-7 gap-1">{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => <div key={day} className="py-2 text-center text-sm text-[#666870]">{day}</div>)}</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: (getDay(startOfMonth(currentMonth)) + 6) % 7 }).map((_, i) => <div key={`blank-${i}`} />)}
{days.map(day => {
const count = getCount(day);
const today = isToday(day);
return <button key={day.toISOString()} type="button" onClick={() => onDateSelect(format(day, 'dd-MM-yyyy'))}
aria-label={`${format(day, 'd MMMM', { locale: ru })}${!isLoadingCounts && !countsError ? `, ${count} ${plural(count)}` : ''}`}
aria-current={today ? 'date' : undefined}
className={`flex min-h-[68px] min-w-0 flex-col items-center justify-center gap-1 rounded-lg px-1 py-2 sm:min-h-[82px] ${today ? 'bg-[#1B263B] text-white' : count > 0 ? 'bg-[#fff1e5] text-[#1b1b1d] hover:bg-[#ffdcc3]' : 'text-[#1b1b1d] hover:bg-[#f5f3f5]'}`}>
<span className="text-base font-medium tabular-nums">{format(day, 'd')}</span>
{!isLoadingCounts && !countsError && count > 0 && <span className={`rounded-full px-1.5 text-xs font-semibold tabular-nums ${today ? 'bg-white/15 text-white' : 'text-[#854300]'}`}>{count}<span className="sr-only"> {plural(count)}</span></span>}
</button>;
})}
</div>
<p className="mt-4 flex min-h-5 items-center gap-2 text-sm text-[#666870]" role="status">{isLoadingCounts ? <><Loader2 size={16} className="animate-spin" />Обновляем календарь…</> : countsError ? 'Количество доставок временно недоступно. Дни можно открывать.' : 'Число под датой — количество доставок'}</p>
</section>
<section className="space-y-3" aria-label="Ближайшие доставки">
<h2 className="text-lg font-semibold">Ближайшие 7 дней</h2>
{isLoadingCounts ? <p role="status" className="py-5 text-sm text-[#666870]">Загружаем план доставок…</p> : countsError ? <p className="text-sm text-[#666870]">Обновите календарь, чтобы увидеть ближайшие даты.</p> : upcoming.length === 0 ? <Card className="p-5"><p className="font-medium">На неделю пока нет заявок</p><p className="mt-2 text-sm text-[#666870]">Выберите день в календаре или создайте доставку.</p><Button onClick={onAddDelivery} variant="secondary" className="mt-4">Создать доставку</Button></Card> : upcoming.map(day => (
<div key={day.toISOString()} className="flex items-center gap-2 rounded-xl border border-[#dedde2] bg-white p-3">
<button type="button" onClick={() => onDateSelect(format(day, 'dd-MM-yyyy'))} className="flex min-h-14 min-w-0 flex-1 items-center justify-between gap-3 text-left">
<div><p className="font-semibold">{isToday(day) ? 'Сегодня' : format(day, 'd MMMM', { locale: ru })}</p><p className="mt-1 text-sm text-[#666870]">{getCount(day)} {plural(getCount(day))} · {format(day, 'EEEE', { locale: ru })}</p></div><ChevronRight size={18} className="shrink-0 text-[#666870]" />
</button>
<button type="button" onClick={() => handlePrint(day)} disabled={printingDate !== null} className="icon-button" aria-label={`Распечатать доставки на ${format(day, 'd MMMM', { locale: ru })}`} title="Распечатать">{printingDate === format(day, 'dd-MM-yyyy') ? <Loader2 size={18} className="animate-spin" /> : <Printer size={18} />}</button>
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: (getDay(startOfMonth(currentMonth)) + 6) % 7 }).map((_, i) => (
<div key={`empty-${i}`} className="p-3 min-h-[80px]" />
))}
{days.map((day) => {
const count = getCountForDate(day);
const isTodayDate = isToday(day);
return (
<button
key={day.toISOString()}
onClick={() => onDateSelect(format(day, 'dd-MM-yyyy'))}
className={`
relative p-3 rounded-lg text-left transition-all min-h-[80px]
${isTodayDate ? 'bg-[#1B263B] text-white' : 'hover:bg-[#f5f3f5]'}
${!isTodayDate && count > 0 ? 'bg-[#ffdcc3]/30' : ''}
`}
>
<div className={`text-sm font-medium ${isTodayDate ? 'text-white' : 'text-[#1b1b1d]'}`}>
{format(day, 'd')}
</div>
{count > 0 && (
<div className={`mt-1 text-[10px] truncate w-full ${isTodayDate ? 'text-white/80' : 'text-[#F28C28]'}`}>
{count} {count === 1 ? 'доставка' : count < 5 ? 'доставки' : 'доставок'}
</div>
)}
</button>
);
})}
</div>
</Card>
<div className="space-y-3">
<h3 className="font-semibold text-[#1b1b1d]">Ближайшие даты с доставками</h3>
{upcomingDays
.filter(day => getCountForDate(day) > 0)
.map(day => {
const count = getCountForDate(day);
return (
<Card key={day.toISOString()} className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="text-center min-w-[60px]">
<div className="text-2xl font-bold text-[#1B263B]">
{format(day, 'd')}
</div>
<div className="text-xs text-[#75777d] uppercase">
{format(day, 'MMM', { locale: ru })}
</div>
</div>
<div>
<div className="font-medium text-[#1b1b1d]">
{count} {count === 1 ? 'доставка' : count < 5 ? 'доставки' : 'доставок'}
</div>
<div className="text-sm text-[#75777d]">
{format(day, 'EEEE', { locale: ru })}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handlePrintDay(day)}
>
<Printer size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onDateSelect(format(day, 'dd-MM-yyyy'))}
>
Открыть
<ChevronRight size={16} className="ml-1" />
</Button>
</div>
</Card>
);
})}
{upcomingDays.filter(day => getCountForDate(day) > 0).length === 0 && (
<Card className="p-8 text-center">
<p className="text-[#75777d]">Нет запланированных доставок</p>
<Button onClick={onAddDelivery} variant="ghost" className="mt-2">
Создать первую доставку
</Button>
</Card>
)}
</section>
</div>
</div>
);
};
export default Dashboard;
+63 -132
View File
@@ -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<Delivery | null>(null);
const [pickupFilter, setPickupFilter] = useState<PickupLocation | 'all'>('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<Delivery | null>(null);
const [pickup, setPickup] = useState<PickupLocation | 'all'>('all');
const [status, setStatus] = useState<DeliveryStatus | 'all'>('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<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => {
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 (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft size={18} className="mr-1" />
Назад
</Button>
<div className="flex items-center gap-2 text-sm text-[#75777d]">
<Filter size={16} />
<span>Всего: {filteredDeliveries.length}</span>
</div>
</div>
<div className="w-48">
<Select
label=""
value={pickupFilter}
onChange={(e) => setPickupFilter(e.target.value as PickupLocation | 'all')}
options={pickupFilterOptions}
/>
</div>
<div className="space-y-5">
<Button variant="ghost" size="sm" onClick={onBack} className="-ml-3"><ArrowLeft size={18} className="mr-2" />К календарю</Button>
<div className="flex flex-wrap items-center justify-between gap-4">
<div><h1 className="text-2xl font-bold sm:text-3xl">{format(date, 'd MMMM yyyy', { locale: ru })}</h1><p className="mt-1 capitalize text-[#666870]">{format(date, 'EEEE', { locale: ru })}</p></div>
<Button onClick={() => { setEditing(null); setFormOpen(true); }} data-tour="new-delivery"><Plus size={18} className="mr-2" />Новая доставка</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-500" />
<div className="flex-1">
<p className="text-red-700">{error}</p>
<div className="flex flex-wrap items-end gap-2" data-tour="day-navigation">
<Button variant="secondary" onClick={() => shiftDay(-1)} aria-label="Предыдущий день"><ChevronLeft size={19} /></Button>
<div className="w-40"><Input aria-label="Выбрать дату доставки" type="date" value={formatDateForInput(selectedDate)} onChange={e => { if (e.target.value) onDateSelect(parseDateFromInput(e.target.value)); }} /></div>
<Button variant="secondary" onClick={() => shiftDay(1)} aria-label="Следующий день"><ChevronRight size={19} /></Button>
<Button variant="ghost" onClick={() => onDateSelect(getTodayFrontend())}>Сегодня</Button>
</div>
<div className="rounded-xl border border-[#dedde2] bg-white p-4" data-tour="delivery-filters">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_416px]">
<div className="relative"><Input label="Поиск" type="search" value={search} placeholder="Клиент, адрес, товар или телефон" onChange={e => setSearch(e.target.value)} className="pl-10" /><Search size={18} className="pointer-events-none absolute left-3 top-[40px] text-[#666870]" /></div>
<button type="button" onClick={() => setFiltersOpen(value => !value)} aria-expanded={filtersOpen} aria-controls="additional-filters" className="flex min-h-11 items-center gap-2 text-sm font-medium text-[#1B263B] sm:hidden"><Filter size={17} />Фильтры{pickup !== 'all' || status !== 'all' ? ' · включены' : ''}</button>
<div id="additional-filters" className={`${filtersOpen ? 'grid' : 'hidden'} grid-cols-1 gap-4 sm:grid sm:grid-cols-2`}>
<Select label="Место загрузки" value={pickup} onChange={e => setPickup(e.target.value as PickupLocation | 'all')} options={pickupFilterOptions} />
<Select label="Статус" value={status} onChange={e => setStatus(e.target.value as DeliveryStatus | 'all')} options={[{ value: 'all', label: 'Все доставки' }, { value: 'new', label: 'В работе' }, { value: 'delivered', label: 'Доставлено' }]} />
</div>
<Button variant="ghost" size="sm" onClick={() => { clearError(); fetchDeliveriesByDate(selectedDate); }}>
Повторить
</Button>
</div>
) : (
<DeliveryListComponent
deliveries={filteredDeliveries}
onStatusChange={handleStatusChange}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
date={selectedDate}
/>
)}
<DeliveryForm
isOpen={isFormOpen}
onClose={handleCloseForm}
onSubmit={handleSubmit}
initialData={editingDelivery}
defaultDate={selectedDate}
/>
{hasFilters && <button type="button" onClick={resetFilters} className="mt-2 min-h-11 text-sm font-medium text-[#1B263B] underline underline-offset-4">Сбросить фильтры</button>}
</div>
{isLoading || currentDate !== selectedDate ? <div role="status" className="flex items-center justify-center gap-3 py-12 text-[#666870]"><Loader2 size={22} className="animate-spin" />Загружаем доставки…</div>
: error ? <div role="alert" className="flex flex-wrap items-center gap-3 rounded-xl bg-red-50 p-4 text-red-800"><AlertCircle size={20} /><p className="min-w-0 flex-1">{error}</p><Button variant="secondary" onClick={() => fetchDeliveriesByDate(selectedDate)}>Повторить</Button></div>
: <DeliveryList deliveries={filtered} total={dayDeliveries.length} filtered={hasFilters} pendingIds={pendingIds} onResetFilters={resetFilters} date={selectedDate}
onStatusChange={id => { 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); }} />}
<DeliveryForm isOpen={isFormOpen} onClose={() => { setFormOpen(false); setEditing(null); }} onSubmit={handleSubmit} initialData={editing} defaultDate={selectedDate} />
</div>
);
};
export default DeliveryListPage;
+51 -81
View File
@@ -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<void>;
@@ -33,6 +36,9 @@ interface DeliveryState {
handleWsDeliveryDeleted: (id: string) => void;
}
let listRequest = 0;
let countsRequest = 0;
export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
// Initial state
deliveries: [],
@@ -41,127 +47,91 @@ export const useDeliveryStore = create<DeliveryState>()((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) }));
}
},
+12
View File
@@ -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} Проверьте соединение и попробуйте ещё раз.`;
}
+79
View File
@@ -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);
});