diff --git a/src/admin/orders/KanbanBoard.jsx b/src/admin/orders/KanbanBoard.jsx new file mode 100644 index 0000000..6020651 --- /dev/null +++ b/src/admin/orders/KanbanBoard.jsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { DndContext, closestCorners, DragOverlay, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { useOutletContext } from 'react-router-dom'; +import { useOrders } from './useOrders'; +import { useAuth } from '../auth/useAuth'; +import KanbanColumn from './KanbanColumn'; +import OrderCard from './OrderCard'; +import OrderModal from './OrderModal'; + +const STATUSES = ['новая', 'замер', 'согласование', 'монтаж', 'закрыт']; + +const KanbanBoard = () => { + const { ordersByStatus, loading, updateStatus } = useOrders(); + const { session } = useAuth(); + const { showNewOrder, setShowNewOrder } = useOutletContext() || {}; + const [activeOrder, setActiveOrder] = useState(null); + const [selectedOrder, setSelectedOrder] = useState(null); + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); + + const findOrderStatus = (orderId) => { + for (const status of STATUSES) { + if (ordersByStatus[status]?.find((o) => o.id === orderId)) return status; + } + return null; + }; + + const handleDragStart = ({ active }) => { + const status = findOrderStatus(active.id); + if (status) setActiveOrder(ordersByStatus[status].find((o) => o.id === active.id)); + }; + + const handleDragEnd = async ({ active, over }) => { + setActiveOrder(null); + if (!over) return; + + const targetStatus = STATUSES.includes(over.id) ? over.id : findOrderStatus(over.id); + const sourceStatus = findOrderStatus(active.id); + + if (targetStatus && sourceStatus !== targetStatus) { + await updateStatus(active.id, targetStatus, session?.user?.email); + } + }; + + if (loading) { + return ( +
+ Загрузка заказов... +
+ ); + } + + return ( + <> + +
+ {STATUSES.map((status) => ( + + ))} +
+ + {activeOrder && {}} />} + +
+ + {selectedOrder && ( + setSelectedOrder(null)} + onSaved={() => setSelectedOrder(null)} + /> + )} + + {showNewOrder && ( + setShowNewOrder(false)} + onSaved={() => setShowNewOrder(false)} + /> + )} + + ); +}; + +export default KanbanBoard; diff --git a/src/admin/orders/KanbanColumn.jsx b/src/admin/orders/KanbanColumn.jsx new file mode 100644 index 0000000..16384c3 --- /dev/null +++ b/src/admin/orders/KanbanColumn.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import OrderCard from './OrderCard'; + +const STATUS_COLORS = { + 'новая': '#f59e0b', + 'замер': '#0ea5e9', + 'согласование': '#8b5cf6', + 'монтаж': '#f97316', + 'закрыт': '#22c55e', +}; + +const KanbanColumn = ({ status, orders, onCardClick }) => { + const { setNodeRef, isOver } = useDroppable({ id: status }); + + return ( +
+ {/* Header */} +
+ + {status} + + + {orders.length} + +
+ + {/* Drop zone */} +
+ o.id)} strategy={verticalListSortingStrategy}> + {orders.map((order) => ( + onCardClick(order)} /> + ))} + +
+
+ ); +}; + +export default KanbanColumn; diff --git a/src/admin/orders/OrderCard.jsx b/src/admin/orders/OrderCard.jsx new file mode 100644 index 0000000..7f3e571 --- /dev/null +++ b/src/admin/orders/OrderCard.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical } from 'lucide-react'; + +const OBJECT_TYPE_LABELS = { + industrial: 'Промышленный', + commercial: 'Коммерческий', + infrastructure: 'Инфраструктура', + residential: 'Частный', + other: 'Другое', +}; + +const OrderCard = ({ order, onClick }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: order.id, + }); + + return ( +
+
e.stopPropagation()} + > + +
+
+

+ {order.name} +

