# Admin Panel Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a password-protected admin panel at `/admin` with a Kanban board, analytics dashboard, and completed-orders table — all backed by Supabase. **Architecture:** Feature-based folders under `src/admin/` with custom hooks for data fetching, React Router v6 for routing, and Supabase Realtime keeping the Kanban live. PublicSite stays untouched except `LeadForm.jsx` gains a silent Supabase insert alongside the existing Telegram call. **Tech Stack:** React 19, Vite, Supabase (PostgreSQL + Auth + Realtime), react-router-dom v6, @dnd-kit/core + @dnd-kit/sortable, recharts, Vitest + @testing-library/react --- ## File Map | Action | Path | Responsibility | |--------|------|----------------| | Create | `src/lib/supabase.js` | Single Supabase client instance | | Create | `src/test/setup.js` | Vitest global setup | | Modify | `vite.config.js` | Add Vitest config | | Modify | `package.json` | Add test scripts | | Modify | `.env.example` | Add Supabase keys | | Modify | `src/App.jsx` | Add BrowserRouter + /admin routes | | Create | `src/admin/auth/useAuth.js` | Session, login, logout hook | | Create | `src/admin/auth/ProtectedRoute.jsx` | Outlet or redirect | | Create | `src/admin/auth/LoginPage.jsx` | Login form | | Create | `src/admin/layout/Sidebar.jsx` | Dark sidebar nav | | Create | `src/admin/layout/AdminLayout.jsx` | Sidebar + header + Outlet | | Create | `src/admin/orders/useOrders.js` | CRUD + status + Realtime | | Create | `src/admin/orders/OrderCard.jsx` | Draggable kanban card | | Create | `src/admin/orders/KanbanColumn.jsx` | Droppable column | | Create | `src/admin/orders/KanbanBoard.jsx` | DndContext + 5 columns | | Create | `src/admin/orders/OrderModal.jsx` | Edit/create modal + event log | | Create | `src/admin/analytics/useAnalytics.js` | Aggregated analytics queries | | Create | `src/admin/analytics/charts/FunnelChart.jsx` | Horizontal bar by stage | | Create | `src/admin/analytics/charts/RevenueChart.jsx` | Line chart by month | | Create | `src/admin/analytics/charts/ObjectTypeChart.jsx` | Donut by object type | | Create | `src/admin/analytics/charts/AvgTimeChart.jsx` | Bar: avg days per stage | | Create | `src/admin/analytics/AnalyticsDashboard.jsx` | KPI cards + 4 charts | | Create | `src/admin/analytics/CompletedOrdersTable.jsx` | Sortable table + CSV export | | Modify | `src/components/LeadForm.jsx` | Add Supabase insert | | Modify | `src/components/Estimator.jsx` | Remove unused windows slider | --- ## Task 1: Install dependencies, test setup, Supabase client, env **Files:** - Modify: `package.json` - Modify: `vite.config.js` - Create: `src/test/setup.js` - Create: `src/lib/supabase.js` - Modify: `.env.example` - [ ] **Step 1: Install packages** ```bash cd "/Users/houseassassin/My Projects/glass" npm install @supabase/supabase-js react-router-dom recharts @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom ``` Expected: no errors, packages appear in `node_modules` - [ ] **Step 2: Add test scripts to package.json** In `package.json`, update the `"scripts"` block to: ```json "scripts": { "dev": "vite", "build": "vite build", "lint": "eslint .", "preview": "vite preview", "test": "vitest", "test:run": "vitest run" } ``` - [ ] **Step 3: Update vite.config.js** ```js import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.js'], }, }) ``` - [ ] **Step 4: Create src/test/setup.js** ```js import '@testing-library/jest-dom' ``` - [ ] **Step 5: Create src/lib/supabase.js** ```js import { createClient } from '@supabase/supabase-js' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY export const supabase = createClient(supabaseUrl, supabaseAnonKey) ``` - [ ] **Step 6: Update .env.example** Append to the existing file: ``` VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your-anon-key ``` - [ ] **Step 7: Verify test runner works** ```bash npm run test:run ``` Expected output: `No test files found` (or 0 tests, 0 failures — not an error) - [ ] **Step 8: Commit** ```bash git init git add package.json package-lock.json vite.config.js src/test/setup.js src/lib/supabase.js .env.example git commit -m "feat: add Supabase, React Router, dnd-kit, recharts, Vitest" ``` --- ## Task 2: Supabase database schema **Files:** None (SQL run in Supabase dashboard) - [ ] **Step 1: Log into Supabase dashboard → SQL Editor → New query** Paste and run: ```sql -- orders table CREATE TABLE orders ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), created_at timestamptz DEFAULT now(), name text NOT NULL, phone text NOT NULL, object_type text, area_sqm numeric, film_thickness integer, estimated_cost numeric, final_cost numeric, status text DEFAULT 'новая' CHECK (status IN ('новая','замер','согласование','монтаж','закрыт')), notes text, assigned_to text, closed_at timestamptz, source text DEFAULT 'form' CHECK (source IN ('form','manual')) ); -- order_events table CREATE TABLE order_events ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), order_id uuid REFERENCES orders(id) ON DELETE CASCADE NOT NULL, created_at timestamptz DEFAULT now(), created_by text, event_type text CHECK (event_type IN ('status_change','note','edit')), description text ); -- Enable RLS ALTER TABLE orders ENABLE ROW LEVEL SECURITY; ALTER TABLE order_events ENABLE ROW LEVEL SECURITY; -- Authenticated users get full access CREATE POLICY "auth_all_orders" ON orders FOR ALL TO authenticated USING (true) WITH CHECK (true); CREATE POLICY "auth_all_events" ON order_events FOR ALL TO authenticated USING (true) WITH CHECK (true); -- Enable Realtime for orders ALTER PUBLICATION supabase_realtime ADD TABLE orders; ``` - [ ] **Step 2: Verify in Table Editor** Navigate to Table Editor → confirm `orders` and `order_events` exist with the correct columns. - [ ] **Step 3: Create a test admin user** In Supabase → Authentication → Users → Add user. Use the email and password you'll log in with. --- ## Task 3: React Router setup in App.jsx **Files:** - Modify: `src/App.jsx` - [ ] **Step 1: Rewrite src/App.jsx** ```jsx import React from 'react'; import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; import Navbar from './components/Navbar'; import Hero from './components/Hero'; import PhysicsOfSafety from './components/PhysicsOfSafety'; import UseCases from './components/UseCases'; import Estimator from './components/Estimator'; import TrustBar from './components/TrustBar'; import Comparison from './components/Comparison'; import LeadForm from './components/LeadForm'; import Footer from './components/Footer'; // Admin imports (filled in later tasks) const LoginPage = React.lazy(() => import('./admin/auth/LoginPage')); const ProtectedRoute = React.lazy(() => import('./admin/auth/ProtectedRoute')); const AdminLayout = React.lazy(() => import('./admin/layout/AdminLayout')); const KanbanBoard = React.lazy(() => import('./admin/orders/KanbanBoard')); const AnalyticsDashboard = React.lazy(() => import('./admin/analytics/AnalyticsDashboard')); const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/CompletedOrdersTable')); function PublicSite() { return (
); } function App() { return ( Загрузка...}> } /> } /> } /> }> }> } /> } /> } /> ); } export default App; ``` - [ ] **Step 2: Start dev server and verify public site still works** ```bash npm run dev ``` Open http://localhost:5173 — public landing page renders normally. Open http://localhost:5173/admin — shows «Загрузка...» then an error overlay (lazy modules don't exist yet). **This is expected.** The public site at `/` is unaffected. Admin routes will work correctly once Tasks 4–15 are complete. - [ ] **Step 3: Commit** ```bash git add src/App.jsx git commit -m "feat: add React Router, lazy-load admin routes" ``` --- ## Task 4: useAuth hook **Files:** - Create: `src/admin/auth/useAuth.js` - Create: `src/admin/auth/__tests__/useAuth.test.js` - [ ] **Step 1: Create the test file** Create directory: `src/admin/auth/__tests__/` ```js // src/admin/auth/__tests__/useAuth.test.js import { renderHook, act } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach } from 'vitest'; const mockUnsubscribe = vi.fn(); const mockGetSession = vi.fn(); const mockOnAuthStateChange = vi.fn(() => ({ data: { subscription: { unsubscribe: mockUnsubscribe } }, })); const mockSignInWithPassword = vi.fn(); const mockSignOut = vi.fn(); vi.mock('../../../lib/supabase', () => ({ supabase: { auth: { getSession: mockGetSession, onAuthStateChange: mockOnAuthStateChange, signInWithPassword: mockSignInWithPassword, signOut: mockSignOut, }, }, })); import { useAuth } from '../useAuth'; describe('useAuth', () => { beforeEach(() => { vi.clearAllMocks(); mockOnAuthStateChange.mockReturnValue({ data: { subscription: { unsubscribe: mockUnsubscribe } }, }); }); it('starts with loading=true and session=null', () => { mockGetSession.mockReturnValue(new Promise(() => {})); // never resolves const { result } = renderHook(() => useAuth()); expect(result.current.loading).toBe(true); expect(result.current.session).toBe(null); }); it('sets loading=false and session after getSession resolves', async () => { const fakeSession = { user: { email: 'admin@test.com' } }; mockGetSession.mockResolvedValue({ data: { session: fakeSession } }); const { result } = renderHook(() => useAuth()); await act(async () => {}); expect(result.current.loading).toBe(false); expect(result.current.session).toEqual(fakeSession); }); it('login calls signInWithPassword with email and password', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); mockSignInWithPassword.mockResolvedValue({ error: null }); const { result } = renderHook(() => useAuth()); await act(async () => { await result.current.login('admin@test.com', 'secret'); }); expect(mockSignInWithPassword).toHaveBeenCalledWith({ email: 'admin@test.com', password: 'secret', }); }); it('login returns error when credentials are wrong', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); const fakeError = new Error('Invalid credentials'); mockSignInWithPassword.mockResolvedValue({ error: fakeError }); const { result } = renderHook(() => useAuth()); let loginResult; await act(async () => { loginResult = await result.current.login('bad@test.com', 'wrong'); }); expect(loginResult.error).toBe(fakeError); }); it('logout calls signOut', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); mockSignOut.mockResolvedValue({}); const { result } = renderHook(() => useAuth()); await act(async () => { await result.current.logout(); }); expect(mockSignOut).toHaveBeenCalled(); }); it('unsubscribes on unmount', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }); const { unmount } = renderHook(() => useAuth()); await act(async () => {}); unmount(); expect(mockUnsubscribe).toHaveBeenCalled(); }); }); ``` - [ ] **Step 2: Run tests — expect FAIL** ```bash npm run test:run -- src/admin/auth/__tests__/useAuth.test.js ``` Expected: FAIL with `Cannot find module '../useAuth'` - [ ] **Step 3: Create src/admin/auth/useAuth.js** ```js import { useState, useEffect } from 'react'; import { supabase } from '../../lib/supabase'; export function useAuth() { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => { setSession(session); setLoading(false); }); const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { setSession(session); }); return () => subscription.unsubscribe(); }, []); const login = async (email, password) => { const { error } = await supabase.auth.signInWithPassword({ email, password }); return { error }; }; const logout = async () => { await supabase.auth.signOut(); }; return { session, loading, login, logout }; } ``` - [ ] **Step 4: Run tests — expect PASS** ```bash npm run test:run -- src/admin/auth/__tests__/useAuth.test.js ``` Expected: `6 passed` - [ ] **Step 5: Commit** ```bash git add src/admin/auth/useAuth.js src/admin/auth/__tests__/useAuth.test.js git commit -m "feat: add useAuth hook with Supabase Auth session management" ``` --- ## Task 5: ProtectedRoute and LoginPage **Files:** - Create: `src/admin/auth/ProtectedRoute.jsx` - Create: `src/admin/auth/LoginPage.jsx` - [ ] **Step 1: Create src/admin/auth/ProtectedRoute.jsx** ```jsx import React from 'react'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { useAuth } from './useAuth'; const ProtectedRoute = () => { const { session, loading } = useAuth(); const location = useLocation(); if (loading) { return (
Загрузка...
); } if (!session) { return ; } return ; }; export default ProtectedRoute; ``` - [ ] **Step 2: Create src/admin/auth/LoginPage.jsx** ```jsx import React, { useState } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { Shield } from 'lucide-react'; import { useAuth } from './useAuth'; const LoginPage = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [submitting, setSubmitting] = useState(false); const { login } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const from = location.state?.from?.pathname || '/admin/kanban'; const handleSubmit = async (e) => { e.preventDefault(); setSubmitting(true); setError(''); const { error } = await login(email, password); if (error) { setError('Неверный email или пароль'); setSubmitting(false); } else { navigate(from, { replace: true }); } }; const inputStyle = { width: '100%', padding: '14px', background: 'var(--bg-lighter)', border: '2px solid var(--border-light)', borderRadius: '8px', color: 'var(--text-main)', fontSize: '1rem', outline: 'none', fontFamily: 'var(--font-body)', }; const labelStyle = { display: 'block', marginBottom: '8px', fontSize: '0.85rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', }; return (
Осколкам.Нет

Вход в панель

setEmail(e.target.value)} required style={inputStyle} onFocus={(e) => e.target.style.borderColor = 'var(--accent-blue)'} onBlur={(e) => e.target.style.borderColor = 'var(--border-light)'} />
setPassword(e.target.value)} required style={inputStyle} onFocus={(e) => e.target.style.borderColor = 'var(--accent-blue)'} onBlur={(e) => e.target.style.borderColor = 'var(--border-light)'} />
{error && (

{error}

)}
); }; export default LoginPage; ``` - [ ] **Step 3: Test login page manually** ```bash npm run dev ``` Open http://localhost:5173/admin/login — form should render. Try logging in with the Supabase user created in Task 2 Step 3. On success, redirects to `/admin/kanban` (shows Загрузка until layout is built). - [ ] **Step 4: Commit** ```bash git add src/admin/auth/ProtectedRoute.jsx src/admin/auth/LoginPage.jsx git commit -m "feat: add ProtectedRoute and LoginPage" ``` --- ## Task 6: AdminLayout and Sidebar **Files:** - Create: `src/admin/layout/Sidebar.jsx` - Create: `src/admin/layout/AdminLayout.jsx` - [ ] **Step 1: Create src/admin/layout/Sidebar.jsx** ```jsx import React from 'react'; import { NavLink, useNavigate } from 'react-router-dom'; import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut } from 'lucide-react'; import { useAuth } from '../auth/useAuth'; const NAV_ITEMS = [ { to: '/admin/kanban', icon: LayoutDashboard, label: 'Канбан' }, { to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' }, { to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' }, ]; const Sidebar = () => { const { session, logout } = useAuth(); const navigate = useNavigate(); const handleLogout = async () => { await logout(); navigate('/admin/login'); }; return ( ); }; export default Sidebar; ``` - [ ] **Step 2: Create src/admin/layout/AdminLayout.jsx** ```jsx import React, { useState, useEffect } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import { Plus } from 'lucide-react'; import Sidebar from './Sidebar'; import { supabase } from '../../lib/supabase'; const PAGE_TITLES = { '/admin/kanban': 'Канбан', '/admin/analytics': 'Аналитика', '/admin/completed': 'Завершённые заказы', }; const AdminLayout = () => { const location = useLocation(); const title = PAGE_TITLES[location.pathname] || 'Панель управления'; const [showNewOrder, setShowNewOrder] = useState(false); const [newCount, setNewCount] = useState(0); const refreshBadge = () => { supabase .from('orders') .select('id', { count: 'exact', head: true }) .eq('status', 'новая') .then(({ count }) => setNewCount(count || 0)); }; useEffect(() => { refreshBadge(); const channel = supabase .channel('badge-count') .on('postgres_changes', { event: '*', schema: 'public', table: 'orders' }, refreshBadge) .subscribe(); return () => supabase.removeChannel(channel); }, []); return (
{/* Header */}

{title}

{newCount > 0 && ( {newCount} новых )}
{/* Page content */}
); }; export default AdminLayout; ``` `showNewOrder` / `setShowNewOrder` is passed via Outlet context so `KanbanBoard` renders the create modal when the header button is clicked. The badge reacts to Realtime changes — turns red when new unprocessed orders arrive. - [ ] **Step 3: Verify layout renders** ```bash npm run dev ``` Log in at http://localhost:5173/admin/login. Should see sidebar + header. Page content area will be empty until kanban is built. - [ ] **Step 4: Commit** ```bash git add src/admin/layout/Sidebar.jsx src/admin/layout/AdminLayout.jsx git commit -m "feat: add AdminLayout with dark sidebar and sticky header" ``` --- ## Task 7: useOrders hook **Files:** - Create: `src/admin/orders/useOrders.js` - Create: `src/admin/orders/__tests__/useOrders.test.js` - [ ] **Step 1: Create the test file** ```js // src/admin/orders/__tests__/useOrders.test.js import { renderHook, act } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach } from 'vitest'; const mockChannel = { on: vi.fn().mockReturnThis(), subscribe: vi.fn().mockReturnThis(), }; vi.mock('../../../lib/supabase', () => ({ supabase: { from: vi.fn(), channel: vi.fn(() => mockChannel), removeChannel: vi.fn(), }, })); import { useOrders } from '../useOrders'; import { supabase } from '../../../lib/supabase'; function makeFromMock(resolvedValue) { return { select: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue(resolvedValue), update: vi.fn().mockReturnThis(), insert: vi.fn().mockResolvedValue({ error: null }), eq: vi.fn().mockResolvedValue({ data: null, error: null }), single: vi.fn().mockResolvedValue({ data: null, error: null }), }; } describe('useOrders', () => { beforeEach(() => { vi.clearAllMocks(); mockChannel.on.mockReturnThis(); mockChannel.subscribe.mockReturnThis(); }); it('starts with loading=true and empty orders', () => { supabase.from.mockReturnValue({ select: vi.fn().mockReturnThis(), order: vi.fn().mockReturnValue(new Promise(() => {})), }); const { result } = renderHook(() => useOrders()); expect(result.current.loading).toBe(true); expect(result.current.orders).toEqual([]); }); it('populates orders and sets loading=false after fetch', async () => { const mockOrders = [ { id: '1', name: 'Иванов', status: 'новая', created_at: '2026-01-01' }, { id: '2', name: 'Петров', status: 'замер', created_at: '2026-01-02' }, ]; supabase.from.mockReturnValue(makeFromMock({ data: mockOrders, error: null })); const { result } = renderHook(() => useOrders()); await act(async () => {}); expect(result.current.loading).toBe(false); expect(result.current.orders).toEqual(mockOrders); }); it('groups orders by status in ordersByStatus', async () => { const mockOrders = [ { id: '1', name: 'А', status: 'новая', created_at: '2026-01-01' }, { id: '2', name: 'Б', status: 'замер', created_at: '2026-01-02' }, { id: '3', name: 'В', status: 'новая', created_at: '2026-01-03' }, ]; supabase.from.mockReturnValue(makeFromMock({ data: mockOrders, error: null })); const { result } = renderHook(() => useOrders()); await act(async () => {}); expect(result.current.ordersByStatus['новая']).toHaveLength(2); expect(result.current.ordersByStatus['замер']).toHaveLength(1); expect(result.current.ordersByStatus['монтаж']).toHaveLength(0); expect(result.current.ordersByStatus['закрыт']).toHaveLength(0); }); it('sets error when fetch fails', async () => { supabase.from.mockReturnValue(makeFromMock({ data: null, error: { message: 'DB error' } })); const { result } = renderHook(() => useOrders()); await act(async () => {}); expect(result.current.error).toBe('DB error'); }); }); ``` - [ ] **Step 2: Run tests — expect FAIL** ```bash npm run test:run -- src/admin/orders/__tests__/useOrders.test.js ``` Expected: FAIL with `Cannot find module '../useOrders'` - [ ] **Step 3: Create src/admin/orders/useOrders.js** ```js import { useState, useEffect, useCallback } from 'react'; import { supabase } from '../../lib/supabase'; const STATUSES = ['новая', 'замер', 'согласование', 'монтаж', 'закрыт']; export function useOrders() { const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchOrders = useCallback(async () => { const { data, error } = await supabase .from('orders') .select('*') .order('created_at', { ascending: false }); if (error) { setError(error.message); } else { setOrders(data || []); } setLoading(false); }, []); useEffect(() => { fetchOrders(); const channel = supabase .channel('orders-realtime') .on('postgres_changes', { event: '*', schema: 'public', table: 'orders' }, fetchOrders) .subscribe(); return () => supabase.removeChannel(channel); }, [fetchOrders]); const ordersByStatus = STATUSES.reduce((acc, status) => { acc[status] = orders.filter((o) => o.status === status); return acc; }, {}); const updateStatus = async (orderId, newStatus, userEmail) => { const order = orders.find((o) => o.id === orderId); const updates = { status: newStatus }; if (newStatus === 'закрыт') updates.closed_at = new Date().toISOString(); const { error } = await supabase.from('orders').update(updates).eq('id', orderId); if (!error) { await supabase.from('order_events').insert({ order_id: orderId, created_by: userEmail, event_type: 'status_change', description: `Статус изменён: ${order?.status} → ${newStatus}`, }); } return { error }; }; const createOrder = async (orderData, userEmail) => { const { data, error } = await supabase .from('orders') .insert({ ...orderData, source: 'manual', status: 'новая' }) .select() .single(); if (!error && data) { await supabase.from('order_events').insert({ order_id: data.id, created_by: userEmail, event_type: 'status_change', description: 'Заявка создана вручную', }); } return { data, error }; }; const updateOrder = async (orderId, updates, userEmail) => { const { error } = await supabase.from('orders').update(updates).eq('id', orderId); if (!error) { await supabase.from('order_events').insert({ order_id: orderId, created_by: userEmail, event_type: 'edit', description: 'Данные заказа обновлены', }); } return { error }; }; return { orders, ordersByStatus, loading, error, updateStatus, createOrder, updateOrder, refetch: fetchOrders }; } ``` - [ ] **Step 4: Run tests — expect PASS** ```bash npm run test:run -- src/admin/orders/__tests__/useOrders.test.js ``` Expected: `4 passed` - [ ] **Step 5: Commit** ```bash git add src/admin/orders/useOrders.js src/admin/orders/__tests__/useOrders.test.js git commit -m "feat: add useOrders hook with CRUD, status updates, and Realtime" ``` --- ## Task 8: KanbanBoard, KanbanColumn, OrderCard **Files:** - Create: `src/admin/orders/OrderCard.jsx` - Create: `src/admin/orders/KanbanColumn.jsx` - Create: `src/admin/orders/KanbanBoard.jsx` - [ ] **Step 1: Create src/admin/orders/OrderCard.jsx** ```jsx 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; ``` - [ ] **Step 2: Create src/admin/orders/KanbanColumn.jsx** ```jsx 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; ``` - [ ] **Step 3: Create src/admin/orders/KanbanBoard.jsx** ```jsx 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; let 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; ``` - [ ] **Step 4: Verify Kanban renders** ```bash npm run dev ``` Log in → http://localhost:5173/admin/kanban. Should see 5 empty columns. Clicking «+ Заявка» in header will fail until OrderModal is built (next task). Console should be clean. - [ ] **Step 5: Commit** ```bash git add src/admin/orders/OrderCard.jsx src/admin/orders/KanbanColumn.jsx src/admin/orders/KanbanBoard.jsx git commit -m "feat: add Kanban board with drag-and-drop columns and cards" ``` --- ## Task 9: OrderModal **Files:** - Create: `src/admin/orders/OrderModal.jsx` - [ ] **Step 1: Create src/admin/orders/OrderModal.jsx** ```jsx 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; } // Realtime in KanbanBoard will pick up the new row automatically } 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; } 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}` : 'Данные заказа обновлены', }); } onSaved?.(); onClose(); }; return (
e.stopPropagation()} > {/* Header */}

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

{/* Fields */}