feat: add Kanban board with drag-and-drop, OrderModal create/edit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)', fontFamily: 'var(--font-heading)' }}>
|
||||
Загрузка заказов...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div style={{ display: 'flex', gap: '14px', overflowX: 'auto', paddingBottom: '20px', alignItems: 'flex-start' }}>
|
||||
{STATUSES.map((status) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
status={status}
|
||||
orders={ordersByStatus[status] || []}
|
||||
onCardClick={setSelectedOrder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeOrder && <OrderCard order={activeOrder} onClick={() => {}} />}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{selectedOrder && (
|
||||
<OrderModal
|
||||
order={selectedOrder}
|
||||
mode="edit"
|
||||
onClose={() => setSelectedOrder(null)}
|
||||
onSaved={() => setSelectedOrder(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showNewOrder && (
|
||||
<OrderModal
|
||||
order={null}
|
||||
mode="create"
|
||||
onClose={() => setShowNewOrder(false)}
|
||||
onSaved={() => setShowNewOrder(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default KanbanBoard;
|
||||
@@ -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 (
|
||||
<div style={{ flex: '1 1 200px', minWidth: '215px', maxWidth: '270px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '10px 13px',
|
||||
background: '#fff', borderRadius: '10px',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderTop: `3px solid ${STATUS_COLORS[status]}`,
|
||||
}}>
|
||||
<span style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.85rem', flex: 1, textTransform: 'capitalize' }}>
|
||||
{status}
|
||||
</span>
|
||||
<span style={{
|
||||
background: STATUS_COLORS[status], color: '#fff',
|
||||
borderRadius: '50px', padding: '1px 8px',
|
||||
fontSize: '0.72rem', fontWeight: 700,
|
||||
}}>
|
||||
{orders.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
flex: 1, minHeight: '180px',
|
||||
display: 'flex', flexDirection: 'column', gap: '8px',
|
||||
padding: '6px',
|
||||
borderRadius: '10px',
|
||||
background: isOver ? 'rgba(14, 165, 233, 0.05)' : 'transparent',
|
||||
border: isOver ? '2px dashed rgba(14, 165, 233, 0.3)' : '2px dashed transparent',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<SortableContext items={orders.map((o) => o.id)} strategy={verticalListSortingStrategy}>
|
||||
{orders.map((order) => (
|
||||
<OrderCard key={order.id} order={order} onClick={() => onCardClick(order)} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KanbanColumn;
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.45 : 1,
|
||||
background: '#fff',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: '10px',
|
||||
padding: '13px',
|
||||
cursor: 'pointer',
|
||||
boxShadow: isDragging ? '0 12px 32px rgba(0,0,0,0.14)' : '0 1px 6px rgba(0,0,0,0.04)',
|
||||
display: 'flex',
|
||||
gap: '9px',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ color: 'var(--text-muted)', cursor: 'grab', paddingTop: '2px', flexShrink: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical size={15} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{
|
||||
fontWeight: 700, fontFamily: 'var(--font-heading)',
|
||||
fontSize: '0.9rem', marginBottom: '6px',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{order.name}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center', marginBottom: '6px' }}>
|
||||
{order.object_type && (
|
||||
<span style={{
|
||||
fontSize: '0.72rem',
|
||||
background: 'rgba(14, 165, 233, 0.08)',
|
||||
color: 'var(--accent-blue)',
|
||||
padding: '2px 7px', borderRadius: '50px', fontWeight: 600,
|
||||
}}>
|
||||
{OBJECT_TYPE_LABELS[order.object_type] || order.object_type}
|
||||
</span>
|
||||
)}
|
||||
{order.area_sqm && (
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>{order.area_sqm} м²</span>
|
||||
)}
|
||||
</div>
|
||||
{(order.final_cost || order.estimated_cost) && (
|
||||
<p style={{ fontSize: '0.82rem', fontWeight: 700, color: 'var(--accent-orange)' }}>
|
||||
{Number(order.final_cost || order.estimated_cost).toLocaleString('ru-RU')} ₽
|
||||
</p>
|
||||
)}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '5px' }}>
|
||||
{new Date(order.created_at).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderCard;
|
||||
@@ -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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={mode === 'create' ? 'Новая заявка' : `Заявка: ${order?.name}`}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 1000, backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass-panel"
|
||||
style={{ width: '100%', maxWidth: '620px', maxHeight: '90vh', overflow: 'auto', padding: '30px', margin: '20px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '22px' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.2rem', fontWeight: 800 }}>
|
||||
{mode === 'create' ? 'Новая заявка' : order?.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||
<div>
|
||||
<label htmlFor="modal-name" style={labelStyle}>ФИО / Организация *</label>
|
||||
<input id="modal-name" name="name" value={form.name} onChange={set} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-phone" style={labelStyle}>Телефон *</label>
|
||||
<input id="modal-phone" name="phone" value={form.phone} onChange={set} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-object-type" style={labelStyle}>Тип объекта</label>
|
||||
<select id="modal-object-type" name="object_type" value={form.object_type} onChange={set} style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
<option value="">Не выбрано</option>
|
||||
{OBJECT_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-status" style={labelStyle}>Статус</label>
|
||||
<select id="modal-status" name="status" value={form.status} onChange={set} style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-area" style={labelStyle}>Площадь (м²)</label>
|
||||
<input id="modal-area" type="number" name="area_sqm" value={form.area_sqm} onChange={set} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-thickness" style={labelStyle}>Толщина плёнки</label>
|
||||
<select id="modal-thickness" name="film_thickness" value={form.film_thickness} onChange={set} style={{ ...inputStyle, cursor: 'pointer' }}>
|
||||
<option value={200}>200 микрон</option>
|
||||
<option value={300}>300 микрон</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-estimated" style={labelStyle}>Ориентировочная стоимость (₽)</label>
|
||||
<input id="modal-estimated" type="number" name="estimated_cost" value={form.estimated_cost} onChange={set} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-final" style={labelStyle}>Итоговая стоимость (₽)</label>
|
||||
<input id="modal-final" type="number" name="final_cost" value={form.final_cost} onChange={set} style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="modal-assigned" style={labelStyle}>Ответственный (email)</label>
|
||||
<input id="modal-assigned" name="assigned_to" value={form.assigned_to} onChange={set} style={inputStyle} placeholder="manager@company.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '14px' }}>
|
||||
<label htmlFor="modal-notes" style={labelStyle}>Заметки</label>
|
||||
<textarea id="modal-notes" name="notes" value={form.notes} onChange={set} style={{ ...inputStyle, height: '72px', resize: 'vertical' }} />
|
||||
</div>
|
||||
|
||||
{error && <p role="alert" style={{ color: '#ef4444', fontSize: '0.85rem', marginTop: '10px' }}>{error}</p>}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '20px' }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', opacity: saving ? 0.7 : 1, cursor: saving ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<Save size={15} />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Event log */}
|
||||
{mode === 'edit' && events.length > 0 && (
|
||||
<div style={{ marginTop: '28px', borderTop: '1px solid var(--border-light)', paddingTop: '20px' }}>
|
||||
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.9rem', fontWeight: 700, marginBottom: '12px' }}>
|
||||
История изменений
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{events.map((e) => (
|
||||
<div key={e.id} style={{ fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', gap: '10px', alignItems: 'baseline' }}>
|
||||
<span style={{ color: 'var(--accent-blue)', fontWeight: 600, flexShrink: 0 }}>
|
||||
{new Date(e.created_at).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>{e.description}</span>
|
||||
{e.created_by && (
|
||||
<span style={{ opacity: 0.55, flexShrink: 0, fontSize: '0.72rem' }}>{e.created_by}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderModal;
|
||||
Reference in New Issue
Block a user