frontend refactor
This commit is contained in:
@@ -1,10 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { DeliveryListPage } from './pages/DeliveryListPage';
|
||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||
import { Truck, Loader2 } from 'lucide-react';
|
||||
import { DeliveryForm } from './components/delivery/DeliveryForm';
|
||||
import { ToastContainer } from './components/ui/Toast';
|
||||
import { useDeliveryStore } from './stores/deliveryStore';
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const DeliveryListPage = lazy(() => import('./pages/DeliveryListPage'));
|
||||
|
||||
// Fallback loading component
|
||||
const PageLoader = () => (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
|
||||
</div>
|
||||
);
|
||||
|
||||
function App() {
|
||||
const [view, setView] = useState<'dashboard' | 'delivery-list'>('dashboard');
|
||||
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||
@@ -76,6 +86,7 @@ function App() {
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
{view === 'dashboard' ? (
|
||||
<Dashboard
|
||||
onDateSelect={handleDateSelect}
|
||||
@@ -87,6 +98,7 @@ function App() {
|
||||
onBack={handleBackToDashboard}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<DeliveryForm
|
||||
@@ -96,6 +108,8 @@ function App() {
|
||||
defaultDate={formDate}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
|
||||
// Request deduplication cache
|
||||
const pendingRequests = new Map<string, Promise<unknown>>();
|
||||
// Abort controllers for cancelling requests
|
||||
const abortControllers = new Map<string, AbortController>();
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
details?: unknown;
|
||||
@@ -12,14 +17,40 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestKey(endpoint: string, method: string, body?: unknown): string {
|
||||
return `${method}:${endpoint}:${body ? JSON.stringify(body) : ''}`;
|
||||
}
|
||||
|
||||
async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
options?: RequestInit & { deduplicate?: boolean }
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const method = options?.method || 'GET';
|
||||
const requestKey = getRequestKey(endpoint, method, options?.body);
|
||||
|
||||
// Cancel previous request with same key (for non-GET requests or explicit override)
|
||||
const shouldCancelPrevious = method !== 'GET' || options?.deduplicate === false;
|
||||
if (shouldCancelPrevious && abortControllers.has(requestKey)) {
|
||||
abortControllers.get(requestKey)?.abort();
|
||||
}
|
||||
|
||||
// Deduplicate GET requests
|
||||
if (method === 'GET' && options?.deduplicate !== false) {
|
||||
if (pendingRequests.has(requestKey)) {
|
||||
return pendingRequests.get(requestKey) as Promise<T>;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
const controller = new AbortController();
|
||||
abortControllers.set(requestKey, controller);
|
||||
|
||||
const requestPromise = (async (): Promise<T> => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
@@ -36,10 +67,22 @@ async function fetchApi<T>(
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} finally {
|
||||
pendingRequests.delete(requestKey);
|
||||
abortControllers.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
|
||||
if (method === 'GET' && options?.deduplicate !== false) {
|
||||
pendingRequests.set(requestKey, requestPromise);
|
||||
}
|
||||
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||
get: <T>(endpoint: string, options?: { deduplicate?: boolean }) =>
|
||||
fetchApi<T>(endpoint, { method: 'GET', ...options }),
|
||||
|
||||
post: <T>(endpoint: string, data: unknown) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
@@ -56,3 +99,10 @@ export const api = {
|
||||
delete: <T>(endpoint: string) =>
|
||||
fetchApi<T>(endpoint, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Utility to cancel all pending requests (useful on unmount)
|
||||
export function cancelAllRequests(): void {
|
||||
abortControllers.forEach(controller => controller.abort());
|
||||
abortControllers.clear();
|
||||
pendingRequests.clear();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from './client';
|
||||
import { backendDateToFrontend } from '../utils/date';
|
||||
import type { Delivery, PickupLocation, DeliveryStatus } from '../types';
|
||||
|
||||
// Types matching backend responses
|
||||
@@ -44,18 +45,6 @@ interface UpdateDeliveryResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Convert backend date format (YYYY-MM-DD) to frontend format (DD-MM-YYYY)
|
||||
function backendDateToFrontend(dateStr: string): string {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${day}-${month}-${year}`;
|
||||
}
|
||||
|
||||
// Convert frontend date format (DD-MM-YYYY) to backend format (YYYY-MM-DD)
|
||||
export function frontendDateToBackend(dateStr: string): string {
|
||||
const [day, month, year] = dateStr.split('-');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Map backend delivery to frontend delivery
|
||||
function mapBackendToFrontend(backend: BackendDelivery): Delivery {
|
||||
return {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { api, ApiError } from './client';
|
||||
export { deliveriesApi, frontendDateToBackend } from './deliveries';
|
||||
export { api, ApiError, cancelAllRequests } from './client';
|
||||
export { deliveriesApi } from './deliveries';
|
||||
export { frontendDateToBackend } from '../utils/date';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { MapPin, Phone, Package, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare } from 'lucide-react';
|
||||
import type { Delivery } from '../../types';
|
||||
import { pickupLocationLabels } from '../../types';
|
||||
@@ -11,7 +12,7 @@ interface DeliveryCardProps {
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => {
|
||||
export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => {
|
||||
const handleAddressClick = () => {
|
||||
const encodedAddress = encodeURIComponent(delivery.address);
|
||||
window.open(`https://maps.google.com/?q=${encodedAddress}`, '_blank');
|
||||
@@ -132,4 +133,6 @@ export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: Del
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
DeliveryCard.displayName = 'DeliveryCard';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button, Input, Select, Modal } from '../ui';
|
||||
import { pickupOptions } from '../../constants/pickup';
|
||||
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
|
||||
import type { Delivery, PickupLocation, DeliveryStatus } from '../../types';
|
||||
import { pickupLocationLabels } from '../../types';
|
||||
|
||||
interface DeliveryFormProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,16 +13,12 @@ interface DeliveryFormProps {
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const pickupOptions: { value: PickupLocation; label: string }[] = [
|
||||
{ value: 'warehouse', label: pickupLocationLabels.warehouse },
|
||||
{ value: 'symbat', label: pickupLocationLabels.symbat },
|
||||
{ value: 'nursaya', label: pickupLocationLabels.nursaya },
|
||||
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
|
||||
];
|
||||
// Phone validation regex for Kazakhstan numbers
|
||||
const PHONE_REGEX = /^\+7\s?\(?\d{3}\)?\s?\d{3}[\s-]?\d{2}[\s-]?\d{2}$/;
|
||||
|
||||
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate, isSubmitting }: DeliveryFormProps) => {
|
||||
const [formData, setFormData] = useState({
|
||||
date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'),
|
||||
date: defaultDate || getTodayFrontend(),
|
||||
pickupLocation: 'warehouse' as PickupLocation,
|
||||
productName: '',
|
||||
address: '',
|
||||
@@ -50,12 +47,23 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
||||
}
|
||||
}, [initialData, defaultDate, isOpen]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
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 isFormValid = formData.productName && formData.address && formData.phone && isPhoneValid;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
if (!isFormValid) return;
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
if (!initialData) {
|
||||
setFormData({
|
||||
date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'),
|
||||
date: defaultDate || getTodayFrontend(),
|
||||
pickupLocation: 'warehouse',
|
||||
productName: '',
|
||||
address: '',
|
||||
@@ -67,17 +75,11 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
// Error is handled by parent, keep form open
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateForInput = (dateStr: string) => {
|
||||
const [day, month, year] = dateStr.split('-');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatDateFromInput = (dateStr: string) => {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${day}-${month}-${year}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -89,7 +91,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
||||
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" form="delivery-form" disabled={isSubmitting}>
|
||||
<Button type="submit" form="delivery-form" disabled={isSubmitting || !isFormValid}>
|
||||
{isSubmitting ? 'Сохранение...' : initialData ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</>
|
||||
@@ -103,7 +105,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
||||
<input
|
||||
type="date"
|
||||
value={formatDateForInput(formData.date)}
|
||||
onChange={(e) => setFormData({ ...formData, date: formatDateFromInput(e.target.value) })}
|
||||
onChange={(e) => setFormData({ ...formData, date: parseDateFromInput(e.target.value) })}
|
||||
className="w-full px-3 py-2 bg-[#f5f3f5] border border-[#c5c6cd] rounded-md text-[#1b1b1d] focus:outline-none focus:ring-2 focus:ring-[#1B263B] focus:border-transparent transition-colors"
|
||||
required
|
||||
/>
|
||||
@@ -144,7 +146,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
||||
}}
|
||||
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="Дополнительный номер телефона"
|
||||
@@ -157,7 +166,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
||||
}
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Plus, LayoutGrid, Table as TableIcon } from 'lucide-react';
|
||||
import { DeliveryCard } from './DeliveryCard';
|
||||
import { DeliveryRow } from './DeliveryRow';
|
||||
@@ -17,8 +17,8 @@ interface DeliveryListProps {
|
||||
export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => {
|
||||
const [viewMode, setViewMode] = useState<'kanban' | 'table'>('kanban');
|
||||
|
||||
const newDeliveries = deliveries.filter(d => d.status === 'new');
|
||||
const deliveredDeliveries = deliveries.filter(d => d.status === 'delivered');
|
||||
const newDeliveries = useMemo(() => deliveries.filter(d => d.status === 'new'), [deliveries]);
|
||||
const deliveredDeliveries = useMemo(() => deliveries.filter(d => d.status === 'delivered'), [deliveries]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { MapPin, Phone } from 'lucide-react';
|
||||
import type { Delivery } from '../../types';
|
||||
import { pickupLocationLabels } from '../../types';
|
||||
@@ -10,7 +11,7 @@ interface DeliveryRowProps {
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const DeliveryRow = ({ delivery, onStatusChange, onEdit, onDelete }: DeliveryRowProps) => {
|
||||
export const DeliveryRow = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryRowProps) => {
|
||||
const handleAddressClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const encodedAddress = encodeURIComponent(delivery.address);
|
||||
@@ -75,4 +76,6 @@ export const DeliveryRow = ({ delivery, onStatusChange, onEdit, onDelete }: Deli
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
DeliveryRow.displayName = 'DeliveryRow';
|
||||
|
||||
45
frontend/src/components/ui/Toast.tsx
Normal file
45
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useToastStore } from '../../stores/toastStore';
|
||||
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||
|
||||
const icons = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const styles = {
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
};
|
||||
|
||||
export const ToastContainer = () => {
|
||||
const { toasts, removeToast } = useToastStore();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => {
|
||||
const Icon = icons[toast.type];
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[300px] animate-in slide-in-from-right ${styles[toast.type]}`}
|
||||
role="alert"
|
||||
>
|
||||
<Icon size={20} />
|
||||
<p className="flex-1 text-sm">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="p-1 hover:bg-black/5 rounded transition-colors"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
frontend/src/constants/pickup.ts
Normal file
14
frontend/src/constants/pickup.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PickupLocation } from '../types';
|
||||
import { pickupLocationLabels } from '../types';
|
||||
|
||||
export const pickupOptions: { value: PickupLocation; label: string }[] = [
|
||||
{ value: 'warehouse', label: pickupLocationLabels.warehouse },
|
||||
{ value: 'symbat', label: pickupLocationLabels.symbat },
|
||||
{ value: 'nursaya', label: pickupLocationLabels.nursaya },
|
||||
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
|
||||
];
|
||||
|
||||
export const pickupFilterOptions: { value: PickupLocation | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'Все места загрузки' },
|
||||
...pickupOptions,
|
||||
];
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Plus, Printer, ChevronRight, CalendarDays } from 'lucide-react';
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { useDeliveryStore } from '../stores/deliveryStore';
|
||||
import type { Delivery } from '../types';
|
||||
import { pickupLocationLabels } from '../types';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Card } from '../components/ui/Card';
|
||||
|
||||
@@ -12,7 +13,7 @@ interface DashboardProps {
|
||||
onAddDelivery: () => void;
|
||||
}
|
||||
|
||||
export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
||||
const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
||||
const deliveryCounts = useDeliveryStore(state => state.deliveryCounts);
|
||||
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
@@ -22,9 +23,11 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
||||
fetchDeliveryCounts();
|
||||
}, [fetchDeliveryCounts]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
return eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
}, [currentMonth]);
|
||||
|
||||
const getCountForDate = (date: Date) => {
|
||||
const dateStr = format(date, 'dd-MM-yyyy');
|
||||
@@ -76,7 +79,7 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
||||
${dayDeliveries.map((d: Delivery) => `
|
||||
<tr>
|
||||
<td><span class="status-${d.status}">${d.status === 'new' ? 'Новое' : 'Доставлено'}</span></td>
|
||||
<td>${d.pickupLocation === 'warehouse' ? 'Склад' : d.pickupLocation === 'symbat' ? 'Сымбат' : d.pickupLocation === 'nursaya' ? 'Нурсая' : 'Галактика'}</td>
|
||||
<td>${pickupLocationLabels[d.pickupLocation]}</td>
|
||||
<td>${d.productName}</td>
|
||||
<td>${d.address}</td>
|
||||
<td>${d.phone}</td>
|
||||
@@ -118,7 +121,7 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
||||
<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, 'MMMM yyyy', { locale: ru })}
|
||||
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigateMonth('prev')}>
|
||||
@@ -234,3 +237,5 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ArrowLeft, Filter, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useDeliveryStore } from '../stores/deliveryStore';
|
||||
import { DeliveryList as DeliveryListComponent } from '../components/delivery/DeliveryList';
|
||||
import { DeliveryForm } from '../components/delivery/DeliveryForm';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Select } from '../components/ui/Select';
|
||||
import { pickupFilterOptions } from '../constants/pickup';
|
||||
import type { Delivery, PickupLocation } from '../types';
|
||||
import { pickupLocationLabels } from '../types';
|
||||
|
||||
interface DeliveryListPageProps {
|
||||
selectedDate: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps) => {
|
||||
const deliveries = useDeliveryStore(state => state.deliveries);
|
||||
const isLoading = useDeliveryStore(state => state.isLoading);
|
||||
const error = useDeliveryStore(state => state.error);
|
||||
const fetchDeliveriesByDate = useDeliveryStore(state => state.fetchDeliveriesByDate);
|
||||
const toggleStatus = useDeliveryStore(state => state.toggleStatus);
|
||||
const deleteDelivery = useDeliveryStore(state => state.deleteDelivery);
|
||||
const updateDelivery = useDeliveryStore(state => state.updateDelivery);
|
||||
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
||||
const clearError = useDeliveryStore(state => state.clearError);
|
||||
const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps) => {
|
||||
const {
|
||||
deliveries,
|
||||
isLoading,
|
||||
error,
|
||||
fetchDeliveriesByDate,
|
||||
toggleStatus,
|
||||
deleteDelivery,
|
||||
updateDelivery,
|
||||
addDelivery,
|
||||
clearError,
|
||||
} = useDeliveryStore();
|
||||
|
||||
// Fetch deliveries when date changes
|
||||
useEffect(() => {
|
||||
@@ -34,18 +36,10 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
|
||||
const [pickupFilter, setPickupFilter] = useState<PickupLocation | 'all'>('all');
|
||||
|
||||
// Use all deliveries from store (already filtered by API)
|
||||
const dayDeliveries = deliveries;
|
||||
const filteredDeliveries = pickupFilter === 'all'
|
||||
? dayDeliveries
|
||||
: dayDeliveries.filter(d => d.pickupLocation === pickupFilter);
|
||||
|
||||
const pickupOptions: { value: PickupLocation | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'Все места загрузки' },
|
||||
{ value: 'warehouse', label: pickupLocationLabels.warehouse },
|
||||
{ value: 'symbat', label: pickupLocationLabels.symbat },
|
||||
{ value: 'nursaya', label: pickupLocationLabels.nursaya },
|
||||
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
|
||||
];
|
||||
const filteredDeliveries = useMemo(() => {
|
||||
if (pickupFilter === 'all') return deliveries;
|
||||
return deliveries.filter(d => d.pickupLocation === pickupFilter);
|
||||
}, [deliveries, pickupFilter]);
|
||||
|
||||
const handleStatusChange = async (id: string) => {
|
||||
const delivery = deliveries.find(d => d.id === id);
|
||||
@@ -110,7 +104,7 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
|
||||
label=""
|
||||
value={pickupFilter}
|
||||
onChange={(e) => setPickupFilter(e.target.value as PickupLocation | 'all')}
|
||||
options={pickupOptions}
|
||||
options={pickupFilterOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,3 +144,5 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryListPage;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { deliveriesApi } from '../api';
|
||||
import { useToastStore } from './toastStore';
|
||||
import type { Delivery, DeliveryStatus } from '../types';
|
||||
|
||||
interface DeliveryState {
|
||||
@@ -40,10 +41,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
||||
const deliveries = await deliveriesApi.getByDate(date);
|
||||
set({ deliveries, isLoading: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch deliveries';
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch deliveries',
|
||||
error: message,
|
||||
isLoading: false,
|
||||
});
|
||||
useToastStore.getState().addToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -54,10 +57,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
||||
const counts = await deliveriesApi.getCounts();
|
||||
set({ deliveryCounts: counts, isLoadingCounts: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch counts';
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch counts',
|
||||
error: message,
|
||||
isLoadingCounts: false,
|
||||
});
|
||||
useToastStore.getState().addToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -72,10 +77,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
||||
await get().fetchDeliveryCounts();
|
||||
set({ isLoading: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create delivery';
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to create delivery',
|
||||
error: message,
|
||||
isLoading: false,
|
||||
});
|
||||
useToastStore.getState().addToast(message, 'error');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -91,10 +98,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
||||
await get().fetchDeliveryCounts();
|
||||
set({ isLoading: false });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update delivery';
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to update delivery',
|
||||
error: message,
|
||||
isLoading: false,
|
||||
});
|
||||
useToastStore.getState().addToast(message, 'error');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -112,10 +121,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
||||
// Refresh counts
|
||||
await get().fetchDeliveryCounts();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete delivery';
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to delete delivery',
|
||||
error: message,
|
||||
isLoading: false,
|
||||
});
|
||||
useToastStore.getState().addToast(message, 'error');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -136,10 +147,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update status';
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to update status',
|
||||
error: message,
|
||||
isLoading: false,
|
||||
});
|
||||
useToastStore.getState().addToast(message, 'error');
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
52
frontend/src/utils/date.ts
Normal file
52
frontend/src/utils/date.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { format, parse, type Locale } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Convert backend date format (YYYY-MM-DD) to frontend format (DD-MM-YYYY)
|
||||
*/
|
||||
export function backendDateToFrontend(dateStr: string): string {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${day}-${month}-${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert frontend date format (DD-MM-YYYY) to backend format (YYYY-MM-DD)
|
||||
*/
|
||||
export function frontendDateToBackend(dateStr: string): string {
|
||||
const [day, month, year] = dateStr.split('-');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format frontend date for HTML input type="date" (YYYY-MM-DD)
|
||||
*/
|
||||
export function formatDateForInput(dateStr: string): string {
|
||||
const [day, month, year] = dateStr.split('-');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse date from HTML input type="date" to frontend format (DD-MM-YYYY)
|
||||
*/
|
||||
export function parseDateFromInput(dateStr: string): string {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
return `${day}-${month}-${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date in frontend format
|
||||
*/
|
||||
export function getTodayFrontend(): string {
|
||||
return format(new Date(), 'dd-MM-yyyy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format frontend date for display with date-fns
|
||||
*/
|
||||
export function formatFrontendDate(
|
||||
dateStr: string,
|
||||
formatStr: string,
|
||||
options?: { locale?: Locale }
|
||||
): string {
|
||||
const date = parse(dateStr, 'dd-MM-yyyy', new Date());
|
||||
return format(date, formatStr, options);
|
||||
}
|
||||
Reference in New Issue
Block a user