commit 11e12f964d9053d8750faef7db8af650004a5820 Author: Egor Pozharov Date: Mon Mar 30 17:58:06 2026 +0600 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f6baf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Environment files +.env +.env.local +.env.*.local + +# Testing +coverage +.nyc_output + +# Cache +.cache +.temp +.tmp +*.tsbuildinfo + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..26a95b3 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Delivery Tracker + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0b79870 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "delivery-tracker", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.2.2", + "date-fns": "^4.1.0", + "lucide-react": "^1.7.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.2", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..92f241e --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Delivery Tracker", + "short_name": "Deliveries", + "description": "Система контроля доставок мебели", + "start_url": "/", + "display": "standalone", + "background_color": "#fbf8fb", + "theme_color": "#1B263B", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..55722f7 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { Truck } from 'lucide-react'; +import { Dashboard } from './pages/Dashboard'; +import { DeliveryListPage } from './pages/DeliveryListPage'; +import { DeliveryForm } from './components/delivery/DeliveryForm'; +import { useDeliveryStore } from './stores/deliveryStore'; + +function App() { + const [view, setView] = useState<'dashboard' | 'delivery-list'>('dashboard'); + const [selectedDate, setSelectedDate] = useState(''); + const [isFormOpen, setIsFormOpen] = useState(false); + const [formDate, setFormDate] = useState(''); + + const addDelivery = useDeliveryStore(state => state.addDelivery); + + const handleDateSelect = (date: string) => { + setSelectedDate(date); + setView('delivery-list'); + }; + + const handleBackToDashboard = () => { + setView('dashboard'); + setSelectedDate(''); + }; + + const handleAddDelivery = () => { + const today = new Date().toLocaleDateString('ru-RU').split('.').join('-'); + setFormDate(today); + setIsFormOpen(true); + }; + + const handleFormSubmit = (data: Parameters[0]) => { + addDelivery(data); + setIsFormOpen(false); + + if (data.date !== new Date().toLocaleDateString('ru-RU').split('.').join('-')) { + setSelectedDate(data.date); + setView('delivery-list'); + } + }; + + return ( +
+
+
+
+
+
+ +
+

Delivery Tracker

