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:
2026-05-07 20:25:28 +03:00
parent 34a2f10b67
commit 7089737f76
4 changed files with 460 additions and 0 deletions
+92
View File
@@ -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;
+62
View File
@@ -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;
+82
View File
@@ -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;
+224
View File
@@ -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;