Files
glass/docs/superpowers/plans/2026-05-07-admin-panel.md
T
2026-05-10 17:11:25 +03:00

80 KiB
Raw Blame History

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

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:

"scripts": {
  "dev": "vite",
  "build": "vite build",
  "lint": "eslint .",
  "preview": "vite preview",
  "test": "vitest",
  "test:run": "vitest run"
}
  • Step 3: Update vite.config.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
import '@testing-library/jest-dom'
  • Step 5: Create src/lib/supabase.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
npm run test:run

Expected output: No test files found (or 0 tests, 0 failures — not an error)

  • Step 8: Commit
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:

-- 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

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 (
    <div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
      <Navbar />
      <main style={{ flex: '1' }}>
        <Hero />
        <PhysicsOfSafety />
        <UseCases />
        <TrustBar />
        <Comparison />
        <Estimator />
        <LeadForm />
      </main>
      <Footer />
    </div>
  );
}

function App() {
  return (
    <BrowserRouter>
      <React.Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', fontFamily: 'sans-serif', color: '#475569' }}>Загрузка...</div>}>
        <Routes>
          <Route path="/" element={<PublicSite />} />
          <Route path="/admin/login" element={<LoginPage />} />
          <Route path="/admin" element={<Navigate to="/admin/kanban" replace />} />
          <Route path="/admin/*" element={<ProtectedRoute />}>
            <Route element={<AdminLayout />}>
              <Route path="kanban" element={<KanbanBoard />} />
              <Route path="analytics" element={<AnalyticsDashboard />} />
              <Route path="completed" element={<CompletedOrdersTable />} />
            </Route>
          </Route>
        </Routes>
      </React.Suspense>
    </BrowserRouter>
  );
}

export default App;
  • Step 2: Start dev server and verify public site still works
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 415 are complete.

  • Step 3: Commit
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__/

// 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
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
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
npm run test:run -- src/admin/auth/__tests__/useAuth.test.js

Expected: 6 passed

  • Step 5: Commit
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

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 (
      <div style={{
        display: 'flex', justifyContent: 'center', alignItems: 'center',
        height: '100vh', fontFamily: 'var(--font-heading)', color: 'var(--text-muted)',
      }}>
        Загрузка...
      </div>
    );
  }

  if (!session) {
    return <Navigate to="/admin/login" state={{ from: location }} replace />;
  }

  return <Outlet />;
};

export default ProtectedRoute;
  • Step 2: Create src/admin/auth/LoginPage.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 (
    <div style={{
      minHeight: '100vh', display: 'flex',
      alignItems: 'center', justifyContent: 'center',
      background: 'var(--bg-light)',
    }}>
      <div className="glass-panel" style={{ padding: '50px', width: '100%', maxWidth: '420px', margin: '20px' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '40px' }}>
          <Shield size={26} color="var(--accent-blue)" />
          <span style={{ fontFamily: 'var(--font-heading)', fontSize: '1.4rem', fontWeight: 800 }}>
            Осколкам.<span style={{ color: 'var(--accent-blue)' }}>Нет</span>
          </span>
        </div>

        <h2 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.8rem', marginBottom: '30px', color: 'var(--text-main)' }}>
          Вход в панель
        </h2>

        <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
          <div>
            <label style={labelStyle}>Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => 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)'}
            />
          </div>

          <div>
            <label style={labelStyle}>Пароль</label>
            <input
              type="password"
              value={password}
              onChange={(e) => 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)'}
            />
          </div>

          {error && (
            <p style={{ color: '#ef4444', fontSize: '0.9rem', margin: 0 }}>{error}</p>
          )}

          <button
            type="submit"
            disabled={submitting}
            className="btn-primary"
            style={{ marginTop: '8px', opacity: submitting ? 0.7 : 1, cursor: submitting ? 'not-allowed' : 'pointer' }}
          >
            {submitting ? 'Входим...' : 'Войти'}
          </button>
        </form>
      </div>
    </div>
  );
};