+
+ {order.object_type && ( + + {OBJECT_TYPE_LABELS[order.object_type] || order.object_type} + + )} + {order.area_sqm && ( + {order.area_sqm} м² + )} +
+ {(order.final_cost || order.estimated_cost) && ( +

+ {Number(order.final_cost || order.estimated_cost).toLocaleString('ru-RU')} ₽ +

+ )} +

+ {new Date(order.created_at).toLocaleDateString('ru-RU')} +

+
+
+ ); +}; + +export default OrderCard; diff --git a/src/admin/orders/OrderModal.jsx b/src/admin/orders/OrderModal.jsx new file mode 100644 index 0000000..47d6185 --- /dev/null +++ b/src/admin/orders/OrderModal.jsx @@ -0,0 +1,224 @@ +import React, { useState, useEffect } from 'react'; +import { X, Save } from 'lucide-react'; +import { supabase } from '../../lib/supabase'; +import { useAuth } from '../auth/useAuth'; + +const STATUSES = ['новая', 'замер', 'согласование', 'монтаж', 'закрыт']; +const OBJECT_TYPES = [ + { value: 'industrial', label: 'Промышленный объект' }, + { value: 'commercial', label: 'Коммерческая штаб-квартира' }, + { value: 'infrastructure', label: 'Критическая инфраструктура' }, + { value: 'residential', label: 'Частная резиденция' }, + { value: 'other', label: 'Другое' }, +]; + +const inputStyle = { + width: '100%', padding: '10px 12px', + background: 'var(--bg-lighter)', + border: '1.5px solid var(--border-light)', + borderRadius: '6px', + color: 'var(--text-main)', fontSize: '0.92rem', + outline: 'none', fontFamily: 'var(--font-body)', + boxSizing: 'border-box', +}; + +const labelStyle = { + display: 'block', marginBottom: '5px', + fontSize: '0.75rem', fontWeight: 700, + color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', +}; + +const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => { + const { session } = useAuth(); + const [form, setForm] = useState({ + name: order?.name || '', + phone: order?.phone || '', + object_type: order?.object_type || '', + area_sqm: order?.area_sqm ?? '', + film_thickness: order?.film_thickness ?? 200, + estimated_cost: order?.estimated_cost ?? '', + final_cost: order?.final_cost ?? '', + notes: order?.notes || '', + assigned_to: order?.assigned_to || '', + status: order?.status || 'новая', + }); + const [events, setEvents] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (order?.id) { + supabase + .from('order_events') + .select('*') + .eq('order_id', order.id) + .order('created_at', { ascending: false }) + .then(({ data }) => setEvents(data || [])); + } + }, [order?.id]); + + const set = (e) => setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); + + const handleSave = async () => { + if (!form.name.trim() || !form.phone.trim()) { + setError('Имя и телефон обязательны'); + return; + } + setSaving(true); + setError(''); + const userEmail = session?.user?.email; + + if (mode === 'create') { + const { error } = await supabase + .from('orders') + .insert({ ...form, source: 'manual' }); + if (error) { setError(error.message); setSaving(false); return; } + } else { + const statusChanged = form.status !== order.status; + const updates = { ...form }; + if (statusChanged && form.status === 'закрыт') updates.closed_at = new Date().toISOString(); + + const { error } = await supabase.from('orders').update(updates).eq('id', order.id); + if (error) { setError(error.message); setSaving(false); return; } + + const { error: evtErr } = await supabase.from('order_events').insert({ + order_id: order.id, + created_by: userEmail, + event_type: statusChanged ? 'status_change' : 'edit', + description: statusChanged + ? `Статус изменён: ${order.status} → ${form.status}` + : 'Данные заказа обновлены', + }); + if (evtErr) console.error('Failed to log order event:', evtErr); + } + setSaving(false); + onSaved?.(); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ {mode === 'create' ? 'Новая заявка' : order?.name} +

+ +
+ + {/* Fields */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +