Improve delivery workflows and add versioned guided tours
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
Первое сохранение завершится ошибкой, повторное с теми же данными — успешно.
|
||||||
|
Этот служебный адрес существует только в демо-сервере.
|
||||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'dev-dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<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="theme-color" content="#1B263B" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"preview:demo": "node scripts/preview-demo.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
|||||||
@@ -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)));
|
||||||
+32
-130
@@ -1,162 +1,64 @@
|
|||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
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 { DeliveryForm } from './components/delivery/DeliveryForm';
|
||||||
import { LoginForm } from './components/auth/LoginForm';
|
import { LoginForm } from './components/auth/LoginForm';
|
||||||
import { ToastContainer } from './components/ui/Toast';
|
import { ToastContainer } from './components/ui/Toast';
|
||||||
import { Button } from './components/ui/Button';
|
import { Button } from './components/ui/Button';
|
||||||
import { UpdatePrompt } from './components/ui/UpdatePrompt';
|
import { UpdatePrompt } from './components/ui/UpdatePrompt';
|
||||||
|
import { GuidedTour } from './components/ui/GuidedTour';
|
||||||
import { useDeliveryStore } from './stores/deliveryStore';
|
import { useDeliveryStore } from './stores/deliveryStore';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
import { useToastStore } from './stores/toastStore';
|
||||||
import { useWebSocket } from './hooks/useWebSocket';
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
|
import { getTodayFrontend } from './utils/date';
|
||||||
|
|
||||||
// Lazy load pages for code splitting
|
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
const DeliveryListPage = lazy(() => import('./pages/DeliveryListPage'));
|
const DeliveryListPage = lazy(() => import('./pages/DeliveryListPage'));
|
||||||
|
const PageLoader = () => <div role="status" className="flex items-center justify-center gap-3 py-20 text-[#666870]"><Loader2 size={24} className="animate-spin" />Загрузка…</div>;
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [view, setView] = useState<'dashboard' | 'delivery-list'>('dashboard');
|
const [view, setView] = useState<'dashboard' | 'delivery-list'>('dashboard');
|
||||||
const [selectedDate, setSelectedDate] = useState<string>('');
|
const [selectedDate, setSelectedDate] = useState(getTodayFrontend);
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [formDate, setFormDate] = useState<string>('');
|
const [tourReplay, setTourReplay] = useState(0);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const { isAuthenticated, isAuthChecking, user, restoreAuth, logout } = useAuthStore();
|
||||||
|
const { addDelivery, isLoading, isLoadingCounts, error, countsError, currentDate } = useDeliveryStore();
|
||||||
const { isAuthenticated, isAuthChecking, restoreAuth, logout } = useAuthStore();
|
|
||||||
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
|
||||||
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
|
||||||
|
|
||||||
useWebSocket();
|
useWebSocket();
|
||||||
|
useEffect(() => { restoreAuth(); }, [restoreAuth]);
|
||||||
// Restore auth on mount
|
const selectDate = (date: string) => { setSelectedDate(date); setView('delivery-list'); };
|
||||||
useEffect(() => {
|
const submitDelivery = async (data: Parameters<typeof addDelivery>[0]) => {
|
||||||
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');
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
await addDelivery(data);
|
||||||
setIsFormOpen(false);
|
useToastStore.getState().addToast('Доставка создана', 'success');
|
||||||
|
selectDate(data.date);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
if (isAuthChecking) return <PageLoader />;
|
||||||
// Show loading while checking auth
|
if (!isAuthenticated) return <><UpdatePrompt /><LoginForm /><ToastContainer /></>;
|
||||||
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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#fbf8fb]">
|
<div className="min-h-screen bg-[#fbf8fb]">
|
||||||
|
<a href="#main-content" className="skip-link">Перейти к содержимому</a>
|
||||||
<UpdatePrompt />
|
<UpdatePrompt />
|
||||||
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
|
<header className="sticky top-0 z-40 bg-[#1B263B] text-white">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<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">
|
||||||
<div className="flex items-center justify-between h-14">
|
<button type="button" onClick={() => setView('dashboard')} className="flex min-h-11 items-center gap-2 text-left" aria-label="Delivery Tracker — открыть план доставок">
|
||||||
<div className="flex items-center gap-3">
|
<Truck size={25} className="shrink-0" /><span className="text-sm font-semibold sm:text-lg">Delivery Tracker</span>
|
||||||
<div className="p-2 bg-white/10 rounded-lg">
|
</button>
|
||||||
<Truck size={24} className="text-white" />
|
<div className="flex items-center gap-1 sm:gap-3">
|
||||||
</div>
|
<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>
|
||||||
<h1 className="text-lg font-semibold hidden sm:block">Delivery Tracker</h1>
|
<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 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<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">
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<Suspense fallback={<PageLoader />}>
|
<Suspense fallback={<PageLoader />}>
|
||||||
{view === 'dashboard' ? (
|
{view === 'dashboard' ? <Dashboard onDateSelect={selectDate} onAddDelivery={() => setFormOpen(true)} />
|
||||||
<Dashboard
|
: <DeliveryListPage selectedDate={selectedDate} onDateSelect={selectDate} onBack={() => setView('dashboard')} />}
|
||||||
onDateSelect={handleDateSelect}
|
|
||||||
onAddDelivery={handleAddDelivery}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DeliveryListPage
|
|
||||||
selectedDate={selectedDate}
|
|
||||||
onBack={handleBackToDashboard}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
<DeliveryForm isOpen={formOpen} onClose={() => setFormOpen(false)} onSubmit={submitDelivery} defaultDate={getTodayFrontend()} />
|
||||||
<DeliveryForm
|
<GuidedTour key={`${user?.id || user?.username}:${view}`} scope={view} userKey={user?.id || user?.username || 'user'} replay={tourReplay}
|
||||||
isOpen={isFormOpen}
|
ready={!formOpen && (view === 'dashboard' ? !isLoadingCounts && !countsError : !isLoading && !error && currentDate === selectedDate)} />
|
||||||
onClose={() => setIsFormOpen(false)}
|
|
||||||
onSubmit={handleFormSubmit}
|
|
||||||
defaultDate={formDate}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export const LoginForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
aria-label="Имя пользователя"
|
||||||
|
autoComplete="username"
|
||||||
placeholder="Имя пользователя"
|
placeholder="Имя пользователя"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
@@ -58,6 +60,8 @@ export const LoginForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
aria-label="Пароль"
|
||||||
|
autoComplete="current-password"
|
||||||
placeholder="Пароль"
|
placeholder="Пароль"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
|||||||
@@ -1,212 +1,55 @@
|
|||||||
import { memo } from 'react';
|
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 type { Delivery } from '../../types';
|
||||||
import { formatPickupLocation } from '../../types';
|
import { formatPickupLocation } from '../../types';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { PaymentBadge } from './PaymentBadge';
|
import { PaymentBadge } from './PaymentBadge';
|
||||||
import { Card } from '../ui/Card';
|
|
||||||
import { WhatsAppIcon } from '../ui/WhatsAppIcon';
|
import { WhatsAppIcon } from '../ui/WhatsAppIcon';
|
||||||
import { getWhatsAppUrl } from '../../utils/phone';
|
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 {
|
interface DeliveryCardProps {
|
||||||
delivery: Delivery;
|
delivery: Delivery;
|
||||||
onStatusChange: (id: string) => void;
|
onStatusChange: (id: string) => void;
|
||||||
onEdit: (delivery: Delivery) => void;
|
onEdit: (delivery: Delivery) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
pending?: boolean;
|
||||||
}
|
}
|
||||||
|
export const DeliveryCard = memo(({ delivery: d, onStatusChange, onEdit, onDelete, pending }: DeliveryCardProps) => (
|
||||||
export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }: 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">
|
||||||
const handleAddressClick = () => {
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
const encodedAddress = encodeURIComponent(delivery.address);
|
<StatusBadge status={d.status} />
|
||||||
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">
|
<div className="flex gap-1">
|
||||||
<button
|
<button type="button" onClick={() => onEdit(d)} disabled={pending} className="icon-button" aria-label={`Редактировать доставку для ${d.customerName}`} title="Редактировать"><Pencil size={18} /></button>
|
||||||
onClick={() => onEdit(delivery)}
|
<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>
|
||||||
className="p-1.5 rounded-md hover:bg-[#f5f3f5] text-[#75777d] transition-colors"
|
</div>
|
||||||
title="Редактировать"
|
</div>
|
||||||
>
|
<a href={`https://2gis.kz/kokshetau/search/${encodeURIComponent(d.address)}`} target="_blank" rel="noopener noreferrer"
|
||||||
<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>
|
className="mb-1 flex min-h-11 items-start gap-2 text-lg font-semibold leading-snug text-[#1B263B] underline-offset-4 hover:underline">
|
||||||
</button>
|
<MapPin size={20} className="mt-1 shrink-0" /><span className="break-words">ул. {d.street}, д. {d.house}{d.apartment ? `, кв. ${d.apartment}` : ''}</span>
|
||||||
<button
|
</a>
|
||||||
onClick={() => onDelete(delivery.id)}
|
<p className="mb-4 pl-7 text-sm text-[#666870]">
|
||||||
className="p-1.5 rounded-md hover:bg-red-50 text-red-500 transition-colors"
|
{[d.entrance && `Подъезд ${d.entrance}`, d.floor && `этаж ${d.floor}`, d.hasElevator ? 'есть лифт' : 'нет лифта'].filter(Boolean).join(' · ')}
|
||||||
title="Удалить"
|
</p>
|
||||||
>
|
<div className="space-y-2.5 border-y border-[#e4e2e4] py-3">
|
||||||
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
|
));
|
||||||
<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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
DeliveryCard.displayName = 'DeliveryCard';
|
DeliveryCard.displayName = 'DeliveryCard';
|
||||||
|
|||||||
@@ -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 { Button, Input, Select, Modal } from '../ui';
|
||||||
import { deliveryRequestSourceOptions, pickupOptions } from '../../constants/pickup';
|
import { deliveryRequestSourceOptions, pickupOptions } from '../../constants/pickup';
|
||||||
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
|
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 {
|
interface DeliveryFormProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => void | Promise<void>;
|
onSubmit: (delivery: DeliveryInput) => void | Promise<void>;
|
||||||
initialData?: Delivery | null;
|
initialData?: Delivery | null;
|
||||||
defaultDate?: string;
|
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 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).
|
// A fresh mount on each opening prevents edited data leaking into a new delivery.
|
||||||
const CITY_LABEL = 'Кокшетау';
|
export const DeliveryForm = (props: DeliveryFormProps) => props.isOpen
|
||||||
const requestSourceOptions = [
|
? <DeliveryFormContent {...props} key={props.initialData?.id || 'new'} /> : null;
|
||||||
{ value: '', label: 'Выберите источник заявки' },
|
|
||||||
...deliveryRequestSourceOptions,
|
|
||||||
];
|
|
||||||
const paymentStatusOptions = [
|
|
||||||
{ value: 'prepaid', label: 'Предоплата' },
|
|
||||||
{ value: 'paid', label: 'Полная оплата' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const buildAddressString = (
|
function DeliveryFormContent({ onClose, onSubmit, initialData, defaultDate }: DeliveryFormProps) {
|
||||||
street: string,
|
const [baseline] = useState<DeliveryInput>(() => initialData ? { ...initialData } : emptyDelivery(defaultDate || getTodayFrontend()));
|
||||||
house: string,
|
const [data, setData] = useState<DeliveryInput>(baseline);
|
||||||
apartment: string,
|
const [secondPickup, setSecondPickup] = useState(!!baseline.pickupLocation2);
|
||||||
entrance: string,
|
const [isSubmitting, setSubmitting] = useState(false);
|
||||||
): string => {
|
const [error, setError] = useState('');
|
||||||
const parts: string[] = [CITY_LABEL];
|
const [attempted, setAttempted] = useState(false);
|
||||||
if (street) parts.push(`ул. ${street}`);
|
const submitting = useRef(false);
|
||||||
if (house) parts.push(`д. ${house}`);
|
const dirty = JSON.stringify(data) !== JSON.stringify(baseline);
|
||||||
if (apartment) parts.push(`кв. ${apartment}`);
|
const update = <K extends keyof DeliveryInput>(key: K, value: DeliveryInput[K]) => setData(prev => ({ ...prev, [key]: value }));
|
||||||
if (entrance) parts.push(`подъезд ${entrance}`);
|
const phoneError = data.phone && !PHONE_REGEX.test(data.phone) ? 'Введите номер в формате +7 (776) 567-89-01' : undefined;
|
||||||
return parts.join(', ');
|
const extraPhoneError = data.additionalPhone && !PHONE_REGEX.test(data.additionalPhone) ? 'Проверьте дополнительный номер или очистите поле' : undefined;
|
||||||
|
const sourceMissing = data.pickupLocation === 'warehouse' && !data.warehouseRequestSource;
|
||||||
|
const secondSourceMissing = secondPickup && data.pickupLocation2 === 'warehouse' && !data.warehouseRequestSource2;
|
||||||
|
const requestClose = () => {
|
||||||
|
if (submitting.current) return;
|
||||||
|
if (!dirty || window.confirm('Закрыть форму? Несохранённые изменения будут потеряны.')) onClose();
|
||||||
};
|
};
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePickupLocation2Change = (pickupLocation2: PickupLocation) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
pickupLocation2,
|
|
||||||
warehouseRequestSource2: pickupLocation2 === 'warehouse' ? formData.warehouseRequestSource2 : null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isFormValid) return;
|
setAttempted(true);
|
||||||
try {
|
if (submitting.current) return;
|
||||||
const payload = {
|
if (phoneError || extraPhoneError || sourceMissing || secondSourceMissing) {
|
||||||
...formData,
|
setError('Проверьте отмеченные поля. Данные остаются в форме.');
|
||||||
address: buildAddressString(formData.street, formData.house, formData.apartment, formData.entrance),
|
return;
|
||||||
};
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
submitting.current = true;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
const address = ['Кокшетау', `ул. ${data.street.trim()}`, `д. ${data.house.trim()}`,
|
||||||
|
data.apartment && `кв. ${data.apartment}`, data.entrance && `подъезд ${data.entrance}`].filter(Boolean).join(', ');
|
||||||
|
try {
|
||||||
|
await onSubmit({ ...data, productName: data.productName.trim(), customerName: data.customerName.trim(), street: data.street.trim(), house: data.house.trim(), address });
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
// Error is handled by parent, keep form open
|
setError('Не удалось сохранить доставку. Проверьте соединение и попробуйте ещё раз. Все введённые данные сохранены в форме.');
|
||||||
|
} finally {
|
||||||
|
submitting.current = false;
|
||||||
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal isOpen onClose={requestClose} closeDisabled={isSubmitting} title={initialData ? 'Редактировать доставку' : 'Новая доставка'}
|
||||||
isOpen={isOpen}
|
footer={<div className="w-full">
|
||||||
onClose={onClose}
|
{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>}
|
||||||
title={initialData ? 'Редактировать доставку' : 'Новая доставка'}
|
<div className="flex justify-end gap-3"><Button variant="ghost" onClick={requestClose} disabled={isSubmitting}>Отмена</Button>
|
||||||
footer={
|
<Button type="submit" form="delivery-form" disabled={isSubmitting}>
|
||||||
<>
|
{isSubmitting && <Loader2 size={18} className="mr-2 animate-spin" />}
|
||||||
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
|
{isSubmitting ? 'Сохранение…' : initialData ? 'Сохранить' : 'Создать доставку'}
|
||||||
Отмена
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="delivery-form" disabled={isSubmitting || !isFormValid}>
|
</div>
|
||||||
{isSubmitting ? 'Сохранение...' : initialData ? 'Сохранить' : 'Создать'}
|
</div>}>
|
||||||
</Button>
|
<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>
|
||||||
<form id="delivery-form" onSubmit={handleSubmit} className="space-y-4">
|
<Input label="Дата доставки *" type="date" value={formatDateForInput(data.date)} required onChange={e => update('date', parseDateFromInput(e.target.value))} />
|
||||||
<div>
|
<Select label="Место загрузки" value={data.pickupLocation} options={pickupOptions} onChange={e => {
|
||||||
<label className="block text-sm font-medium text-[#1b1b1d] mb-1">
|
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>
|
</label>
|
||||||
<input
|
{secondPickup && <div className="space-y-4 border-t border-[#e4e2e4] pt-4">
|
||||||
type="date"
|
<Select label="Второе место загрузки" value={data.pickupLocation2 || 'warehouse'} options={pickupOptions} onChange={e => {
|
||||||
value={formatDateForInput(formData.date)}
|
const pickupLocation2 = e.target.value as PickupLocation;
|
||||||
onChange={(e) => setFormData({ ...formData, date: parseDateFromInput(e.target.value) })}
|
setData(prev => ({ ...prev, pickupLocation2, warehouseRequestSource2: pickupLocation2 === 'warehouse' ? prev.warehouseRequestSource2 : null }));
|
||||||
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
|
{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>
|
||||||
|
<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>
|
||||||
<Select
|
</fieldset>
|
||||||
label="Место загрузки"
|
<fieldset disabled={isSubmitting} className="space-y-4 border-t border-[#e4e2e4] pt-5">
|
||||||
value={formData.pickupLocation}
|
<legend className="text-base font-semibold">Клиент и оплата</legend>
|
||||||
onChange={(e) => handlePickupLocationChange(e.target.value as PickupLocation)}
|
<Input label="ФИО клиента *" value={data.customerName} required pattern=".*\S.*" autoComplete="name" onChange={e => update('customerName', e.target.value)} />
|
||||||
options={pickupOptions}
|
<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)} />
|
||||||
{formData.pickupLocation === 'warehouse' && (
|
<Select label="Оплата" value={data.paymentStatus} options={paymentOptions} onChange={e => update('paymentStatus', e.target.value as PaymentStatus)} />
|
||||||
<Select
|
<Input label="Услуги: сборка, подъём" value={data.serviceInfo || ''} placeholder="Например, сборка 5000 тг" onChange={e => update('serviceInfo', e.target.value)} />
|
||||||
label="От кого заявка *"
|
<Input label="Комментарий" value={data.comment} placeholder="Что важно знать доставщику" onChange={e => update('comment', e.target.value)} />
|
||||||
value={formData.warehouseRequestSource || ''}
|
</fieldset>
|
||||||
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="Название товара со второй точки"
|
|
||||||
/>
|
|
||||||
</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="Дополнительная информация..."
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, LayoutGrid, Table as TableIcon } from 'lucide-react';
|
import { LayoutGrid, Table as TableIcon, PackageSearch, Plus } from 'lucide-react';
|
||||||
import { DeliveryCard } from './DeliveryCard';
|
import { DeliveryCard } from './DeliveryCard';
|
||||||
import { DeliveryRow } from './DeliveryRow';
|
import { DeliveryRow } from './DeliveryRow';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
@@ -7,132 +7,59 @@ import type { Delivery } from '../../types';
|
|||||||
|
|
||||||
interface DeliveryListProps {
|
interface DeliveryListProps {
|
||||||
deliveries: Delivery[];
|
deliveries: Delivery[];
|
||||||
|
total: number;
|
||||||
|
filtered: boolean;
|
||||||
|
pendingIds: string[];
|
||||||
|
onResetFilters: () => void;
|
||||||
onStatusChange: (id: string) => void;
|
onStatusChange: (id: string) => void;
|
||||||
onEdit: (delivery: Delivery) => void;
|
onEdit: (delivery: Delivery) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
export const DeliveryList = ({ deliveries, total, filtered, pendingIds, onResetFilters, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => {
|
||||||
export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => {
|
const [viewMode, setViewMode] = useState<'cards' | 'table'>(() => {
|
||||||
const [viewMode, setViewMode] = useState<'kanban' | 'table'>('kanban');
|
try { return localStorage.getItem('delivery-view') === 'table' ? 'table' : 'cards'; } catch { return 'cards'; }
|
||||||
|
});
|
||||||
const newDeliveries = useMemo(() => deliveries.filter(d => d.status === 'new'), [deliveries]);
|
const changeView = (view: 'cards' | 'table') => {
|
||||||
const deliveredDeliveries = useMemo(() => deliveries.filter(d => d.status === 'delivered'), [deliveries]);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="text-xl font-semibold text-[#1b1b1d]">
|
<p className="text-sm text-[#666870]" aria-live="polite">{filtered ? `Показано ${deliveries.length} из ${total}` : `Всего за день: ${total}`}</p>
|
||||||
Доставки на {date}
|
<div className="inline-flex rounded-lg bg-[#eae7e9] p-1" role="group" aria-label="Вид списка" data-tour="view-switch">
|
||||||
</h2>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deliveries.length === 0 ? (
|
{deliveries.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-[#f5f3f5] rounded-lg">
|
<div className="rounded-xl border border-dashed border-[#c5c6cd] px-5 py-12 text-center">
|
||||||
<p className="text-[#75777d]">Нет доставок на эту дату</p>
|
<PackageSearch size={28} className="mx-auto mb-3 text-[#666870]" />
|
||||||
<Button onClick={onAdd} variant="ghost" className="mt-2">
|
<h2 className="text-lg font-semibold">{filtered ? 'Нет подходящих доставок' : 'На этот день доставок пока нет'}</h2>
|
||||||
Создать первую доставку
|
<p className="mx-auto mt-2 max-w-sm text-sm text-[#666870]">{filtered ? 'Измените поиск или фильтры, чтобы увидеть другие заявки.' : 'Создайте заявку — здесь появятся адрес, товары и контакты клиента.'}</p>
|
||||||
</Button>
|
<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>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto rounded-xl border border-[#dedde2] bg-white" role="region" aria-label="Таблица доставок, можно прокручивать горизонтально" tabIndex={0}>
|
||||||
<table className="w-full bg-white rounded-lg shadow-sm border border-[#e4e2e4]">
|
<table className="w-full text-left">
|
||||||
<thead className="bg-[#f5f3f5]">
|
<caption className="sr-only">Доставки на {date}</caption>
|
||||||
<tr className="text-left text-xs font-semibold text-[#75777d] uppercase tracking-wider">
|
<thead className="bg-[#f5f3f5] text-sm text-[#575963]"><tr>
|
||||||
<th className="px-4 py-3">Статус</th>
|
{['Статус', 'Товар и загрузка', 'Клиент и связь', 'Адрес', 'Оплата', 'Комментарий', 'Действия'].map(label => <th scope="col" key={label} className="whitespace-nowrap px-3 py-3 font-medium">{label}</th>)}
|
||||||
<th className="px-4 py-3">Дата</th>
|
</tr></thead>
|
||||||
<th className="px-4 py-3">Загрузка</th>
|
<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>
|
||||||
<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>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { MapPin, Phone } from 'lucide-react';
|
import { MapPin, Pencil, Trash2 } from 'lucide-react';
|
||||||
import type { Delivery } from '../../types';
|
import type { Delivery } from '../../types';
|
||||||
import { formatPickupLocation } from '../../types';
|
import { formatPickupLocation } from '../../types';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
@@ -7,108 +7,34 @@ import { PaymentBadge } from './PaymentBadge';
|
|||||||
import { WhatsAppIcon } from '../ui/WhatsAppIcon';
|
import { WhatsAppIcon } from '../ui/WhatsAppIcon';
|
||||||
import { getWhatsAppUrl } from '../../utils/phone';
|
import { getWhatsAppUrl } from '../../utils/phone';
|
||||||
|
|
||||||
const CITY = 'kokshetau';
|
|
||||||
|
|
||||||
interface DeliveryRowProps {
|
interface DeliveryRowProps {
|
||||||
delivery: Delivery;
|
delivery: Delivery;
|
||||||
onStatusChange: (id: string) => void;
|
onStatusChange: (id: string) => void;
|
||||||
onEdit: (delivery: Delivery) => void;
|
onEdit: (delivery: Delivery) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
pending?: boolean;
|
||||||
}
|
}
|
||||||
|
export const DeliveryRow = memo(({ delivery: d, onStatusChange, onEdit, onDelete, pending }: DeliveryRowProps) => (
|
||||||
export const DeliveryRow = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryRowProps) => {
|
<tr data-tour="delivery-card" aria-busy={pending} className="align-top transition-colors hover:bg-[#f5f3f5]">
|
||||||
const handleAddressClick = (e: React.MouseEvent) => {
|
<td className="px-3 py-3"><StatusBadge status={d.status} onClick={() => onStatusChange(d.id)} disabled={pending} size="sm" /></td>
|
||||||
e.stopPropagation();
|
<td className="min-w-[140px] max-w-[220px] px-3 py-3 text-sm">
|
||||||
const encodedAddress = encodeURIComponent(delivery.address);
|
<p className="break-words font-medium">{d.productName}</p><p className="mt-1 text-[#666870]">{formatPickupLocation(d.pickupLocation, d.warehouseRequestSource)}</p>
|
||||||
window.open(`https://2gis.kz/${CITY}/search/${encodedAddress}`, '_blank');
|
{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>}
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
||||||
<td className="px-4 py-3 text-sm text-[#1b1b1d]">{delivery.date}</td>
|
<td className="min-w-[190px] px-3 py-3 text-sm"><p className="font-medium">{d.customerName}</p>
|
||||||
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
|
<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>
|
||||||
{delivery.pickupLocation2
|
{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>}
|
||||||
? `${formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)} + ${formatPickupLocation(delivery.pickupLocation2, delivery.warehouseRequestSource2)}`
|
|
||||||
: formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
|
<td className="min-w-[160px] max-w-[220px] px-3 py-3 text-sm">
|
||||||
{delivery.productName}
|
<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>
|
||||||
{delivery.productName2 && <span className="block text-xs text-[#75777d]">+ {delivery.productName2}</span>}
|
<p className="mt-1 text-[#666870]">{[d.entrance && `Подъезд ${d.entrance}`, d.floor && `этаж ${d.floor}`, d.hasElevator ? 'есть лифт' : 'нет лифта'].filter(Boolean).join(' · ')}</p>
|
||||||
</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>
|
</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>
|
</tr>
|
||||||
);
|
));
|
||||||
});
|
|
||||||
|
|
||||||
DeliveryRow.displayName = 'DeliveryRow';
|
DeliveryRow.displayName = 'DeliveryRow';
|
||||||
|
|||||||
@@ -5,30 +5,13 @@ interface StatusBadgeProps {
|
|||||||
status: DeliveryStatus;
|
status: DeliveryStatus;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
export const StatusBadge = ({ status, onClick, size = 'md', disabled }: StatusBadgeProps) => {
|
||||||
export const StatusBadge = ({ status, onClick, size = 'md' }: 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 baseStyles = 'inline-flex items-center justify-center font-medium rounded-full transition-colors';
|
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
|
||||||
const variants = {
|
? <button type="button" data-tour="delivery-status" className={`${className} min-h-11 disabled:opacity-50`} disabled={disabled} onClick={onClick}
|
||||||
new: 'bg-[#ffdcc3] text-[#6e3900]',
|
aria-label={status === 'new' ? 'Отметить доставленным' : 'Вернуть в новые'}>{content}</button>
|
||||||
delivered: 'bg-[#dcfce7] text-[#166534]',
|
: <span className={className}>{content}</span>;
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,25 +11,27 @@ export const Button = ({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
|
type = 'button',
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) => {
|
}: 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 = {
|
const variants = {
|
||||||
primary: 'bg-[#1B263B] text-white hover:bg-[#2a3a52] focus:ring-[#1B263B]',
|
primary: 'bg-[#1B263B] text-white hover:bg-[#2a3a52] focus:ring-[#1B263B]',
|
||||||
secondary: 'bg-[#f0edef] text-[#1b1b1d] hover:bg-[#e4e2e4] focus:ring-[#75777d]',
|
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]',
|
ghost: 'bg-transparent text-[#1b1b1d] hover:bg-[#f5f3f5] focus:ring-[#75777d]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: 'px-3 py-1.5 text-sm',
|
sm: 'min-h-11 px-3 py-2 text-sm',
|
||||||
md: 'px-4 py-2 text-base',
|
md: 'min-h-11 px-4 py-2.5 text-base',
|
||||||
lg: 'px-6 py-3 text-lg',
|
lg: 'px-6 py-3 text-lg',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type={type}
|
||||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const Card = ({ children, className = '', padding = 'md' }: CardProps) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,20 @@
|
|||||||
import type { InputHTMLAttributes } from 'react';
|
import { useId, type InputHTMLAttributes } from 'react';
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
error?: 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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full min-w-0">
|
||||||
{label && (
|
{label && <label htmlFor={inputId} className="mb-1.5 block text-sm font-medium text-[#1b1b1d]">{label}</label>}
|
||||||
<label className="block text-sm font-medium text-[#1b1b1d] mb-1">
|
<input {...props} id={inputId} aria-invalid={error ? true : props['aria-invalid']} aria-describedby={description}
|
||||||
{label}
|
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}`} />
|
||||||
</label>
|
{error && <p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-700">{error}</p>}
|
||||||
)}
|
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -7,38 +8,38 @@ interface ModalProps {
|
|||||||
title: string;
|
title: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
closeDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal = ({ isOpen, onClose, title, children, footer }: ModalProps) => {
|
export const Modal = ({ isOpen, onClose, title, children, footer, closeDisabled = false }: ModalProps) => {
|
||||||
if (!isOpen) return null;
|
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 (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<dialog ref={ref} aria-labelledby={titleId} aria-modal="true" className="app-dialog"
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
onCancel={e => { e.preventDefault(); if (!closeDisabled) onClose(); }}
|
||||||
<div
|
onClick={e => { if (e.target === e.currentTarget && !closeDisabled) onClose(); }}>
|
||||||
className="fixed inset-0 bg-black/30 transition-opacity"
|
<div className="dialog-panel">
|
||||||
onClick={onClose}
|
<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>
|
||||||
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all w-full max-w-lg">
|
<button type="button" onClick={onClose} disabled={closeDisabled} aria-label="Закрыть окно" className="icon-button"><X size={20} /></button>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,37 +1,24 @@
|
|||||||
import type { SelectHTMLAttributes } from 'react';
|
import { useId, type SelectHTMLAttributes } from 'react';
|
||||||
|
|
||||||
interface SelectOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
interface SelectOption { value: string; label: string }
|
||||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
error?: string;
|
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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full min-w-0">
|
||||||
{label && (
|
{label && <label htmlFor={selectId} className="mb-1.5 block text-sm font-medium text-[#1b1b1d]">{label}</label>}
|
||||||
<label className="block text-sm font-medium text-[#1b1b1d] mb-1">
|
<select {...props} id={selectId} aria-invalid={error ? true : props['aria-invalid']} aria-describedby={description}
|
||||||
{label}
|
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}`}>
|
||||||
</label>
|
{options.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
{error && (
|
{error && <p id={`${selectId}-error`} className="mt-1.5 text-sm text-red-700">{error}</p>}
|
||||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,20 +19,20 @@ export const ToastContainer = () => {
|
|||||||
if (toasts.length === 0) return null;
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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) => {
|
{toasts.map((toast) => {
|
||||||
const Icon = icons[toast.type];
|
const Icon = icons[toast.type];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
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"
|
role="alert"
|
||||||
>
|
>
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
<p className="flex-1 text-sm">{toast.message}</p>
|
<p className="flex-1 text-sm">{toast.message}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeToast(toast.id)}
|
onClick={() => removeToast(toast.id)}
|
||||||
className="p-1 hover:bg-black/5 rounded transition-colors"
|
className="icon-button hover:bg-black/5"
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
|
|||||||
@@ -32,3 +32,57 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
width: 100%;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,59 +1,40 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Printer, ChevronRight, CalendarDays } from 'lucide-react';
|
import { Plus, Printer, ChevronRight, ChevronLeft, CalendarDays, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||||||
import { addDays, format, startOfDay, startOfMonth, endOfMonth, eachDayOfInterval, isToday, getDay } from 'date-fns';
|
import { addDays, addMonths, format, startOfDay, startOfMonth, endOfMonth, eachDayOfInterval, isToday, getDay } from 'date-fns';
|
||||||
import { ru } from 'date-fns/locale';
|
import { ru } from 'date-fns/locale';
|
||||||
import { useDeliveryStore } from '../stores/deliveryStore';
|
import { useDeliveryStore } from '../stores/deliveryStore';
|
||||||
|
import { useToastStore } from '../stores/toastStore';
|
||||||
|
import { deliveriesApi } from '../api';
|
||||||
import type { Delivery } from '../types';
|
import type { Delivery } from '../types';
|
||||||
import { formatPickupLocation } from '../types';
|
import { formatPickupLocation } from '../types';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Card } from '../components/ui/Card';
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { getTodayFrontend } from '../utils/date';
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps { onDateSelect: (date: string) => void; onAddDelivery: () => void }
|
||||||
onDateSelect: (date: string) => void;
|
const plural = (count: number) => count % 10 === 1 && count % 100 !== 11 ? 'доставка' : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 12 || count % 100 > 14) ? 'доставки' : 'доставок';
|
||||||
onAddDelivery: () => void;
|
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]!));
|
||||||
}
|
|
||||||
|
|
||||||
const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
||||||
const deliveryCounts = useDeliveryStore(state => state.deliveryCounts);
|
const { deliveryCounts, isLoadingCounts, countsError, fetchDeliveryCounts } = useDeliveryStore();
|
||||||
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
const [currentMonth, setCurrentMonth] = useState(() => startOfMonth(new Date()));
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [printingDate, setPrintingDate] = useState<string | null>(null);
|
||||||
|
useEffect(() => { void fetchDeliveryCounts(); }, [fetchDeliveryCounts]);
|
||||||
// Fetch counts on mount
|
const days = useMemo(() => eachDayOfInterval({ start: startOfMonth(currentMonth), end: endOfMonth(currentMonth) }), [currentMonth]);
|
||||||
useEffect(() => {
|
const upcomingDays = eachDayOfInterval({ start: startOfDay(new Date()), end: addDays(new Date(), 6) });
|
||||||
fetchDeliveryCounts();
|
const getCount = (date: Date) => deliveryCounts[format(date, 'dd-MM-yyyy')] || 0;
|
||||||
}, [fetchDeliveryCounts]);
|
const upcoming = upcomingDays.filter(day => getCount(day) > 0);
|
||||||
|
const handlePrint = async (date: Date) => {
|
||||||
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 dateStr = format(date, 'dd-MM-yyyy');
|
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');
|
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 = `
|
const html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -81,7 +62,9 @@ const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
|||||||
<th>Услуги</th>
|
<th>Услуги</th>
|
||||||
<th>Комментарий</th>
|
<th>Комментарий</th>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>${d.pickupLocation2 ? formatPickupLocation(d.pickupLocation, d.warehouseRequestSource) + ' + ' + formatPickupLocation(d.pickupLocation2, d.warehouseRequestSource2) : formatPickupLocation(d.pickupLocation, d.warehouseRequestSource)}</td>
|
<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>
|
<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.serviceInfo || '-'}</td>
|
||||||
<td>${d.comment || '-'}</td>
|
<td>${d.comment || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`; }).join('')}
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -105,148 +88,57 @@ const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
|||||||
printWindow?.print();
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div><h1 className="text-2xl font-bold sm:text-3xl">План доставок</h1><p className="mt-1 text-[#666870]">Рабочий день и заявки на ближайшие даты</p></div>
|
||||||
<h1 className="text-2xl font-bold text-[#1b1b1d]">Панель управления</h1>
|
<Button onClick={onAddDelivery} data-tour="new-delivery"><Plus size={18} className="mr-2" />Новая доставка</Button>
|
||||||
<p className="text-[#75777d] mt-1">Выберите дату для просмотра доставок</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onAddDelivery}>
|
<button type="button" onClick={() => onDateSelect(getTodayFrontend())} data-tour="today-deliveries"
|
||||||
<Plus size={18} className="mr-2" />
|
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>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
</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>
|
</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 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>
|
</div>
|
||||||
</Card>
|
<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>
|
||||||
<div className="space-y-3">
|
<section className="space-y-3" aria-label="Ближайшие доставки">
|
||||||
<h3 className="font-semibold text-[#1b1b1d]">Ближайшие даты с доставками</h3>
|
<h2 className="text-lg font-semibold">Ближайшие 7 дней</h2>
|
||||||
{upcomingDays
|
{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 => (
|
||||||
.filter(day => getCountForDate(day) > 0)
|
<div key={day.toISOString()} className="flex items-center gap-2 rounded-xl border border-[#dedde2] bg-white p-3">
|
||||||
.map(day => {
|
<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">
|
||||||
const count = getCountForDate(day);
|
<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]" />
|
||||||
return (
|
</button>
|
||||||
<Card key={day.toISOString()} className="flex items-center justify-between p-4">
|
<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 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>
|
||||||
<div className="text-xs text-[#75777d] uppercase">
|
))}
|
||||||
{format(day, 'MMM', { locale: ru })}
|
</section>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|||||||
@@ -1,148 +1,79 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ArrowLeft, Filter, Loader2, AlertCircle } from 'lucide-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 { 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 { DeliveryForm } from '../components/delivery/DeliveryForm';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
import { Select } from '../components/ui/Select';
|
import { Select } from '../components/ui/Select';
|
||||||
import { pickupFilterOptions } from '../constants/pickup';
|
import { pickupFilterOptions } from '../constants/pickup';
|
||||||
import type { Delivery, PickupLocation } from '../types';
|
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../utils/date';
|
||||||
|
import type { Delivery, DeliveryStatus, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
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'>) => {
|
const handleSubmit = async (data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
try {
|
if (editing) await updateDelivery(editing.id, data);
|
||||||
if (editingDelivery) {
|
else await addDelivery(data);
|
||||||
await updateDelivery(editingDelivery.id, data);
|
useToastStore.getState().addToast(data.date !== selectedDate ? `Доставка сохранена на ${data.date}` : editing ? 'Изменения сохранены' : 'Доставка создана', 'success');
|
||||||
} else {
|
|
||||||
await addDelivery(data);
|
|
||||||
}
|
|
||||||
setEditingDelivery(null);
|
|
||||||
} catch {
|
|
||||||
// Error is handled by store
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
const date = parse(selectedDate, 'dd-MM-yyyy', new Date());
|
||||||
const handleAdd = () => {
|
const shiftDay = (offset: number) => onDateSelect(format(addDays(date, offset), 'dd-MM-yyyy'));
|
||||||
setEditingDelivery(null);
|
|
||||||
setIsFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
|
||||||
setIsFormOpen(false);
|
|
||||||
setEditingDelivery(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<Button variant="ghost" size="sm" onClick={onBack} className="-ml-3"><ArrowLeft size={18} className="mr-2" />К календарю</Button>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
<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>
|
||||||
<ArrowLeft size={18} className="mr-1" />
|
<Button onClick={() => { setEditing(null); setFormOpen(true); }} data-tour="new-delivery"><Plus size={18} className="mr-2" />Новая доставка</Button>
|
||||||
Назад
|
</div>
|
||||||
</Button>
|
<div className="flex flex-wrap items-end gap-2" data-tour="day-navigation">
|
||||||
<div className="flex items-center gap-2 text-sm text-[#75777d]">
|
<Button variant="secondary" onClick={() => shiftDay(-1)} aria-label="Предыдущий день"><ChevronLeft size={19} /></Button>
|
||||||
<Filter size={16} />
|
<div className="w-40"><Input aria-label="Выбрать дату доставки" type="date" value={formatDateForInput(selectedDate)} onChange={e => { if (e.target.value) onDateSelect(parseDateFromInput(e.target.value)); }} /></div>
|
||||||
<span>Всего: {filteredDeliveries.length}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-48">
|
{hasFilters && <button type="button" onClick={resetFilters} className="mt-2 min-h-11 text-sm font-medium text-[#1B263B] underline underline-offset-4">Сбросить фильтры</button>}
|
||||||
<Select
|
|
||||||
label=""
|
|
||||||
value={pickupFilter}
|
|
||||||
onChange={(e) => setPickupFilter(e.target.value as PickupLocation | 'all')}
|
|
||||||
options={pickupFilterOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
{isLoading ? (
|
: <DeliveryList deliveries={filtered} total={dayDeliveries.length} filtered={hasFilters} pendingIds={pendingIds} onResetFilters={resetFilters} date={selectedDate}
|
||||||
<div className="flex items-center justify-center py-12">
|
onStatusChange={id => { const d = dayDeliveries.find(item => item.id === id); if (d) void toggleStatus(id, d.status).catch(() => {}); }}
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
|
onEdit={d => { setEditing(d); setFormOpen(true); }}
|
||||||
</div>
|
onDelete={id => { const d = dayDeliveries.find(item => item.id === id); if (window.confirm(`Удалить доставку для ${d?.customerName || 'клиента'}?`)) void deleteDelivery(id).catch(() => {}); }}
|
||||||
) : error ? (
|
onAdd={() => { setEditing(null); setFormOpen(true); }} />}
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
<DeliveryForm isOpen={isFormOpen} onClose={() => { setFormOpen(false); setEditing(null); }} onSubmit={handleSubmit} initialData={editing} defaultDate={selectedDate} />
|
||||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-red-700">{error}</p>
|
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DeliveryListPage;
|
export default DeliveryListPage;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { deliveriesApi } from '../api';
|
import { deliveriesApi } from '../api';
|
||||||
import { useToastStore } from './toastStore';
|
import { useToastStore } from './toastStore';
|
||||||
|
import { deliveryErrorMessage } from '../utils/errors';
|
||||||
import type { Delivery, DeliveryStatus } from '../types';
|
import type { Delivery, DeliveryStatus } from '../types';
|
||||||
|
|
||||||
interface DeliveryState {
|
interface DeliveryState {
|
||||||
@@ -13,6 +14,8 @@ interface DeliveryState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isLoadingCounts: boolean;
|
isLoadingCounts: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
countsError: string | null;
|
||||||
|
pendingIds: string[];
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchDeliveriesByDate: (date: string) => Promise<void>;
|
fetchDeliveriesByDate: (date: string) => Promise<void>;
|
||||||
@@ -33,6 +36,9 @@ interface DeliveryState {
|
|||||||
handleWsDeliveryDeleted: (id: string) => void;
|
handleWsDeliveryDeleted: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let listRequest = 0;
|
||||||
|
let countsRequest = 0;
|
||||||
|
|
||||||
export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
deliveries: [],
|
deliveries: [],
|
||||||
@@ -41,127 +47,91 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
isLoadingCounts: false,
|
isLoadingCounts: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
countsError: null,
|
||||||
|
pendingIds: [],
|
||||||
|
|
||||||
// Fetch deliveries for a specific date
|
// Ignore stale responses when users switch days quickly.
|
||||||
fetchDeliveriesByDate: async (date: string) => {
|
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 {
|
try {
|
||||||
const deliveries = await deliveriesApi.getByDate(date);
|
const deliveries = await deliveriesApi.getByDate(date);
|
||||||
set({ deliveries, isLoading: false });
|
if (request === listRequest) set({ deliveries, isLoading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to fetch deliveries';
|
if (request !== listRequest) return;
|
||||||
set({
|
set({ error: deliveryErrorMessage(err, 'Не удалось загрузить доставки.'), isLoading: false });
|
||||||
error: message,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
useToastStore.getState().addToast(message, 'error');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fetch delivery counts for calendar
|
|
||||||
fetchDeliveryCounts: async () => {
|
fetchDeliveryCounts: async () => {
|
||||||
set({ isLoadingCounts: true, error: null });
|
const request = ++countsRequest;
|
||||||
|
set({ isLoadingCounts: true, countsError: null });
|
||||||
try {
|
try {
|
||||||
const counts = await deliveriesApi.getCounts();
|
const counts = await deliveriesApi.getCounts();
|
||||||
set({ deliveryCounts: counts, isLoadingCounts: false });
|
if (request === countsRequest) set({ deliveryCounts: counts, isLoadingCounts: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to fetch counts';
|
if (request === countsRequest) set({ countsError: deliveryErrorMessage(err, 'Не удалось обновить календарь.'), isLoadingCounts: false });
|
||||||
set({
|
|
||||||
error: message,
|
|
||||||
isLoadingCounts: false,
|
|
||||||
});
|
|
||||||
useToastStore.getState().addToast(message, 'error');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Add new delivery
|
// Mutations keep the current day and list mounted. Count refresh failures
|
||||||
addDelivery: async (delivery) => {
|
// must not turn a successfully saved delivery into a failed submission.
|
||||||
set({ isLoading: true, error: null });
|
addDelivery: async delivery => {
|
||||||
try {
|
try {
|
||||||
await deliveriesApi.create(delivery);
|
const id = await deliveriesApi.create(delivery);
|
||||||
// Refresh deliveries for that date
|
get().handleWsDeliveryCreated({ ...delivery, id, createdAt: Date.now(), updatedAt: Date.now() });
|
||||||
await get().fetchDeliveriesByDate(delivery.date);
|
void get().fetchDeliveryCounts();
|
||||||
// Refresh counts
|
|
||||||
await get().fetchDeliveryCounts();
|
|
||||||
set({ isLoading: false });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to create delivery';
|
useToastStore.getState().addToast(deliveryErrorMessage(err, 'Не удалось создать доставку.'), 'error');
|
||||||
set({
|
|
||||||
error: message,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
useToastStore.getState().addToast(message, 'error');
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update delivery
|
|
||||||
updateDelivery: async (id, updates) => {
|
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 {
|
try {
|
||||||
await deliveriesApi.update(id, updates);
|
await deliveriesApi.update(id, updates);
|
||||||
// Refresh deliveries for that date
|
const existing = get().deliveries.find(d => d.id === id);
|
||||||
await get().fetchDeliveriesByDate(updates.date);
|
get().handleWsDeliveryUpdated({ ...updates, id, createdAt: existing?.createdAt || Date.now(), updatedAt: Date.now() });
|
||||||
// Refresh counts (in case date changed)
|
void get().fetchDeliveryCounts();
|
||||||
await get().fetchDeliveryCounts();
|
|
||||||
set({ isLoading: false });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to update delivery';
|
useToastStore.getState().addToast(deliveryErrorMessage(err, 'Не удалось сохранить доставку.'), 'error');
|
||||||
set({
|
|
||||||
error: message,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
useToastStore.getState().addToast(message, 'error');
|
|
||||||
throw err;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
set(state => ({ pendingIds: state.pendingIds.filter(pending => pending !== id) }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete delivery
|
deleteDelivery: async id => {
|
||||||
deleteDelivery: async (id) => {
|
if (get().pendingIds.includes(id)) return;
|
||||||
set({ isLoading: true, error: null });
|
set(state => ({ pendingIds: [...state.pendingIds, id] }));
|
||||||
try {
|
try {
|
||||||
await deliveriesApi.delete(id);
|
await deliveriesApi.delete(id);
|
||||||
// Remove from local state
|
get().handleWsDeliveryDeleted(id);
|
||||||
set((state) => ({
|
void get().fetchDeliveryCounts();
|
||||||
deliveries: state.deliveries.filter((d) => d.id !== id),
|
useToastStore.getState().addToast('Доставка удалена', 'success');
|
||||||
isLoading: false,
|
|
||||||
}));
|
|
||||||
// Refresh counts
|
|
||||||
await get().fetchDeliveryCounts();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to delete delivery';
|
useToastStore.getState().addToast(deliveryErrorMessage(err, 'Не удалось удалить доставку.'), 'error');
|
||||||
set({
|
|
||||||
error: message,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
useToastStore.getState().addToast(message, 'error');
|
|
||||||
throw err;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
set(state => ({ pendingIds: state.pendingIds.filter(pending => pending !== id) }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggle delivery status
|
|
||||||
toggleStatus: async (id, currentStatus) => {
|
toggleStatus: async (id, currentStatus) => {
|
||||||
set({ isLoading: true, error: null });
|
if (get().pendingIds.includes(id)) return;
|
||||||
|
set(state => ({ pendingIds: [...state.pendingIds, id] }));
|
||||||
try {
|
try {
|
||||||
const newStatus = currentStatus === 'new' ? 'delivered' : 'new';
|
const status = currentStatus === 'new' ? 'delivered' : 'new';
|
||||||
await deliveriesApi.updateStatus(id, newStatus);
|
await deliveriesApi.updateStatus(id, status);
|
||||||
// Update local state
|
get().handleWsStatusChanged(id, status);
|
||||||
set((state) => ({
|
useToastStore.getState().addToast(status === 'delivered' ? 'Доставка отмечена выполненной' : 'Доставка возвращена в новые', 'success');
|
||||||
deliveries: state.deliveries.map((d) =>
|
|
||||||
d.id === id
|
|
||||||
? { ...d, status: newStatus, updatedAt: Date.now() }
|
|
||||||
: d
|
|
||||||
),
|
|
||||||
isLoading: false,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to update status';
|
useToastStore.getState().addToast(deliveryErrorMessage(err, 'Не удалось изменить статус.'), 'error');
|
||||||
set({
|
|
||||||
error: message,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
useToastStore.getState().addToast(message, 'error');
|
|
||||||
throw err;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
set(state => ({ pendingIds: state.pendingIds.filter(pending => pending !== id) }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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} Проверьте соединение и попробуйте ещё раз.`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user