+
+
+ {view === 'dashboard' ? 'Панель управления' : `Доставки на ${selectedDate}`} +
+
+
+
+ +
+ {view === 'dashboard' ? ( + + ) : ( + + )} +
+ + setIsFormOpen(false)} + onSubmit={handleFormSubmit} + defaultDate={formDate} + /> +
+ ); +} + +export default App; diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/components/delivery/DeliveryCard.tsx b/src/components/delivery/DeliveryCard.tsx new file mode 100644 index 0000000..4783dd4 --- /dev/null +++ b/src/components/delivery/DeliveryCard.tsx @@ -0,0 +1,135 @@ +import { MapPin, Phone, Package, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare } from 'lucide-react'; +import type { Delivery } from '../../types'; +import { pickupLocationLabels } from '../../types'; +import { StatusBadge } from './StatusBadge'; +import { Card } from '../ui/Card'; + +interface DeliveryCardProps { + delivery: Delivery; + onStatusChange: (id: string) => void; + onEdit: (delivery: Delivery) => void; + onDelete: (id: string) => void; +} + +export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => { + const handleAddressClick = () => { + const encodedAddress = encodeURIComponent(delivery.address); + window.open(`https://maps.google.com/?q=${encodedAddress}`, '_blank'); + }; + + const handlePhoneClick = () => { + window.location.href = `tel:${delivery.phone}`; + }; + + return ( + +
+
+ onStatusChange(delivery.id)} + size="md" + /> +
+
+ + +
+
+ +
+
+ + {delivery.date} +
+ +
+ + {pickupLocationLabels[delivery.pickupLocation]} +
+ +
+ + {delivery.productName} +
+ + + + + + {delivery.additionalPhone && ( + + )} + +
+ + + {delivery.hasElevator ? 'Есть лифт' : 'Нет лифта'} + +
+ + {delivery.comment && ( +
+ + {delivery.comment} +
+ )} +
+ +
+ +
+
+ ); +}; diff --git a/src/components/delivery/DeliveryForm.tsx b/src/components/delivery/DeliveryForm.tsx new file mode 100644 index 0000000..f0ed8c9 --- /dev/null +++ b/src/components/delivery/DeliveryForm.tsx @@ -0,0 +1,183 @@ +import { useState, useEffect } from 'react'; +import { Button, Input, Select, Modal } from '../ui'; +import type { Delivery, PickupLocation, DeliveryStatus } from '../../types'; +import { pickupLocationLabels } from '../../types'; + +interface DeliveryFormProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (delivery: Omit) => void; + initialData?: Delivery | null; + defaultDate?: string; +} + +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 DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate }: DeliveryFormProps) => { + const [formData, setFormData] = useState({ + date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'), + pickupLocation: 'warehouse' as PickupLocation, + productName: '', + address: '', + phone: '', + additionalPhone: '', + hasElevator: false, + comment: '', + status: 'new' as DeliveryStatus, + }); + + useEffect(() => { + if (initialData) { + setFormData({ + date: initialData.date, + pickupLocation: initialData.pickupLocation, + productName: initialData.productName, + address: initialData.address, + phone: initialData.phone, + additionalPhone: initialData.additionalPhone || '', + hasElevator: initialData.hasElevator, + comment: initialData.comment, + status: initialData.status, + }); + } else if (defaultDate) { + setFormData(prev => ({ ...prev, date: defaultDate })); + } + }, [initialData, defaultDate, isOpen]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData); + if (!initialData) { + setFormData({ + date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'), + pickupLocation: 'warehouse', + productName: '', + address: '', + phone: '', + additionalPhone: '', + hasElevator: false, + comment: '', + status: 'new', + }); + } + onClose(); + }; + + 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 ( + + + + + } + > +
+
+ + setFormData({ ...formData, date: formatDateFromInput(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 + /> +
+ + setFormData({ ...formData, productName: e.target.value })} + placeholder="Введите название товара" + required + /> + + setFormData({ ...formData, address: e.target.value })} + placeholder="ул. Примерная, д. 1" + required + /> + + setFormData({ ...formData, phone: e.target.value })} + onFocus={(e) => { + if (!e.target.value) { + setFormData({ ...formData, phone: '+7' }); + } + }} + placeholder="+7 (776)-567-89-01" + required + /> + + setFormData({ ...formData, additionalPhone: e.target.value })} + onFocus={(e) => { + if (!e.target.value) { + setFormData({ ...formData, additionalPhone: '+7' }); + } + }} + placeholder="+7 (776)-567-89-01" + /> + +
+ setFormData({ ...formData, hasElevator: e.target.checked })} + className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]" + /> + +
+ + setFormData({ ...formData, comment: e.target.value })} + placeholder="Дополнительная информация..." + /> +
+
+ ); +}; diff --git a/src/components/delivery/DeliveryList.tsx b/src/components/delivery/DeliveryList.tsx new file mode 100644 index 0000000..0c669f5 --- /dev/null +++ b/src/components/delivery/DeliveryList.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { Plus, LayoutGrid, Table as TableIcon } from 'lucide-react'; +import { DeliveryCard } from './DeliveryCard'; +import { DeliveryRow } from './DeliveryRow'; +import { Button } from '../ui/Button'; +import type { Delivery } from '../../types'; + +interface DeliveryListProps { + deliveries: Delivery[]; + onStatusChange: (id: string) => void; + onEdit: (delivery: Delivery) => void; + onDelete: (id: string) => void; + onAdd: () => void; + date: string; +} + +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'); + + return ( +
+
+

+ Доставки на {date} +

