Compare commits

...

2 Commits

Author SHA1 Message Date
Egor Pozharov
0540218332 switch frontend to real API instead of mocks 2026-04-14 16:17:42 +06:00
Egor Pozharov
7f410e814b add CORS 2026-04-14 16:17:22 +06:00
14 changed files with 502 additions and 192 deletions

View File

@@ -5,9 +5,11 @@ import (
"log"
"net/http"
"os"
"time"
db "github.com/chedius/delivery-tracker/internal/db/sqlc"
"github.com/chedius/delivery-tracker/internal/delivery"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
@@ -29,6 +31,15 @@ func main() {
r := gin.Default()
// CORS middleware - allow all origins in development
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"*"},
AllowCredentials: false,
MaxAge: 12 * time.Hour,
}))
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})

View File

@@ -7,7 +7,10 @@ require (
github.com/jackc/pgx/v5 v5.9.1
)
require github.com/google/uuid v1.6.0
require (
github.com/gin-contrib/cors v1.7.7
github.com/google/uuid v1.6.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
@@ -37,11 +40,11 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

View File

@@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
@@ -83,19 +85,19 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
# API Configuration
VITE_API_URL=http://localhost:8080

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Truck } from 'lucide-react';
import { Dashboard } from './pages/Dashboard';
import { DeliveryListPage } from './pages/DeliveryListPage';
@@ -10,8 +10,17 @@ function App() {
const [selectedDate, setSelectedDate] = useState<string>('');
const [isFormOpen, setIsFormOpen] = useState(false);
const [formDate, setFormDate] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const addDelivery = useDeliveryStore(state => state.addDelivery);
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
// Refresh counts when form closes
useEffect(() => {
if (!isFormOpen) {
fetchDeliveryCounts();
}
}, [isFormOpen, fetchDeliveryCounts]);
const handleDateSelect = (date: string) => {
setSelectedDate(date);
@@ -29,13 +38,22 @@ function App() {
setIsFormOpen(true);
};
const handleFormSubmit = (data: Parameters<typeof addDelivery>[0]) => {
addDelivery(data);
setIsFormOpen(false);
if (data.date !== new Date().toLocaleDateString('ru-RU').split('.').join('-')) {
setSelectedDate(data.date);
setView('delivery-list');
const handleFormSubmit = async (data: Parameters<typeof addDelivery>[0]) => {
setIsSubmitting(true);
try {
await addDelivery(data);
setIsFormOpen(false);
// If created for different date, navigate to that date
const today = new Date().toLocaleDateString('ru-RU').split('.').join('-');
if (data.date !== today) {
setSelectedDate(data.date);
setView('delivery-list');
}
} catch {
// Error is handled by store
} finally {
setIsSubmitting(false);
}
};
@@ -76,6 +94,7 @@ function App() {
onClose={() => setIsFormOpen(false)}
onSubmit={handleFormSubmit}
defaultDate={formDate}
isSubmitting={isSubmitting}
/>
</div>
);

View File

@@ -0,0 +1,58 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
export class ApiError extends Error {
status: number;
details?: unknown;
constructor(message: string, status: number, details?: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.details = details;
}
}
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new ApiError(
errorData?.error || `HTTP ${response.status}`,
response.status,
errorData?.details
);
}
return response.json();
}
export const api = {
get: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, data: unknown) =>
fetchApi<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
}),
patch: <T>(endpoint: string, data?: unknown) =>
fetchApi<T>(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
}),
delete: <T>(endpoint: string) =>
fetchApi<T>(endpoint, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,148 @@
import { api } from './client';
import type { Delivery, PickupLocation, DeliveryStatus } from '../types';
// Types matching backend responses
interface BackendDelivery {
id: string;
date: string; // YYYY-MM-DD from pgtype.Date
pickup_location: PickupLocation;
product_name: string;
address: string;
phone: string;
additional_phone: string | null;
has_elevator: boolean;
comment: string;
status: DeliveryStatus;
created_at: string; // ISO timestamp
updated_at: string; // ISO timestamp
}
interface DeliveryCount {
date: string; // YYYY-MM-DD
count: number;
}
// API Response types
interface GetDeliveriesResponse {
deliveries: BackendDelivery[];
}
interface GetDeliveryResponse {
delivery: BackendDelivery;
}
interface GetDeliveryCountResponse {
counts: DeliveryCount[];
}
interface CreateDeliveryResponse {
message: string;
id: string;
}
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 {
id: backend.id,
date: backendDateToFrontend(backend.date),
pickupLocation: backend.pickup_location,
productName: backend.product_name,
address: backend.address,
phone: backend.phone,
additionalPhone: backend.additional_phone || undefined,
hasElevator: backend.has_elevator,
comment: backend.comment,
status: backend.status,
createdAt: new Date(backend.created_at).getTime(),
updatedAt: new Date(backend.updated_at).getTime(),
};
}
// Delivery API methods
export const deliveriesApi = {
// Get deliveries by date (DD-MM-YYYY)
getByDate: async (date: string): Promise<Delivery[]> => {
const response = await api.get<GetDeliveriesResponse>(
`/api/deliveries?date=${encodeURIComponent(date)}`
);
return response.deliveries.map(mapBackendToFrontend);
},
// Get single delivery by ID
getById: async (id: string): Promise<Delivery> => {
const response = await api.get<GetDeliveryResponse>(`/api/deliveries/${id}`);
return mapBackendToFrontend(response.delivery);
},
// Get delivery counts by date
getCounts: async (): Promise<Record<string, number>> => {
const response = await api.get<GetDeliveryCountResponse>('/api/deliveries/count');
const counts: Record<string, number> = {};
response.counts.forEach(({ date, count }) => {
counts[backendDateToFrontend(date)] = count;
});
return counts;
},
// Create delivery
create: async (
data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>
): Promise<string> => {
const payload = {
date: data.date,
pickup_location: data.pickupLocation,
product_name: data.productName,
address: data.address,
phone: data.phone,
additional_phone: data.additionalPhone || '',
has_elevator: data.hasElevator,
comment: data.comment,
};
const response = await api.post<CreateDeliveryResponse>('/api/deliveries', payload);
return response.id;
},
// Update delivery
update: async (
id: string,
data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>
): Promise<void> => {
const payload = {
date: data.date,
pickup_location: data.pickupLocation,
product_name: data.productName,
address: data.address,
phone: data.phone,
additional_phone: data.additionalPhone || '',
has_elevator: data.hasElevator,
comment: data.comment,
};
await api.patch<UpdateDeliveryResponse>(`/api/deliveries/${id}`, payload);
},
// Update delivery status
updateStatus: async (id: string, status: DeliveryStatus): Promise<void> => {
await api.patch(`/api/deliveries/${id}/status`, { status });
},
// Delete delivery
delete: async (id: string): Promise<void> => {
await api.delete(`/api/deliveries/${id}`);
},
};

