Compare commits
2 Commits
b36a6fb262
...
0540218332
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0540218332 | ||
|
|
7f410e814b |
@@ -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"})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=http://localhost:8080
|
||||
@@ -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,14 +38,23 @@ function App() {
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleFormSubmit = (data: Parameters<typeof addDelivery>[0]) => {
|
||||
addDelivery(data);
|
||||
const handleFormSubmit = async (data: Parameters<typeof addDelivery>[0]) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await addDelivery(data);
|
||||
setIsFormOpen(false);
|
||||
|
||||
if (data.date !== new Date().toLocaleDateString('ru-RU').split('.').join('-')) {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -76,6 +94,7 @@ function App() {
|
||||
onClose={() => setIsFormOpen(false)}
|
||||
onSubmit={handleFormSubmit}
|
||||
defaultDate={formDate}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
58
frontend/src/api/client.ts
Normal file
58
frontend/src/api/client.ts
Normal 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' }),
|
||||
};
|
||||
148
frontend/src/api/deliveries.ts
Normal file
148
frontend/src/api/deliveries.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
2
frontend/src/api/index.ts
Normal file
2
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { api, ApiError } from './client';
|
||||
export { deliveriesApi, frontendDateToBackend } from './deliveries';
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
@@ -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'>) => {
|
||||
const handleSubmit = async (data: Omit<Delivery, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
try {
|
||||
if (editingDelivery) {
|
||||
updateDelivery(editingDelivery.id, data);
|
||||
await updateDelivery(editingDelivery.id, data);
|
||||
} else {
|
||||
addDelivery(data);
|
||||
await addDelivery(data);
|
||||
}
|
||||
setEditingDelivery(null);
|
||||
} catch {
|
||||
// Error is handled by store
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -94,6 +115,21 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
@@ -102,6 +138,7 @@ export const DeliveryListPage = ({ selectedDate, onBack }: DeliveryListPageProps
|
||||
onAdd={handleAdd}
|
||||
date={selectedDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeliveryForm
|
||||
isOpen={isFormOpen}
|
||||
|
||||
@@ -1,62 +1,150 @@
|
||||
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>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
|
||||
// Initial state
|
||||
deliveries: [],
|
||||
deliveryCounts: {},
|
||||
isLoading: false,
|
||||
isLoadingCounts: false,
|
||||
error: null,
|
||||
|
||||
addDelivery: (delivery) => {
|
||||
const now = Date.now();
|
||||
const newDelivery: Delivery = {
|
||||
...delivery,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
set((state) => ({
|
||||
deliveries: [...state.deliveries, newDelivery],
|
||||
}));
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateDelivery: (id, updates) => {
|
||||
set((state) => ({
|
||||
deliveries: state.deliveries.map((d) =>
|
||||
d.id === id ? { ...d, ...updates, updatedAt: Date.now() } : d
|
||||
),
|
||||
}));
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteDelivery: (id) => {
|
||||
// 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;
|
||||
}
|
||||
},
|
||||
|
||||
toggleStatus: (id) => {
|
||||
// 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: d.status === 'new' ? 'delivered' : 'new', updatedAt: Date.now() }
|
||||
? { ...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);
|
||||
},
|
||||
@@ -69,15 +157,8 @@ export const useDeliveryStore = create<DeliveryState>()(
|
||||
},
|
||||
|
||||
getDeliveryCountsByDate: () => {
|
||||
const counts: Record<string, number> = {};
|
||||
get().deliveries.forEach((d) => {
|
||||
counts[d.date] = (counts[d.date] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
return get().deliveryCounts;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user