Compare commits

...

4 Commits

Author SHA1 Message Date
Egor Pozharov
9abc1e3888 add /api proxy 2026-04-14 17:30:43 +06:00
Egor Pozharov
9c9f01b2f2 add pwa 2026-04-14 17:13:47 +06:00
Egor Pozharov
cb3f91c17f refactor [2] 2026-04-14 17:11:00 +06:00
Egor Pozharov
9b90a8aa7f frontend refactor 2026-04-14 17:10:13 +06:00
26 changed files with 9824 additions and 570 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
dev-dist
dist-ssr
*.local

View File

@@ -1,2 +1,4 @@
# API Configuration
VITE_API_URL=http://localhost:8080
# Leave empty to use proxy (recommended for local dev and production)
# Or set full URL like http://localhost:8081 for direct API access
VITE_API_URL=

View File

@@ -16,6 +16,19 @@ server {
add_header Cache-Control "public, immutable";
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;

6902
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/workbox-window": "^4.3.4",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -30,6 +31,8 @@
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
"vite": "^8.0.1",
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#1B263B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@@ -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>
);
}

View File

@@ -1,4 +1,9 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
const API_BASE_URL = import.meta.env.VITE_API_URL || '';
// 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;
@@ -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();
}

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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

View File

@@ -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">

View File

@@ -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';

View 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>
);
};

View 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,
];

View File

@@ -3,6 +3,18 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then((registration) => {
console.log('SW registered:', registration)
})
.catch((error) => {
console.log('SW registration failed:', error)
})
})
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
},

View File

@@ -0,0 +1,35 @@
import { create } from 'zustand';
export type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastState {
toasts: Toast[];
addToast: (message: string, type: ToastType) => void;
removeToast: (id: string) => void;
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (message, type) => {
const id = Math.random().toString(36).substring(2, 9);
set((state) => ({
toasts: [...state.toasts, { id, message, type }],
}));
// Auto remove after 5 seconds
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
}, 5000);
},
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
}));

View 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);
}

View File

@@ -1,15 +1,37 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
manifest: false, // manifest.json from public
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
},
},
],
},
}),
],
server: {
allowedHosts: ['delivery.loca.lt', '.loca.lt'],
// hmr: {
// host: 'delivery.loca.lt', // Use the hostname provided by localtunnel
// port: 443, // Use HTTPS port
// },
proxy: {
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
},
},
},
})

File diff suppressed because it is too large Load Diff