# 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 (
    <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**

```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 (
      <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**

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

```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 (
    <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**

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

```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 (
    <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**

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

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

```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 (
    <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**

```bash
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**

```bash
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**

```js
// 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**

```bash
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**

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

```bash
npm run test:run -- src/admin/analytics/__tests__/useAnalytics.test.js
```

Expected: `3 passed`

- [ ] **Step 5: Commit**

```bash
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**

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

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

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

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

```bash
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**

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

```bash
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**

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

```bash
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:
```js
import { supabase } from '../lib/supabase';
```

- [ ] **Step 2: Add Supabase insert inside handleSubmit**

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

```js
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**

```bash
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 56–70, 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**

```bash
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**

```bash
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**

```bash
npm run test:run
```

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

- [ ] **Step 2: Run linter**

```bash
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:

```markdown

---

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

**Завершено:** 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**

```bash
git add docs/superpowers/specs/2026-05-07-admin-panel-design.md
git commit -m "docs: mark admin panel spec as implemented"
```