View File

@@ -0,0 +1,2 @@
export { api, ApiError } from './client';
export { deliveriesApi, frontendDateToBackend } from './deliveries';

View File

@@ -6,9 +6,10 @@ import { pickupLocationLabels } from '../../types';
interface DeliveryFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => void;
onSubmit: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => void | Promise<void>;
initialData?: Delivery | null;
defaultDate?: string;
isSubmitting?: boolean;
}
const pickupOptions: { value: PickupLocation; label: string }[] = [
@@ -18,7 +19,7 @@ const pickupOptions: { value: PickupLocation; label: string }[] = [
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
];
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate }: DeliveryFormProps) => {
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate, isSubmitting }: DeliveryFormProps) => {
const [formData, setFormData] = useState({
date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'warehouse' as PickupLocation,
@@ -85,11 +86,11 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
title={initialData ? 'Редактировать доставку' : 'Новая доставка'}
footer={
<>
<Button variant="ghost" onClick={onClose}>
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
Отмена
</Button>
<Button type="submit" form="delivery-form">
{initialData ? 'Сохранить' : 'Создать'}
<Button type="submit" form="delivery-form" disabled={isSubmitting}>
{isSubmitting ? 'Сохранение...' : initialData ? 'Сохранить' : 'Создать'}
</Button>
</>
}

View File

@@ -2,17 +2,6 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { mockDeliveries } from './utils/mockData'
import { useDeliveryStore } from './stores/deliveryStore'
// Seed mock data if no data exists
const stored = localStorage.getItem('delivery-tracker-data')
if (!stored) {
const store = useDeliveryStore.getState()
mockDeliveries.forEach(delivery => {
store.addDelivery(delivery)
})
}
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useState, useEffect } 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 { Button } from '../components/ui/Button';
import { Card } from '../components/ui/Card';
@@ -12,21 +13,36 @@ interface DashboardProps {
}
export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
const deliveries = useDeliveryStore(state => state.deliveries);
const deliveryCounts = useDeliveryStore(state => state.deliveryCounts);
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
const [currentMonth, setCurrentMonth] = useState(new Date());
// Fetch counts on mount
useEffect(() => {
fetchDeliveryCounts();
}, [fetchDeliveryCounts]);
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const getCountForDate = (date: Date) => {
const dateStr = format(date, 'dd-MM-yyyy');
return deliveries.filter(d => d.date === dateStr).length;
return deliveryCounts[dateStr] || 0;
};
const handlePrintDay = (date: Date) => {
const dateStr = format(date, 'dd-MM-yyyy');
const dayDeliveries = deliveries.filter(d => d.date === dateStr);
const fetchDeliveriesByDate = useDeliveryStore.getState().fetchDeliveriesByDate;
// Fetch and print
fetchDeliveriesByDate(dateStr).then(() => {
const deliveries = useDeliveryStore.getState().deliveries;
printDeliveries(date, deliveries);
});
};
const printDeliveries = (date: Date, dayDeliveries: Delivery[]) => {
const printWindow = window.open('', '_blank');
if (!printWindow) return;
@@ -57,7 +73,7 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
<th>Телефон</th>
<th>Комментарий</th>
</tr>
${dayDeliveries.map(d => `
${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>
@@ -74,7 +90,7 @@ export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
printWindow.document.write(html);
printWindow.document.close();
printWindow.print();
printWindow?.print();
};
const navigateMonth = (direction: 'prev' | 'next') => {

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ArrowLeft, Filter } from 'lucide-react';
import { useState, useEffect } 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';
@@ -15,16 +15,26 @@ interface DeliveryListPageProps {
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);
// Fetch deliveries when date changes
useEffect(() => {
fetchDeliveriesByDate(selectedDate);
}, [selectedDate, fetchDeliveriesByDate]);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingDelivery, setEditingDelivery] = useState<Delivery | null>(null);
const [pickupFilter, setPickupFilter] = useState<PickupLocation | 'all'>('all');
const dayDeliveries = deliveries.filter(d => d.date === selectedDate);
// Use all deliveries from store (already filtered by API)
const dayDeliveries = deliveries;
const filteredDeliveries = pickupFilter === 'all'
? dayDeliveries
: dayDeliveries.filter(d => d.pickupLocation === pickupFilter);
@@ -37,8 +47,11 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
];
const handleStatusChange = (id: string) => {
toggleStatus(id);
const handleStatusChange = async (id: string) => {
const delivery = deliveries.find(d => d.id === id);
if (delivery) {
await toggleStatus(id, delivery.status);
}
};
const handleEdit = (delivery: Delivery) => {
@@ -46,19 +59,27 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
const handleDelete = async (id: string) => {
if (confirm('Удалить эту доставку?')) {
deleteDelivery(id);
try {
await deleteDelivery(id);
} catch {
// Error is handled by store
}
}
};
const handleSubmit = (data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => {
if (editingDelivery) {
updateDelivery(editingDelivery.id, data);
} else {
addDelivery(data);
const handleSubmit = async (data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => {
try {
if (editingDelivery) {
await updateDelivery(editingDelivery.id, data);
} else {
await addDelivery(data);
}
setEditingDelivery(null);
} catch {
// Error is handled by store
}
setEditingDelivery(null);
};
const handleAdd = () => {
@@ -94,14 +115,30 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
</div>
</div>
<DeliveryListComponent
deliveries={filteredDeliveries}
onStatusChange={handleStatusChange}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
date={selectedDate}
/>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-[#1B263B]" />
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-500" />
<div className="flex-1">
<p className="text-red-700">{error}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => { clearError(); fetchDeliveriesByDate(selectedDate); }}>
Повторить
</Button>
</div>
) : (
<DeliveryListComponent
deliveries={filteredDeliveries}
onStatusChange={handleStatusChange}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
date={selectedDate}
/>
)}
<DeliveryForm
isOpen={isFormOpen}

View File

@@ -1,83 +1,164 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Delivery } from '../types';
import { deliveriesApi } from '../api';
import type { Delivery, DeliveryStatus } from '../types';
interface DeliveryState {
// Data
deliveries: Delivery[];
addDelivery: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => void;
updateDelivery: (id: string, updates: Partial<Delivery>) => void;
deleteDelivery: (id: string) => void;
toggleStatus: (id: string) => void;
deliveryCounts: Record<string, number>;
// Loading states
isLoading: boolean;
isLoadingCounts: boolean;
error: string | null;
// Actions
fetchDeliveriesByDate: (date: string) => Promise<void>;
fetchDeliveryCounts: () => Promise<void>;
addDelivery: (delivery: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
updateDelivery: (id: string, updates: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
deleteDelivery: (id: string) => Promise<void>;
toggleStatus: (id: string, currentStatus: DeliveryStatus) => Promise<void>;
getDeliveriesByDate: (date: string) => Delivery[];
getDeliveriesByDateRange: (startDate: string, endDate: string) => Delivery[];
getDeliveryCountsByDate: () => Record<string, number>;
clearError: () => void;
}
const STORAGE_KEY = 'delivery-tracker-data';
export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
// Initial state
deliveries: [],
deliveryCounts: {},
isLoading: false,
isLoadingCounts: false,
error: null,
export const useDeliveryStore = create<DeliveryState>()(
persist(
(set, get) => ({
deliveries: [],
addDelivery: (delivery) => {
const now = Date.now();
const newDelivery: Delivery = {
...delivery,
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
};
set((state) => ({
deliveries: [...state.deliveries, newDelivery],
}));
},
updateDelivery: (id, updates) => {
set((state) => ({
deliveries: state.deliveries.map((d) =>
d.id === id ? { ...d, ...updates, updatedAt: Date.now() } : d
),
}));
},
deleteDelivery: (id) => {
set((state) => ({
deliveries: state.deliveries.filter((d) => d.id !== id),
}));
},
toggleStatus: (id) => {
set((state) => ({
deliveries: state.deliveries.map((d) =>
d.id === id
? { ...d, status: d.status === 'new' ? 'delivered' : 'new', updatedAt: Date.now() }
: d
),
}));
},
getDeliveriesByDate: (date) => {
return get().deliveries.filter((d) => d.date === date);
},
getDeliveriesByDateRange: (startDate, endDate) => {
return get().deliveries.filter((d) => {
const date = d.date;
return date >= startDate && date <= endDate;
});
},
getDeliveryCountsByDate: () => {
const counts: Record<string, number> = {};
get().deliveries.forEach((d) => {
counts[d.date] = (counts[d.date] || 0) + 1;
});
return counts;
},
}),
{
name: STORAGE_KEY,
// Fetch deliveries for a specific date
fetchDeliveriesByDate: async (date: string) => {
set({ isLoading: true, error: null });
try {
const deliveries = await deliveriesApi.getByDate(date);
set({ deliveries, isLoading: false });
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to fetch deliveries',
isLoading: false,
});
}
)
);
},
// Fetch delivery counts for calendar
fetchDeliveryCounts: async () => {
set({ isLoadingCounts: true, error: null });
try {
const counts = await deliveriesApi.getCounts();
set({ deliveryCounts: counts, isLoadingCounts: false });
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to fetch counts',
isLoadingCounts: false,
});
}
},
// Add new delivery
addDelivery: async (delivery) => {
set({ isLoading: true, error: null });
try {
await deliveriesApi.create(delivery);
// Refresh deliveries for that date
await get().fetchDeliveriesByDate(delivery.date);
// Refresh counts
await get().fetchDeliveryCounts();
set({ isLoading: false });
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to create delivery',
isLoading: false,
});
throw err;
}
},
// Update delivery
updateDelivery: async (id, updates) => {
set({ isLoading: true, error: null });
try {
await deliveriesApi.update(id, updates);
// Refresh deliveries for that date
await get().fetchDeliveriesByDate(updates.date);
// Refresh counts (in case date changed)
await get().fetchDeliveryCounts();
set({ isLoading: false });
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to update delivery',
isLoading: false,
});
throw err;
}
},
// Delete delivery
deleteDelivery: async (id) => {
set({ isLoading: true, error: null });
try {
await deliveriesApi.delete(id);
// Remove from local state
set((state) => ({
deliveries: state.deliveries.filter((d) => d.id !== id),
isLoading: false,
}));
// Refresh counts
await get().fetchDeliveryCounts();
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to delete delivery',
isLoading: false,
});
throw err;
}
},
// Toggle delivery status
toggleStatus: async (id, currentStatus) => {
set({ isLoading: true, error: null });
try {
const newStatus = currentStatus === 'new' ? 'delivered' : 'new';
await deliveriesApi.updateStatus(id, newStatus);
// Update local state
set((state) => ({
deliveries: state.deliveries.map((d) =>
d.id === id
? { ...d, status: newStatus, updatedAt: Date.now() }
: d
),
isLoading: false,
}));
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to update status',
isLoading: false,
});
throw err;
}
},
// Getters (local filtering)
getDeliveriesByDate: (date) => {
return get().deliveries.filter((d) => d.date === date);
},
getDeliveriesByDateRange: (startDate, endDate) => {
return get().deliveries.filter((d) => {
const date = d.date;
return date >= startDate && date <= endDate;
});
},
getDeliveryCountsByDate: () => {
return get().deliveryCounts;
},
clearError: () => set({ error: null }),
}));

View File

@@ -1,59 +0,0 @@
import type { Delivery } from '../types';
export const mockDeliveries: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>[] = [
{
date: new Date().toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'symbat',
productName: 'Диван прямой Милан',
address: 'ул. Ленина, д. 10, кв. 25',
phone: '+7 (771)-123-45-67',
additionalPhone: '',
hasElevator: true,
comment: 'Доставить после 18:00',
status: 'new',
},
{
date: new Date().toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'warehouse',
productName: 'Шкаф двухдверный',
address: 'ул. Гагарина, д. 5, офис 304',
phone: '+7 (777)-234-56-78',
additionalPhone: '+7 (702)-111-22-33',
hasElevator: false,
comment: 'Предварительно позвонить',
status: 'new',
},
{
date: new Date(Date.now() + 86400000).toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'nursaya',
productName: 'Стол обеденный + 4 стула',
address: 'пр. Мира, д. 15',
phone: '+7 (705)-345-67-89',
additionalPhone: '',
hasElevator: true,
comment: '',
status: 'new',
},
{
date: new Date(Date.now() - 86400000).toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'galaktika',
productName: 'Матрас ортопедический 160x200',
address: 'ул. Пушкина, д. 20',
phone: '+7 (701)-456-78-90',
additionalPhone: '',
hasElevator: false,
comment: 'Доставлено успешно',
status: 'delivered',
},
{
date: new Date(Date.now() + 172800000).toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'warehouse',
productName: 'Кресло реклайнер',
address: 'ул. Чехова, д. 8, кв. 12',
phone: '+7 (776)-567-89-01',
additionalPhone: '',
hasElevator: true,
comment: 'Подъезд с торца',
status: 'new',
},
];