frontend refactor
This commit is contained in:
@@ -1,10 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||||
import { Truck } from 'lucide-react';
|
import { Truck, Loader2 } from 'lucide-react';
|
||||||
import { Dashboard } from './pages/Dashboard';
|
|
||||||
import { DeliveryListPage } from './pages/DeliveryListPage';
|
|
||||||
import { DeliveryForm } from './components/delivery/DeliveryForm';
|
import { DeliveryForm } from './components/delivery/DeliveryForm';
|
||||||
|
import { ToastContainer } from './components/ui/Toast';
|
||||||
import { useDeliveryStore } from './stores/deliveryStore';
|
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() {
|
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<string>('');
|
||||||
@@ -76,6 +86,7 @@ function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
{view === 'dashboard' ? (
|
{view === 'dashboard' ? (
|
||||||
<Dashboard
|
<Dashboard
|
||||||
onDateSelect={handleDateSelect}
|
onDateSelect={handleDateSelect}
|
||||||
@@ -87,6 +98,7 @@ function App() {
|
|||||||
onBack={handleBackToDashboard}
|
onBack={handleBackToDashboard}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<DeliveryForm
|
<DeliveryForm
|
||||||
@@ -96,6 +108,8 @@ function App() {
|
|||||||
defaultDate={formDate}
|
defaultDate={formDate}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
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 {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
details?: unknown;
|
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>(
|
async function fetchApi<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options?: RequestInit
|
options?: RequestInit & { deduplicate?: boolean }
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = `${API_BASE_URL}${endpoint}`;
|
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, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
@@ -36,10 +67,22 @@ async function fetchApi<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
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 = {
|
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) =>
|
post: <T>(endpoint: string, data: unknown) =>
|
||||||
fetchApi<T>(endpoint, {
|
fetchApi<T>(endpoint, {
|
||||||
@@ -56,3 +99,10 @@ export const api = {
|
|||||||
delete: <T>(endpoint: string) =>
|
delete: <T>(endpoint: string) =>
|
||||||
fetchApi<T>(endpoint, { method: 'DELETE' }),
|
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 { api } from './client';
|
||||||
|
import { backendDateToFrontend } from '../utils/date';
|
||||||
import type { Delivery, PickupLocation, DeliveryStatus } from '../types';
|
import type { Delivery, PickupLocation, DeliveryStatus } from '../types';
|
||||||
|
|
||||||
// Types matching backend responses
|
// Types matching backend responses
|
||||||
@@ -44,18 +45,6 @@ interface UpdateDeliveryResponse {
|
|||||||
message: string;
|
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
|
// Map backend delivery to frontend delivery
|
||||||
function mapBackendToFrontend(backend: BackendDelivery): Delivery {
|
function mapBackendToFrontend(backend: BackendDelivery): Delivery {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { api, ApiError } from './client';
|
export { api, ApiError, cancelAllRequests } from './client';
|
||||||
export { deliveriesApi, frontendDateToBackend } from './deliveries';
|
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 { MapPin, Phone, Package, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare } from 'lucide-react';
|
||||||
import type { Delivery } from '../../types';
|
import type { Delivery } from '../../types';
|
||||||
import { pickupLocationLabels } from '../../types';
|
import { pickupLocationLabels } from '../../types';
|
||||||
@@ -11,7 +12,7 @@ interface DeliveryCardProps {
|
|||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => {
|
export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => {
|
||||||
const handleAddressClick = () => {
|
const handleAddressClick = () => {
|
||||||
const encodedAddress = encodeURIComponent(delivery.address);
|
const encodedAddress = encodeURIComponent(delivery.address);
|
||||||
window.open(`https://maps.google.com/?q=${encodedAddress}`, '_blank');
|
window.open(`https://maps.google.com/?q=${encodedAddress}`, '_blank');
|
||||||
@@ -132,4 +133,6 @@ export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: Del
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 { 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 type { Delivery, PickupLocation, DeliveryStatus } from '../../types';
|
||||||
import { pickupLocationLabels } from '../../types';
|
|
||||||
|
|
||||||
interface DeliveryFormProps {
|
interface DeliveryFormProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,16 +13,12 @@ interface DeliveryFormProps {
|
|||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickupOptions: { value: PickupLocation; label: string }[] = [
|
// Phone validation regex for Kazakhstan numbers
|
||||||
{ value: 'warehouse', label: pickupLocationLabels.warehouse },
|
const PHONE_REGEX = /^\+7\s?\(?\d{3}\)?\s?\d{3}[\s-]?\d{2}[\s-]?\d{2}$/;
|
||||||
{ value: 'symbat', label: pickupLocationLabels.symbat },
|
|
||||||
{ value: 'nursaya', label: pickupLocationLabels.nursaya },
|
|
||||||
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate, isSubmitting }: DeliveryFormProps) => {
|
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate, isSubmitting }: DeliveryFormProps) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'),
|
date: defaultDate || getTodayFrontend(),
|
||||||
pickupLocation: 'warehouse' as PickupLocation,
|
pickupLocation: 'warehouse' as PickupLocation,
|
||||||
productName: '',
|
productName: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -50,12 +47,23 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
}
|
}
|
||||||
}, [initialData, defaultDate, isOpen]);
|
}, [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();
|
e.preventDefault();
|
||||||
onSubmit(formData);
|
if (!isFormValid) return;
|
||||||
|
try {
|
||||||
|
await onSubmit(formData);
|
||||||
if (!initialData) {
|
if (!initialData) {
|
||||||
setFormData({
|
setFormData({
|
||||||
date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'),
|
date: defaultDate || getTodayFrontend(),
|
||||||
pickupLocation: 'warehouse',
|
pickupLocation: 'warehouse',
|
||||||
productName: '',
|
productName: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -67,17 +75,11 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
onClose();
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -89,7 +91,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
|
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="delivery-form" disabled={isSubmitting}>
|
<Button type="submit" form="delivery-form" disabled={isSubmitting || !isFormValid}>
|
||||||
{isSubmitting ? 'Сохранение...' : initialData ? 'Сохранить' : 'Создать'}
|
{isSubmitting ? 'Сохранение...' : initialData ? 'Сохранить' : 'Создать'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -103,7 +105,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formatDateForInput(formData.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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
@@ -144,7 +146,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
}}
|
}}
|
||||||
placeholder="+7 (776)-567-89-01"
|
placeholder="+7 (776)-567-89-01"
|
||||||
required
|
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
|
<Input
|
||||||
label="Дополнительный номер телефона"
|
label="Дополнительный номер телефона"
|
||||||
@@ -157,7 +166,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="+7 (776)-567-89-01"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { Plus, LayoutGrid, Table as TableIcon } from 'lucide-react';
|
import { Plus, LayoutGrid, Table as TableIcon } from 'lucide-react';
|
||||||
import { DeliveryCard } from './DeliveryCard';
|
import { DeliveryCard } from './DeliveryCard';
|
||||||
import { DeliveryRow } from './DeliveryRow';
|
import { DeliveryRow } from './DeliveryRow';
|
||||||
@@ -17,8 +17,8 @@ interface DeliveryListProps {
|
|||||||
export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => {
|
export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => {
|
||||||
const [viewMode, setViewMode] = useState<'kanban' | 'table'>('kanban');
|
const [viewMode, setViewMode] = useState<'kanban' | 'table'>('kanban');
|
||||||
|
|
||||||
const newDeliveries = deliveries.filter(d => d.status === 'new');
|
const newDeliveries = useMemo(() => deliveries.filter(d => d.status === 'new'), [deliveries]);
|
||||||
const deliveredDeliveries = deliveries.filter(d => d.status === 'delivered');
|
const deliveredDeliveries = useMemo(() => deliveries.filter(d => d.status === 'delivered'), [deliveries]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import { MapPin, Phone } from 'lucide-react';
|
import { MapPin, Phone } from 'lucide-react';
|
||||||
import type { Delivery } from '../../types';
|
import type { Delivery } from '../../types';
|
||||||
import { pickupLocationLabels } from '../../types';
|
import { pickupLocationLabels } from '../../types';
|
||||||
@@ -10,7 +11,7 @@ interface DeliveryRowProps {
|
|||||||
onDelete: (id: string) => void;
|
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) => {
|
const handleAddressClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const encodedAddress = encodeURIComponent(delivery.address);
|
const encodedAddress = encodeURIComponent(delivery.address);
|
||||||
@@ -75,4 +76,6 @@ export const DeliveryRow = ({ delivery, onStatusChange, onEdit, onDelete }: Deli
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 { Plus, Printer, ChevronRight, CalendarDays } from 'lucide-react';
|
||||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday } from 'date-fns';
|
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday } 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 type { Delivery } from '../types';
|
import type { Delivery } from '../types';
|
||||||
|
import { pickupLocationLabels } 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';
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ interface DashboardProps {
|
|||||||
onAddDelivery: () => void;
|
onAddDelivery: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
||||||
const deliveryCounts = useDeliveryStore(state => state.deliveryCounts);
|
const deliveryCounts = useDeliveryStore(state => state.deliveryCounts);
|
||||||
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
@@ -22,9 +23,11 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
|||||||
fetchDeliveryCounts();
|
fetchDeliveryCounts();
|
||||||
}, [fetchDeliveryCounts]);
|
}, [fetchDeliveryCounts]);
|
||||||
|
|
||||||
|
const days = useMemo(() => {
|
||||||
const monthStart = startOfMonth(currentMonth);
|
const monthStart = startOfMonth(currentMonth);
|
||||||
const monthEnd = endOfMonth(currentMonth);
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
return eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||||
|
}, [currentMonth]);
|
||||||
|
|
||||||
const getCountForDate = (date: Date) => {
|
const getCountForDate = (date: Date) => {
|
||||||
const dateStr = format(date, 'dd-MM-yyyy');
|
const dateStr = format(date, 'dd-MM-yyyy');
|
||||||
@@ -76,7 +79,7 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
|||||||
${dayDeliveries.map((d: Delivery) => `
|
${dayDeliveries.map((d: Delivery) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="status-${d.status}">${d.status === 'new' ? 'Новое' : 'Доставлено'}</span></td>
|
<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.productName}</td>
|
||||||
<td>${d.address}</td>
|
<td>${d.address}</td>
|
||||||
<td>${d.phone}</td>
|
<td>${d.phone}</td>
|
||||||
@@ -118,7 +121,7 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-lg font-semibold text-[#1b1b1d] flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-[#1b1b1d] flex items-center gap-2">
|
||||||
<CalendarDays size={20} className="text-[#1B263B]" />
|
<CalendarDays size={20} className="text-[#1B263B]" />
|
||||||
{format(currentMonth, 'MMMM yyyy', { locale: ru })}
|
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigateMonth('prev')}>
|
<Button variant="ghost" size="sm" onClick={() => navigateMonth('prev')}>
|
||||||
@@ -234,3 +237,5 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
|
|||||||
</div>
|
</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 { ArrowLeft, Filter, Loader2, AlertCircle } from 'lucide-react';
|
||||||
import { useDeliveryStore } from '../stores/deliveryStore';
|
import { useDeliveryStore } from '../stores/deliveryStore';
|
||||||
import { DeliveryList as DeliveryListComponent } from '../components/delivery/DeliveryList';
|
import { DeliveryList as DeliveryListComponent } 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 { Select } from '../components/ui/Select';
|
import { Select } from '../components/ui/Select';
|
||||||
|
import { pickupFilterOptions } from '../constants/pickup';
|
||||||
import type { Delivery, PickupLocation } from '../types';
|
import type { Delivery, PickupLocation } from '../types';
|
||||||
import { pickupLocationLabels } from '../types';
|
|
||||||
|
|
||||||
interface DeliveryListPageProps {
|
interface DeliveryListPageProps {
|
||||||
selectedDate: string;
|
selectedDate: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps) => {
|
const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps) => {
|
||||||
const deliveries = useDeliveryStore(state => state.deliveries);
|
const {
|
||||||
const isLoading = useDeliveryStore(state => state.isLoading);
|
deliveries,
|
||||||
const error = useDeliveryStore(state => state.error);
|
isLoading,
|
||||||
const fetchDeliveriesByDate = useDeliveryStore(state => state.fetchDeliveriesByDate);
|
error,
|
||||||
const toggleStatus = useDeliveryStore(state => state.toggleStatus);
|
fetchDeliveriesByDate,
|
||||||
const deleteDelivery = useDeliveryStore(state => state.deleteDelivery);
|
toggleStatus,
|
||||||
const updateDelivery = useDeliveryStore(state => state.updateDelivery);
|
deleteDelivery,
|
||||||
const addDelivery = useDeliveryStore(state => state.addDelivery);
|
updateDelivery,
|
||||||
const clearError = useDeliveryStore(state => state.clearError);
|
addDelivery,
|
||||||
|
clearError,
|
||||||
|
} = useDeliveryStore();
|
||||||
|
|
||||||
// Fetch deliveries when date changes
|
// Fetch deliveries when date changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,18 +36,10 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
|
|||||||
const [pickupFilter, setPickupFilter] = useState<PickupLocation | 'all'>('all');
|
const [pickupFilter, setPickupFilter] = useState<PickupLocation | 'all'>('all');
|
||||||
|
|
||||||
// Use all deliveries from store (already filtered by API)
|
// Use all deliveries from store (already filtered by API)
|
||||||
const dayDeliveries = deliveries;
|
const filteredDeliveries = useMemo(() => {
|
||||||
const filteredDeliveries = pickupFilter === 'all'
|
if (pickupFilter === 'all') return deliveries;
|
||||||
? dayDeliveries
|
return deliveries.filter(d => d.pickupLocation === pickupFilter);
|
||||||
: dayDeliveries.filter(d => d.pickupLocation === pickupFilter);
|
}, [deliveries, 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 handleStatusChange = async (id: string) => {
|
const handleStatusChange = async (id: string) => {
|
||||||
const delivery = deliveries.find(d => d.id === id);
|
const delivery = deliveries.find(d => d.id === id);
|
||||||
@@ -110,7 +104,7 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
|
|||||||
label=""
|
label=""
|
||||||
value={pickupFilter}
|
value={pickupFilter}
|
||||||
onChange={(e) => setPickupFilter(e.target.value as PickupLocation | 'all')}
|
onChange={(e) => setPickupFilter(e.target.value as PickupLocation | 'all')}
|
||||||
options={pickupOptions}
|
options={pickupFilterOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,3 +144,5 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default DeliveryListPage;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { deliveriesApi } from '../api';
|
import { deliveriesApi } from '../api';
|
||||||
|
import { useToastStore } from './toastStore';
|
||||||
import type { Delivery, DeliveryStatus } from '../types';
|
import type { Delivery, DeliveryStatus } from '../types';
|
||||||
|
|
||||||
interface DeliveryState {
|
interface DeliveryState {
|
||||||
@@ -40,10 +41,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
|||||||
const deliveries = await deliveriesApi.getByDate(date);
|
const deliveries = await deliveriesApi.getByDate(date);
|
||||||
set({ deliveries, isLoading: false });
|
set({ deliveries, isLoading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to fetch deliveries';
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Failed to fetch deliveries',
|
error: message,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
useToastStore.getState().addToast(message, 'error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -54,10 +57,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
|||||||
const counts = await deliveriesApi.getCounts();
|
const counts = await deliveriesApi.getCounts();
|
||||||
set({ deliveryCounts: counts, isLoadingCounts: false });
|
set({ deliveryCounts: counts, isLoadingCounts: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to fetch counts';
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Failed to fetch counts',
|
error: message,
|
||||||
isLoadingCounts: false,
|
isLoadingCounts: false,
|
||||||
});
|
});
|
||||||
|
useToastStore.getState().addToast(message, 'error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -72,10 +77,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
|||||||
await get().fetchDeliveryCounts();
|
await get().fetchDeliveryCounts();
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to create delivery';
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Failed to create delivery',
|
error: message,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
useToastStore.getState().addToast(message, 'error');
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -91,10 +98,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
|||||||
await get().fetchDeliveryCounts();
|
await get().fetchDeliveryCounts();
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to update delivery';
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Failed to update delivery',
|
error: message,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
useToastStore.getState().addToast(message, 'error');
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -112,10 +121,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
|||||||
// Refresh counts
|
// Refresh counts
|
||||||
await get().fetchDeliveryCounts();
|
await get().fetchDeliveryCounts();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to delete delivery';
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Failed to delete delivery',
|
error: message,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
useToastStore.getState().addToast(message, 'error');
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -136,10 +147,12 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to update status';
|
||||||
set({
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Failed to update status',
|
error: message,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
useToastStore.getState().addToast(message, 'error');
|
||||||
throw err;
|
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