+
+
+ + +
+ +
+
+ + {deliveries.length === 0 ? ( +
+

Нет доставок на эту дату

+ +
+ ) : viewMode === 'kanban' ? ( +
+
+
+

+ + Новые + ({newDeliveries.length}) +

+
+
+ {newDeliveries.map(delivery => ( + + ))} +
+
+ +
+
+

+ + Доставлено + ({deliveredDeliveries.length}) +

+
+
+ {deliveredDeliveries.map(delivery => ( + + ))} +
+
+
+ ) : ( +
+ + + + + + + + + + + + + + + {deliveries.map(delivery => ( + + ))} + +
СтатусДатаЗагрузкаТоварАдресТелефонКомментарийДействия
+
+ )} +
+ ); +}; diff --git a/src/components/delivery/DeliveryRow.tsx b/src/components/delivery/DeliveryRow.tsx new file mode 100644 index 0000000..263920c --- /dev/null +++ b/src/components/delivery/DeliveryRow.tsx @@ -0,0 +1,78 @@ +import { MapPin, Phone } from 'lucide-react'; +import type { Delivery } from '../../types'; +import { pickupLocationLabels } from '../../types'; +import { StatusBadge } from './StatusBadge'; + +interface DeliveryRowProps { + delivery: Delivery; + onStatusChange: (id: string) => void; + onEdit: (delivery: Delivery) => void; + onDelete: (id: string) => void; +} + +export const DeliveryRow = ({ delivery, onStatusChange, onEdit, onDelete }: DeliveryRowProps) => { + const handleAddressClick = (e: React.MouseEvent) => { + e.stopPropagation(); + const encodedAddress = encodeURIComponent(delivery.address); + window.open(`https://maps.google.com/?q=${encodedAddress}`, '_blank'); + }; + + const handlePhoneClick = (e: React.MouseEvent) => { + e.stopPropagation(); + window.location.href = `tel:${delivery.phone}`; + }; + + return ( + + + onStatusChange(delivery.id)} + size="sm" + /> + + {delivery.date} + {pickupLocationLabels[delivery.pickupLocation]} + {delivery.productName} + + + + + + + + {delivery.comment || '-'} + + +
+ + +
+ + + ); +}; diff --git a/src/components/delivery/StatusBadge.tsx b/src/components/delivery/StatusBadge.tsx new file mode 100644 index 0000000..7bab109 --- /dev/null +++ b/src/components/delivery/StatusBadge.tsx @@ -0,0 +1,34 @@ +import type { DeliveryStatus } from '../../types'; +import { statusLabels } from '../../types'; + +interface StatusBadgeProps { + status: DeliveryStatus; + onClick?: () => void; + size?: 'sm' | 'md' | 'lg'; +} + +export const StatusBadge = ({ status, onClick, size = 'md' }: StatusBadgeProps) => { + const baseStyles = 'inline-flex items-center justify-center font-medium rounded-full transition-colors'; + + const variants = { + new: 'bg-[#ffdcc3] text-[#6e3900]', + delivered: 'bg-[#dcfce7] text-[#166534]', + }; + + const sizes = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-3 py-1 text-sm', + lg: 'px-4 py-2 text-base', + }; + + const clickableStyles = onClick ? 'cursor-pointer hover:opacity-80 active:scale-95' : ''; + + return ( + + {statusLabels[status]} + + ); +}; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..9227f80 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,39 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'tertiary' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + children: ReactNode; +} + +export const Button = ({ + variant = 'primary', + size = 'md', + children, + className = '', + ...props +}: ButtonProps) => { + const baseStyles = 'inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'; + + const variants = { + primary: 'bg-[#1B263B] text-white hover:bg-[#2a3a52] focus:ring-[#1B263B]', + secondary: 'bg-[#f0edef] text-[#1b1b1d] hover:bg-[#e4e2e4] focus:ring-[#75777d]', + tertiary: 'bg-[#F28C28] text-white hover:bg-[#d97a1f] focus:ring-[#F28C28]', + ghost: 'bg-transparent text-[#1b1b1d] hover:bg-[#f5f3f5] focus:ring-[#75777d]', + }; + + const sizes = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg', + }; + + return ( + + ); +}; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..876eb8f --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react'; + +interface CardProps { + children: ReactNode; + className?: string; + padding?: 'none' | 'sm' | 'md' | 'lg'; +} + +export const Card = ({ children, className = '', padding = 'md' }: CardProps) => { + const paddings = { + none: '', + sm: 'p-3', + md: 'p-4', + lg: 'p-6', + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 0000000..3741236 --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,25 @@ +import type { InputHTMLAttributes } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; +} + +export const Input = ({ label, error, className = '', ...props }: InputProps) => { + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ); +}; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..d36a194 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,44 @@ +import { X } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + footer?: ReactNode; +} + +export const Modal = ({ isOpen, onClose, title, children, footer }: ModalProps) => { + if (!isOpen) return null; + + return ( +
+
+
+
+
+

{title}

+ +
+
+ {children} +
+ {footer && ( +
+ {footer} +
+ )} +
+
+
+ ); +}; diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx new file mode 100644 index 0000000..229a31c --- /dev/null +++ b/src/components/ui/Select.tsx @@ -0,0 +1,37 @@ +import type { SelectHTMLAttributes } from 'react'; + +interface SelectOption { + value: string; + label: string; +} + +interface SelectProps extends SelectHTMLAttributes { + label?: string; + options: SelectOption[]; + error?: string; +} + +export const Select = ({ label, options, error, className = '', ...props }: SelectProps) => { + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ); +}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..de5c37e --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,5 @@ +export { Button } from './Button'; +export { Card } from './Card'; +export { Modal } from './Modal'; +export { Input } from './Input'; +export { Select } from './Select'; diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..f772785 --- /dev/null +++ b/src/hooks/useWebSocket.ts @@ -0,0 +1,83 @@ +import { useEffect, useCallback } from 'react'; +import { useDeliveryStore } from '../stores/deliveryStore'; +import type { Delivery } from '../types'; + +type WebSocketEvent = + | { type: 'delivery.created'; payload: Delivery } + | { type: 'delivery.updated'; payload: Delivery } + | { type: 'delivery.deleted'; payload: { id: string } }; + +type EventHandler = (event: WebSocketEvent) => void; + +class MockWebSocket { + private handlers: EventHandler[] = []; + private isConnected = false; + + connect() { + this.isConnected = true; + console.log('WebSocket connected (mock)'); + } + + disconnect() { + this.isConnected = false; + console.log('WebSocket disconnected (mock)'); + } + + subscribe(handler: EventHandler) { + this.handlers.push(handler); + return () => { + this.handlers = this.handlers.filter((h) => h !== handler); + }; + } + + emit(event: WebSocketEvent) { + if (!this.isConnected) return; + this.handlers.forEach((handler) => handler(event)); + } + + simulateIncomingEvent(event: WebSocketEvent) { + this.emit(event); + } +} + +const mockWebSocket = new MockWebSocket(); + +export const useWebSocket = () => { + const { addDelivery, updateDelivery, deleteDelivery } = useDeliveryStore(); + + useEffect(() => { + mockWebSocket.connect(); + + const unsubscribe = mockWebSocket.subscribe((event) => { + switch (event.type) { + case 'delivery.created': + addDelivery(event.payload); + break; + case 'delivery.updated': + updateDelivery(event.payload.id, event.payload); + break; + case 'delivery.deleted': + deleteDelivery(event.payload.id); + break; + } + }); + + return () => { + unsubscribe(); + mockWebSocket.disconnect(); + }; + }, [addDelivery, updateDelivery, deleteDelivery]); + + const sendEvent = useCallback((event: WebSocketEvent) => { + mockWebSocket.emit(event); + }, []); + + return { sendEvent, isConnected: true }; +}; + +export const simulateIncomingDelivery = (delivery: Delivery) => { + mockWebSocket.simulateIncomingEvent({ + type: 'delivery.created', + payload: delivery, + }); +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..c0a4ae5 --- /dev/null +++ b/src/index.css @@ -0,0 +1,34 @@ +@import "tailwindcss"; + +@theme { + --color-primary: #1B263B; + --color-primary-container: #051125; + --color-tertiary: #F28C28; + --color-surface: #fbf8fb; + --color-surface-container: #f0edef; + --color-surface-container-low: #f5f3f5; + --color-surface-container-high: #eae7e9; + --color-on-surface: #1b1b1d; + --color-outline: #75777d; + --color-outline-variant: #c5c6cd; + --color-success: #16a34a; + --font-sans: 'Inter', system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: var(--font-sans); + background-color: var(--color-surface); + color: var(--color-on-surface); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + min-height: 100vh; + width: 100%; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..2d3facb --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,21 @@ +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( + + + , +) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..793bc7b --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,220 @@ +import { useState } 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 { Button } from '../components/ui/Button'; +import { Card } from '../components/ui/Card'; + +interface DashboardProps { + onDateSelect: (date: string) => void; + onAddDelivery: () => void; +} + +export const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => { + const deliveries = useDeliveryStore(state => state.deliveries); + const [currentMonth, setCurrentMonth] = useState(new Date()); + + 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; + }; + + const handlePrintDay = (date: Date) => { + const dateStr = format(date, 'dd-MM-yyyy'); + const dayDeliveries = deliveries.filter(d => d.date === dateStr); + + const printWindow = window.open('', '_blank'); + if (!printWindow) return; + + const html = ` + + + + Доставки на ${format(date, 'dd MMMM yyyy', { locale: ru })} + + + +

Доставки на ${format(date, 'dd MMMM yyyy', { locale: ru })}

+ + + + + + + + + + ${dayDeliveries.map(d => ` + + + + + + + + + `).join('')} +
СтатусЗагрузкаТоварАдресТелефонКомментарий
${d.status === 'new' ? 'Новое' : 'Доставлено'}${d.pickupLocation === 'warehouse' ? 'Склад' : d.pickupLocation === 'symbat' ? 'Сымбат' : d.pickupLocation === 'nursaya' ? 'Нурсая' : 'Галактика'}${d.productName}${d.address}${d.phone}${d.comment || '-'}
+ + + `; + + printWindow.document.write(html); + printWindow.document.close(); + printWindow.print(); + }; + + const navigateMonth = (direction: 'prev' | 'next') => { + setCurrentMonth(prev => { + const newDate = new Date(prev); + newDate.setMonth(prev.getMonth() + (direction === 'next' ? 1 : -1)); + return newDate; + }); + }; + + return ( +
+
+
+

Панель управления

+

Выберите дату для просмотра доставок

+
+ +
+ + +
+

+ + {format(currentMonth, 'MMMM yyyy', { locale: ru })} +

+
+ + + +
+
+ +
+ {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => ( +
+ {day} +
+ ))} +
+ +
+ {days.map((day) => { + const count = getCountForDate(day); + const isTodayDate = isToday(day); + + return ( + + ); + })} +
+
+ +
+

Ближайшие даты с доставками

+ {days + .filter(day => getCountForDate(day) > 0) + .slice(0, 7) + .map(day => { + const count = getCountForDate(day); + return ( + +
+
+
+ {format(day, 'd')} +
+
+ {format(day, 'MMM', { locale: ru })} +
+
+
+
+ {count} {count === 1 ? 'доставка' : count < 5 ? 'доставки' : 'доставок'} +
+
+ {format(day, 'EEEE', { locale: ru })} +
+
+
+
+ + +
+
+ ); + })} + + {days.filter(day => getCountForDate(day) > 0).length === 0 && ( + +

Нет запланированных доставок

+ +
+ )} +
+
+ ); +}; diff --git a/src/pages/DeliveryListPage.tsx b/src/pages/DeliveryListPage.tsx new file mode 100644 index 0000000..cf2af1a --- /dev/null +++ b/src/pages/DeliveryListPage.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { ArrowLeft, Filter } 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 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 toggleStatus = useDeliveryStore(state => state.toggleStatus); + const deleteDelivery = useDeliveryStore(state => state.deleteDelivery); + const updateDelivery = useDeliveryStore(state => state.updateDelivery); + const addDelivery = useDeliveryStore(state => state.addDelivery); + + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingDelivery, setEditingDelivery] = useState(null); + const [pickupFilter, setPickupFilter] = useState('all'); + + const dayDeliveries = deliveries.filter(d => d.date === selectedDate); + 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 handleStatusChange = (id: string) => { + toggleStatus(id); + }; + + const handleEdit = (delivery: Delivery) => { + setEditingDelivery(delivery); + setIsFormOpen(true); + }; + + const handleDelete = (id: string) => { + if (confirm('Удалить эту доставку?')) { + deleteDelivery(id); + } + }; + + const handleSubmit = (data: Omit) => { + if (editingDelivery) { + updateDelivery(editingDelivery.id, data); + } else { + addDelivery(data); + } + setEditingDelivery(null); + }; + + const handleAdd = () => { + setEditingDelivery(null); + setIsFormOpen(true); + }; + + const handleCloseForm = () => { + setIsFormOpen(false); + setEditingDelivery(null); + }; + + return ( +
+
+
+ +
+ + Всего: {filteredDeliveries.length} +
+
+
+