export default LoginPage;
  • Step 3: Test login page manually
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
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

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 (
    <aside style={{
      width: '240px', minHeight: '100vh',
      background: '#0f172a',
      display: 'flex', flexDirection: 'column',
      position: 'fixed', top: 0, left: 0,
      zIndex: 100,
      borderRight: '1px solid rgba(255,255,255,0.06)',
    }}>
      {/* Logo */}
      <div style={{ padding: '22px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
          <Shield size={20} color="var(--accent-blue)" />
          <span style={{ fontFamily: 'var(--font-heading)', fontWeight: 800, color: '#fff', fontSize: '1.05rem' }}>
            Осколкам.<span style={{ color: 'var(--accent-blue)' }}>Нет</span>
          </span>
        </div>
        <p style={{ fontSize: '0.72rem', color: 'rgba(255,255,255,0.35)', marginTop: '3px', marginLeft: '30px' }}>
          Панель управления
        </p>
      </div>

      {/* Navigation */}
      <nav style={{ flex: 1, padding: '14px 10px' }}>
        {NAV_ITEMS.map(({ to, icon: Icon, label }) => (
          <NavLink
            key={to}
            to={to}
            style={({ isActive }) => ({
              display: 'flex', alignItems: 'center', gap: '12px',
              padding: '11px 14px', borderRadius: '8px', marginBottom: '2px',
              color: isActive ? '#fff' : 'rgba(255,255,255,0.45)',
              background: isActive ? 'rgba(14, 165, 233, 0.15)' : 'transparent',
              textDecoration: 'none',
              fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.88rem',
              transition: 'all 0.15s',
              borderLeft: isActive ? '3px solid var(--accent-blue)' : '3px solid transparent',
            })}
          >
            <Icon size={17} />
            {label}
          </NavLink>
        ))}
      </nav>

      {/* User + logout */}
      <div style={{ padding: '14px 18px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
        <p style={{
          fontSize: '0.75rem', color: 'rgba(255,255,255,0.35)',
          marginBottom: '10px', overflow: 'hidden',
          textOverflow: 'ellipsis', whiteSpace: 'nowrap',
        }}>
          {session?.user?.email}
        </p>
        <button
          onClick={handleLogout}
          style={{
            display: 'flex', alignItems: 'center', gap: '8px',
            background: 'none', border: 'none', cursor: 'pointer',
            color: 'rgba(255,255,255,0.45)',
            fontFamily: 'var(--font-body)', fontSize: '0.82rem', padding: '4px 0',
            transition: 'color 0.15s',
          }}
          onMouseEnter={(e) => e.currentTarget.style.color = '#fff'}
          onMouseLeave={(e) => e.currentTarget.style.color = 'rgba(255,255,255,0.45)'}
        >
          <LogOut size={15} />
          Выйти
        </button>
      </div>
    </aside>
  );
};

export default Sidebar;
  • Step 2: Create src/admin/layout/AdminLayout.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 (
    <div style={{ display: 'flex', minHeight: '100vh', background: 'var(--bg-light)' }}>
      <Sidebar />
      <div style={{ marginLeft: '240px', flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
        {/* Header */}
        <header style={{
          height: '60px', background: '#fff',
          borderBottom: '1px solid var(--border-light)',
          display: 'flex', alignItems: 'center',
          justifyContent: 'space-between', padding: '0 28px',
          position: 'sticky', top: 0, zIndex: 50,
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
            <h1 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.05rem', fontWeight: 700, color: 'var(--text-main)' }}>
              {title}
            </h1>
            {newCount > 0 && (
              <span style={{
                background: '#ef4444', color: '#fff',
                borderRadius: '50px', padding: '2px 9px',
                fontSize: '0.72rem', fontWeight: 700,
              }}>
                {newCount} новых
              </span>
            )}
          </div>
          <button
            onClick={() => setShowNewOrder(true)}
            className="btn-primary"
            style={{ padding: '8px 18px', fontSize: '0.88rem', display: 'flex', alignItems: 'center', gap: '7px' }}
          >
            <Plus size={15} />
            Заявка
          </button>
        </header>

        {/* Page content */}
        <main style={{ flex: 1, padding: '28px', overflow: 'auto' }}>
          <Outlet context={{ showNewOrder, setShowNewOrder }} />
        </main>
      </div>
    </div>
  );
};

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
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
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

// 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
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
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
npm run test:run -- src/admin/orders/__tests__/useOrders.test.js

Expected: 4 passed

  • Step 5: Commit
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

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;
  • Step 2: Create src/admin/orders/KanbanColumn.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 (
    <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;
  • Step 3: Create src/admin/orders/KanbanBoard.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 (
      <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;
  • Step 4: Verify Kanban renders
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
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

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 (
    <div
      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} 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 style={labelStyle}>ФИО / Организация *</label>
            <input name="name" value={form.name} onChange={set} style={inputStyle} />
          </div>
          <div>
            <label style={labelStyle}>Телефон *</label>
            <input name="phone" value={form.phone} onChange={set} style={inputStyle} />
          </div>
          <div>
            <label style={labelStyle}>Тип объекта</label>
            <select 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 style={labelStyle}>Статус</label>
            <select 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 style={labelStyle}>Площадь (м²)</label>
            <input type="number" name="area_sqm" value={form.area_sqm} onChange={set} style={inputStyle} />
          </div>
          <div>
            <label style={labelStyle}>Толщина плёнки</label>
            <select 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 style={labelStyle}>Ориентировочная стоимость ()</label>
            <input type="number" name="estimated_cost" value={form.estimated_cost} onChange={set} style={inputStyle} />
          </div>
          <div>
            <label style={labelStyle}>Итоговая стоимость ()</label>
            <input type="number" name="final_cost" value={form.final_cost} onChange={set} style={inputStyle} />
          </div>
          <div style={{ gridColumn: '1 / -1' }}>
            <label style={labelStyle}>Ответственный (email)</label>
            <input name="assigned_to" value={form.assigned_to} onChange={set} style={inputStyle} placeholder="manager@company.com" />
          </div>
        </div>

        <div style={{ marginTop: '14px' }}>
          <label style={labelStyle}>Заметки</label>
          <textarea name="notes" value={form.notes} onChange={set} style={{ ...inputStyle, height: '72px', resize: 'vertical' }} />
        </div>

        {error && <p 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;
  • Step 2: Test end-to-end
npm run dev
  1. Open http://localhost:5173/admin/kanban
  2. Click «+ Заявка» in header → modal appears
  3. Fill name + phone + status, click «Сохранить» → card appears in the correct column automatically (Realtime)
  4. Click a card → modal opens in edit mode with all fields populated
  5. Change status via dropdown, save → card moves to new column
  • Step 3: Commit
git add src/admin/orders/OrderModal.jsx
git commit -m "feat: add OrderModal for create/edit with event log"

Task 10: useAnalytics hook

Files:

  • Create: src/admin/analytics/useAnalytics.js

  • Create: src/admin/analytics/__tests__/useAnalytics.test.js

  • Step 1: Create the test file

// src/admin/analytics/__tests__/useAnalytics.test.js
import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';

vi.mock('../../../lib/supabase', () => ({
  supabase: {
    from: vi.fn(),
  },
}));

import { useAnalytics } from '../useAnalytics';
import { supabase } from '../../../lib/supabase';

const FROM = '2026-01-01T00:00:00.000Z';
const TO = '2026-12-31T23:59:59.999Z';

describe('useAnalytics', () => {
  beforeEach(() => vi.clearAllMocks());

  it('returns loading=true initially', () => {
    supabase.from.mockReturnValue({
      select: vi.fn().mockReturnThis(),
      gte: vi.fn().mockReturnThis(),
      lte: vi.fn().mockReturnThis(),
      eq: vi.fn().mockReturnThis(),
      in: vi.fn().mockReturnThis(),
      order: vi.fn().mockReturnValue(new Promise(() => {})),
    });
    const { result } = renderHook(() => useAnalytics(FROM, TO));
    expect(result.current.loading).toBe(true);
  });

  it('computes totalOrders, closedOrders, conversionRate correctly', async () => {
    const orders = [
      { id: '1', status: 'новая', object_type: 'industrial', created_at: '2026-03-01', final_cost: null, closed_at: null },
      { id: '2', status: 'закрыт', object_type: 'commercial', created_at: '2026-03-02', final_cost: 50000, closed_at: '2026-03-10' },
      { id: '3', status: 'закрыт', object_type: 'industrial', created_at: '2026-04-01', final_cost: 80000, closed_at: '2026-04-15' },
    ];
    const events = [];

    supabase.from.mockImplementation((table) => {
      if (table === 'orders') {
        return {
          select: vi.fn().mockReturnThis(),
          gte: vi.fn().mockReturnThis(),
          lte: vi.fn().mockResolvedValue({ data: orders, error: null }),
        };
      }
      return {
        select: vi.fn().mockReturnThis(),
        eq: vi.fn().mockReturnThis(),
        gte: vi.fn().mockReturnThis(),
        in: vi.fn().mockReturnThis(),
        order: vi.fn().mockResolvedValue({ data: events, error: null }),
      };
    });

    const { result } = renderHook(() => useAnalytics(FROM, TO));
    await act(async () => {});

    expect(result.current.totalOrders).toBe(3);
    expect(result.current.closedOrders).toBe(2);
    expect(result.current.conversionRate).toBe(67);
    expect(result.current.totalRevenue).toBe(130000);
    expect(result.current.avgDeal).toBe(65000);
    expect(result.current.loading).toBe(false);
  });

  it('builds byObjectType counts', async () => {
    const orders = [
      { id: '1', status: 'новая', object_type: 'industrial', created_at: '2026-03-01', final_cost: null, closed_at: null },
      { id: '2', status: 'новая', object_type: 'industrial', created_at: '2026-03-02', final_cost: null, closed_at: null },
      { id: '3', status: 'новая', object_type: 'commercial', created_at: '2026-03-03', final_cost: null, closed_at: null },
    ];
    supabase.from.mockImplementation((table) => {
      if (table === 'orders') {
        return { select: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), lte: vi.fn().mockResolvedValue({ data: orders, error: null }) };
      }
      return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), in: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue({ data: [], error: null }) };
    });

    const { result } = renderHook(() => useAnalytics(FROM, TO));
    await act(async () => {});
    const industrial = result.current.byObjectType.find((x) => x.type === 'industrial');
    expect(industrial?.count).toBe(2);
  });
});
  • Step 2: Run tests — expect FAIL
npm run test:run -- src/admin/analytics/__tests__/useAnalytics.test.js

Expected: FAIL with Cannot find module '../useAnalytics'

  • Step 3: Create src/admin/analytics/useAnalytics.js
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '../../lib/supabase';

const STATUSES = ['новая', 'замер', 'согласование', 'монтаж', 'закрыт'];

export function useAnalytics(dateFrom, dateTo) {
  const [analytics, setAnalytics] = useState({
    totalOrders: 0, closedOrders: 0, conversionRate: 0,
    totalRevenue: 0, avgDeal: 0,
    funnelData: STATUSES.map((s) => ({ status: s, count: 0 })),
    revenueByMonth: [], byObjectType: [], avgTimeData: [],
  });
  const [loading, setLoading] = useState(true);

  const fetch = useCallback(async () => {
    setLoading(true);

    const { data: orders } = await supabase
      .from('orders')
      .select('*')
      .gte('created_at', dateFrom)
      .lte('created_at', dateTo);

    const safeOrders = orders || [];
    const orderIds = safeOrders.map((o) => o.id);

    const { data: rawEvents } = orderIds.length > 0
      ? await supabase
          .from('order_events')
          .select('*')
          .eq('event_type', 'status_change')
          .in('order_id', orderIds)
          .order('created_at', { ascending: true })
      : { data: [] };

    const events = rawEvents || [];
    const closed = safeOrders.filter((o) => o.status === 'закрыт');
    const totalRevenue = closed.reduce((s, o) => s + (Number(o.final_cost) || 0), 0);
    const avgDeal = closed.length > 0 ? Math.round(totalRevenue / closed.length) : 0;

    // Funnel: current count per stage
    const funnelData = STATUSES.map((status) => ({
      status,
      count: safeOrders.filter((o) => o.status === status).length,
    }));

    // Revenue by month
    const revMap = {};
    closed.forEach((o) => {
      const key = new Date(o.closed_at || o.created_at)
        .toLocaleString('ru-RU', { month: 'short', year: '2-digit' });
      revMap[key] = (revMap[key] || 0) + (Number(o.final_cost) || 0);
    });
    const revenueByMonth = Object.entries(revMap).map(([month, revenue]) => ({ month, revenue }));

    // By object type
    const typeMap = {};
    safeOrders.forEach((o) => {
      const type = o.object_type || 'other';
      typeMap[type] = (typeMap[type] || 0) + 1;
    });
    const byObjectType = Object.entries(typeMap).map(([type, count]) => ({ type, count }));

    // Avg time per stage (from status_change events)
    const timeMap = Object.fromEntries(STATUSES.slice(0, -1).map((s) => [s, []]));
    safeOrders.forEach((order) => {
      const oEvents = events.filter((e) => e.order_id === order.id);
      let prevTime = new Date(order.created_at);
      let prevStatus = 'новая';
      oEvents.forEach((e) => {
        const match = e.description?.match(/→ (.+)$/);
        if (match) {
          const days = (new Date(e.created_at) - prevTime) / 86400000;
          if (timeMap[prevStatus]) timeMap[prevStatus].push(days);
          prevStatus = match[1].trim();
          prevTime = new Date(e.created_at);
        }
      });
    });
    const avgTimeData = STATUSES.slice(0, -1).map((status) => ({
      status,
      avgDays: timeMap[status].length > 0
        ? Math.round((timeMap[status].reduce((a, b) => a + b, 0) / timeMap[status].length) * 10) / 10
        : 0,
    }));

    setAnalytics({
      totalOrders: safeOrders.length,
      closedOrders: closed.length,
      conversionRate: safeOrders.length > 0 ? Math.round((closed.length / safeOrders.length) * 100) : 0,
      totalRevenue, avgDeal, funnelData, revenueByMonth, byObjectType, avgTimeData,
    });
    setLoading(false);
  }, [dateFrom, dateTo]);

  useEffect(() => { fetch(); }, [fetch]);

  return { ...analytics, loading };
}
  • Step 4: Run tests — expect PASS
npm run test:run -- src/admin/analytics/__tests__/useAnalytics.test.js

Expected: 3 passed

  • Step 5: Commit
git add src/admin/analytics/useAnalytics.js src/admin/analytics/__tests__/useAnalytics.test.js
git commit -m "feat: add useAnalytics hook with KPI and chart data aggregation"

Task 11: Analytics charts

Files:

  • Create: src/admin/analytics/charts/FunnelChart.jsx

  • Create: src/admin/analytics/charts/RevenueChart.jsx

  • Create: src/admin/analytics/charts/ObjectTypeChart.jsx

  • Create: src/admin/analytics/charts/AvgTimeChart.jsx

  • Step 1: Create src/admin/analytics/charts/FunnelChart.jsx

import React from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';

const COLORS = ['#f59e0b', '#0ea5e9', '#8b5cf6', '#f97316', '#22c55e'];

const FunnelChart = ({ data }) => (
  <div>
    <h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.95rem', fontWeight: 700, marginBottom: '14px' }}>
      Воронка по стадиям
    </h3>
    {data.every((d) => d.count === 0) ? (
      <p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', textAlign: 'center', padding: '40px 0' }}>Нет данных</p>
    ) : (
      <ResponsiveContainer width="100%" height={210}>
        <BarChart data={data} layout="vertical" margin={{ left: 10, right: 30 }}>
          <XAxis type="number" tick={{ fontSize: 11, fill: '#94a3b8' }} />
          <YAxis type="category" dataKey="status" tick={{ fontSize: 11, fill: '#94a3b8' }} width={95} />
          <Tooltip formatter={(v) => [`${v} заказов`]} />
          <Bar dataKey="count" radius={[0, 4, 4, 0]}>
            {data.map((entry, i) => <Cell key={entry.status} fill={COLORS[i]} />)}
          </Bar>
        </BarChart>
      </ResponsiveContainer>
    )}
  </div>
);

export default FunnelChart;
  • Step 2: Create src/admin/analytics/charts/RevenueChart.jsx
import React from 'react';
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';

const RevenueChart = ({ data }) => (
  <div>
    <h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.95rem', fontWeight: 700, marginBottom: '14px' }}>
      Выручка по месяцам ()
    </h3>
    {data.length === 0 ? (
      <p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', textAlign: 'center', padding: '40px 0' }}>Нет закрытых заказов</p>
    ) : (
      <ResponsiveContainer width="100%" height={210}>
        <LineChart data={data} margin={{ left: 10, right: 20 }}>
          <CartesianGrid strokeDasharray="3 3" stroke="rgba(15,23,42,0.05)" />
          <XAxis dataKey="month" tick={{ fontSize: 11, fill: '#94a3b8' }} />
          <YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} tickFormatter={(v) => `${(v / 1000).toFixed(0)}к`} />
          <Tooltip formatter={(v) => [`${v.toLocaleString('ru-RU')} ₽`, 'Выручка']} />
          <Line type="monotone" dataKey="revenue" stroke="var(--accent-blue)" strokeWidth={2} dot={{ fill: 'var(--accent-blue)', r: 4 }} />
        </LineChart>
      </ResponsiveContainer>
    )}
  </div>
);

export default RevenueChart;
  • Step 3: Create src/admin/analytics/charts/ObjectTypeChart.jsx
import React from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';

const COLORS = ['#0ea5e9', '#f59e0b', '#8b5cf6', '#22c55e', '#f97316'];
const TYPE_LABELS = {
  industrial: 'Промышленный', commercial: 'Коммерческий',
  infrastructure: 'Инфраструктура', residential: 'Частный', other: 'Другое',
};

const ObjectTypeChart = ({ data }) => (
  <div>
    <h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.95rem', fontWeight: 700, marginBottom: '14px' }}>
      Типы объектов
    </h3>
    {data.length === 0 ? (
      <p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', textAlign: 'center', padding: '40px 0' }}>Нет данных</p>
    ) : (
      <ResponsiveContainer width="100%" height={210}>
        <PieChart>
          <Pie data={data} dataKey="count" nameKey="type" cx="50%" cy="50%" innerRadius={50} outerRadius={80}>
            {data.map((entry, i) => <Cell key={entry.type} fill={COLORS[i % COLORS.length]} />)}
          </Pie>
          <Tooltip formatter={(v, name) => [v, TYPE_LABELS[name] || name]} />
          <Legend formatter={(v) => TYPE_LABELS[v] || v} iconSize={10} />
        </PieChart>
      </ResponsiveContainer>
    )}
  </div>
);

export default ObjectTypeChart;
  • Step 4: Create src/admin/analytics/charts/AvgTimeChart.jsx
import React from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';

const COLORS = ['#f59e0b', '#0ea5e9', '#8b5cf6', '#f97316'];

const AvgTimeChart = ({ data }) => (
  <div>
    <h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.95rem', fontWeight: 700, marginBottom: '14px' }}>
      Среднее время в стадии (дни)
    </h3>
    {data.every((d) => d.avgDays === 0) ? (
      <p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', textAlign: 'center', padding: '40px 0' }}>
        Недостаточно данных
      </p>
    ) : (
      <ResponsiveContainer width="100%" height={210}>
        <BarChart data={data} margin={{ left: 10, right: 20 }}>
          <XAxis dataKey="status" tick={{ fontSize: 11, fill: '#94a3b8' }} />
          <YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} unit=" д" />
          <Tooltip formatter={(v) => [`${v} дней`]} />
          <Bar dataKey="avgDays" radius={[4, 4, 0, 0]}>
            {data.map((entry, i) => <Cell key={entry.status} fill={COLORS[i % COLORS.length]} />)}
          </Bar>
        </BarChart>
      </ResponsiveContainer>
    )}
  </div>
);

export default AvgTimeChart;
  • Step 5: Commit
git add src/admin/analytics/charts/
git commit -m "feat: add FunnelChart, RevenueChart, ObjectTypeChart, AvgTimeChart"

Task 12: AnalyticsDashboard

Files:

  • Create: src/admin/analytics/AnalyticsDashboard.jsx

  • Step 1: Create src/admin/analytics/AnalyticsDashboard.jsx

import React, { useState } from 'react';
import { useAnalytics } from './useAnalytics';
import FunnelChart from './charts/FunnelChart';
import RevenueChart from './charts/RevenueChart';
import ObjectTypeChart from './charts/ObjectTypeChart';
import AvgTimeChart from './charts/AvgTimeChart';

const PERIODS = [
  { label: '7 дней', days: 7 },
  { label: '30 дней', days: 30 },
  { label: '90 дней', days: 90 },
  { label: '365 дней', days: 365 },
];

function getRange(days) {
  const to = new Date();
  const from = new Date();
  from.setDate(from.getDate() - days);
  return { from: from.toISOString(), to: to.toISOString() };
}

const KpiCard = ({ label, value, sub }) => (
  <div className="glass-panel" style={{ padding: '22px', flex: '1 1 190px', minWidth: '160px' }}>
    <p style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: '7px' }}>
      {label}
    </p>
    <p style={{ fontFamily: 'var(--font-heading)', fontSize: '1.8rem', fontWeight: 800, color: 'var(--text-main)', lineHeight: 1 }}>
      {value}
    </p>
    {sub && <p style={{ fontSize: '0.8rem', color: 'var(--accent-blue)', marginTop: '5px' }}>{sub}</p>}
  </div>
);

const AnalyticsDashboard = () => {
  const [activeDays, setActiveDays] = useState(30);
  const { from, to } = getRange(activeDays);
  const {
    totalOrders, closedOrders, conversionRate,
    totalRevenue, avgDeal,
    funnelData, revenueByMonth, byObjectType, avgTimeData,
    loading,
  } = useAnalytics(from, to);

  return (
    <div>
      {/* Period selector */}
      <div style={{ display: 'flex', gap: '7px', marginBottom: '24px', flexWrap: 'wrap' }}>
        {PERIODS.map(({ label, days }) => (
          <button
            key={days}
            onClick={() => setActiveDays(days)}
            style={{
              padding: '7px 16px', borderRadius: '50px',
              border: `1.5px solid ${activeDays === days ? 'var(--accent-blue)' : 'var(--border-light)'}`,
              background: activeDays === days ? 'rgba(14, 165, 233, 0.08)' : '#fff',
              color: activeDays === days ? 'var(--accent-blue)' : 'var(--text-muted)',
              fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.82rem',
              cursor: 'pointer', transition: 'all 0.15s',
            }}
          >
            {label}
          </button>
        ))}
      </div>

      {loading ? (
        <p style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>Загрузка...</p>
      ) : (
        <>
          {/* KPI row */}
          <div style={{ display: 'flex', gap: '14px', flexWrap: 'wrap', marginBottom: '24px' }}>
            <KpiCard label="Всего заявок" value={totalOrders} />
            <KpiCard label="Закрыто" value={closedOrders} sub={`Конверсия ${conversionRate}%`} />
            <KpiCard label="Выручка" value={`${totalRevenue.toLocaleString('ru-RU')} ₽`} />
            <KpiCard label="Средний чек" value={avgDeal > 0 ? `${avgDeal.toLocaleString('ru-RU')} ₽` : '—'} />
          </div>

          {/* Charts grid */}
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))', gap: '18px' }}>
            <div className="glass-panel" style={{ padding: '22px' }}><FunnelChart data={funnelData} /></div>
            <div className="glass-panel" style={{ padding: '22px' }}><RevenueChart data={revenueByMonth} /></div>
            <div className="glass-panel" style={{ padding: '22px' }}><ObjectTypeChart data={byObjectType} /></div>
            <div className="glass-panel" style={{ padding: '22px' }}><AvgTimeChart data={avgTimeData} /></div>
          </div>
        </>
      )}
    </div>
  );
};

