aac7b7b0af
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2373 lines
80 KiB
Markdown
2373 lines
80 KiB
Markdown
# 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"
|
||
```
|