Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
80 KiB
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 4–15 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
- Open http://localhost:5173/admin/kanban
- Click «+ Заявка» in header → modal appears
- Fill name + phone + status, click «Сохранить» → card appears in the correct column automatically (Realtime)
- Click a card → modal opens in edit mode with all fields populated
- 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
- Open http://localhost:5173
- Fill in the contact form and submit
- Open http://localhost:5173/admin/kanban
- 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 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
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"