export default AnalyticsDashboard;
  • Step 2: Test in browser

Navigate to http://localhost:5173/admin/analytics. Four KPI cards and four chart placeholders should render. With no data, charts show "Нет данных" messages.

  • Step 3: Commit
git add src/admin/analytics/AnalyticsDashboard.jsx
git commit -m "feat: add AnalyticsDashboard with period selector and KPI cards"

Task 13: CompletedOrdersTable

Files:

  • Create: src/admin/analytics/CompletedOrdersTable.jsx

  • Step 1: Create src/admin/analytics/CompletedOrdersTable.jsx

import React, { useState, useEffect } from 'react';
import { Download } from 'lucide-react';
import { supabase } from '../../lib/supabase';

const TYPE_LABELS = {
  industrial: 'Промышленный', commercial: 'Коммерческий',
  infrastructure: 'Инфраструктура', residential: 'Частный', other: 'Другое',
};

const CompletedOrdersTable = () => {
  const [orders, setOrders] = useState([]);
  const [loading, setLoading] = useState(true);
  const [sortKey, setSortKey] = useState('closed_at');
  const [sortDir, setSortDir] = useState('desc');

  useEffect(() => {
    supabase
      .from('orders')
      .select('*')
      .eq('status', 'закрыт')
      .order('closed_at', { ascending: false })
      .then(({ data }) => { setOrders(data || []); setLoading(false); });
  }, []);

  const handleSort = (key) => {
    setSortDir((d) => sortKey === key ? (d === 'asc' ? 'desc' : 'asc') : 'desc');
    setSortKey(key);
  };

  const sorted = [...orders].sort((a, b) => {
    const av = a[sortKey] ?? '', bv = b[sortKey] ?? '';
    const cmp = av < bv ? -1 : av > bv ? 1 : 0;
    return sortDir === 'asc' ? cmp : -cmp;
  });

  const exportCsv = () => {
    const headers = ['Дата закрытия', 'Имя', 'Телефон', 'Тип объекта', 'Площадь м²', 'Итоговая стоимость', 'Ответственный'];
    const rows = sorted.map((o) => [
      o.closed_at ? new Date(o.closed_at).toLocaleDateString('ru-RU') : '',
      o.name, o.phone,
      TYPE_LABELS[o.object_type] || o.object_type || '',
      o.area_sqm || '', o.final_cost || '', o.assigned_to || '',
    ]);
    const csv = [headers, ...rows].map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
    const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `zakazy_${new Date().toISOString().split('T')[0]}.csv`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  };

  const ColHeader = ({ label, field }) => (
    <th
      onClick={() => handleSort(field)}
      style={{
        padding: '12px 16px', textAlign: 'left', cursor: 'pointer',
        fontSize: '0.75rem', fontWeight: 700, color: 'var(--text-muted)',
        textTransform: 'uppercase', letterSpacing: '0.4px', userSelect: 'none',
        borderBottom: '2px solid var(--border-light)', whiteSpace: 'nowrap',
      }}
    >
      {label}{sortKey === field ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
    </th>
  );

  if (loading) return <p style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>Загрузка...</p>;

  return (
    <div>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' }}>
        <p style={{ color: 'var(--text-muted)', fontSize: '0.88rem' }}>Завершено: {orders.length}</p>
        <button
          onClick={exportCsv}
          style={{
            display: 'flex', alignItems: 'center', gap: '7px',
            padding: '8px 16px', background: '#fff',
            border: '1.5px solid var(--border-light)', borderRadius: '8px',
            cursor: 'pointer', fontFamily: 'var(--font-heading)',
            fontWeight: 600, fontSize: '0.82rem', color: 'var(--text-main)',
            transition: 'border-color 0.15s',
          }}
          onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent-blue)'}
          onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--border-light)'}
        >
          <Download size={15} />
          Экспорт CSV
        </button>
      </div>

      <div className="glass-panel" style={{ overflow: 'auto' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse' }}>
          <thead>
            <tr>
              <ColHeader label="Дата закрытия" field="closed_at" />
              <ColHeader label="Имя" field="name" />
              <ColHeader label="Тип объекта" field="object_type" />
              <ColHeader label="Площадь" field="area_sqm" />
              <ColHeader label="Итоговая стоимость" field="final_cost" />
              <ColHeader label="Ответственный" field="assigned_to" />
            </tr>
          </thead>
          <tbody>
            {sorted.length === 0 ? (
              <tr>
                <td colSpan={6} style={{ textAlign: 'center', padding: '50px', color: 'var(--text-muted)' }}>
                  Нет завершённых заказов
                </td>
              </tr>
            ) : sorted.map((o) => (
              <tr key={o.id} style={{ borderBottom: '1px solid var(--border-light)' }}>
                <td style={{ padding: '13px 16px', fontSize: '0.88rem', color: 'var(--text-muted)' }}>
                  {o.closed_at ? new Date(o.closed_at).toLocaleDateString('ru-RU') : '—'}
                </td>
                <td style={{ padding: '13px 16px', fontSize: '0.88rem', fontWeight: 600 }}>{o.name}</td>
                <td style={{ padding: '13px 16px', fontSize: '0.88rem', color: 'var(--text-muted)' }}>
                  {TYPE_LABELS[o.object_type] || o.object_type || '—'}
                </td>
                <td style={{ padding: '13px 16px', fontSize: '0.88rem', color: 'var(--text-muted)' }}>
                  {o.area_sqm ? `${o.area_sqm} м²` : '—'}
                </td>
                <td style={{ padding: '13px 16px', fontSize: '0.88rem', fontWeight: 700, color: 'var(--accent-orange)' }}>
                  {o.final_cost ? `${Number(o.final_cost).toLocaleString('ru-RU')} ₽` : '—'}
                </td>
                <td style={{ padding: '13px 16px', fontSize: '0.88rem', color: 'var(--text-muted)' }}>
                  {o.assigned_to || '—'}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
};

export default CompletedOrdersTable;
  • Step 2: Test CSV export

Create a test order and move it to «Закрыт». Navigate to http://localhost:5173/admin/completed. Click «Экспорт CSV» — a .csv file should download with correct Russian headers and UTF-8 BOM (opens correctly in Excel).

  • Step 3: Commit
git add src/admin/analytics/CompletedOrdersTable.jsx
git commit -m "feat: add CompletedOrdersTable with sort and CSV export"

Task 14: LeadForm Supabase integration

Files:

  • Modify: src/components/LeadForm.jsx

  • Step 1: Add Supabase import to LeadForm.jsx

At the top of src/components/LeadForm.jsx, add:

import { supabase } from '../lib/supabase';
  • Step 2: Add Supabase insert inside handleSubmit

Inside handleSubmit, replace the if (response.ok) { ... } block with:

if (response.ok) {
  // Write to Supabase (non-blocking — Telegram already succeeded)
  try {
    await supabase.from('orders').insert({
      name: formData.name,
      phone: formData.phone,
      object_type: formData.objectType,
      status: 'новая',
      source: 'form',
    });
  } catch (dbErr) {
    console.error('Supabase insert failed:', dbErr);
  }

  alert("Заявка успешно отправлена! Наши специалисты свяжутся с вами в ближайшее время для согласования замера.");
  setFormData({ name: '', phone: '', objectType: '' });
} else {
  alert("Произошла ошибка при отправке заявки. Пожалуйста, попробуйте позже или позвоните нам напрямую.");
}
  • Step 3: Test end-to-end
  1. Open http://localhost:5173
  2. Fill in the contact form and submit
  3. Open http://localhost:5173/admin/kanban
  4. A new card should appear in the «Новая» column
  • Step 4: Commit
git add src/components/LeadForm.jsx
git commit -m "feat: insert order to Supabase on lead form submit"

Task 15: Estimator — remove unused windows slider

Files:

  • Modify: src/components/Estimator.jsx

  • Step 1: Remove windows state and slider from Estimator.jsx

Remove line 8: const [windows, setWindows] = useState(5);

Remove the entire <div> block for the windows slider (lines 5670, the block with label "Количество окон" and the range input).

The file should now only have area and thickness sliders. The cost calculation estimatedCost = area * costPerSqM is unchanged.

  • Step 2: Verify calculator still works
npm run dev

Open http://localhost:5173 → scroll to calculator. Only two controls should be visible: «Толщина плёнки» and «Общая площадь». Estimated cost updates correctly. No console errors.

  • Step 3: Commit
git add src/components/Estimator.jsx
git commit -m "fix: remove windows slider from Estimator (unused in cost formula)"

Task 16: Run full test suite and update Obsidian docs

Files:

  • Modify: docs/superpowers/specs/2026-05-07-admin-panel-design.md (update status)

  • Modify: glass-admin/ in Obsidian vault

  • Step 1: Run all tests

npm run test:run

Expected: all tests pass (no failures). If any fail, fix before proceeding.

  • Step 2: Run linter
npm run lint

Fix any errors.

  • Step 3: Update spec status

In docs/superpowers/specs/2026-05-07-admin-panel-design.md, change:

**Статус:** Approved

to:

**Статус:** Implemented
  • Step 4: Update Obsidian note

In /Users/houseassassin/My Projects/claude/projects/glass-admin/2026-05-07-admin-panel-design.md, append:


---

## Статус реализации

**Завершено:** 2026-05-07  
Все 16 задач выполнены. Приложение задеплоено локально.

### Что сделано
- Supabase: таблицы `orders` + `order_events`, RLS, Realtime
- Auth: Supabase email+password, ProtectedRoute
- Канбан: 5 колонок, drag-and-drop (@dnd-kit), Realtime sync
- OrderModal: create/edit + история изменений
- Аналитика: 4 KPI + 4 графика (recharts), фильтр по периоду
- Завершённые: таблица с сортировкой + экспорт CSV
- LeadForm интегрирован с Supabase
- Estimator: удалён неиспользуемый слайдер окон
- Тесты: Vitest + @testing-library/react, 13 тестов
  • Step 5: Final commit
git add docs/superpowers/specs/2026-05-07-admin-panel-design.md
git commit -m "docs: mark admin panel spec as implemented"