From aac7b7b0afd660b04bf0fa040c072be0a3b5f04d Mon Sep 17 00:00:00 2001 From: houseassassin Date: Sun, 10 May 2026 17:11:25 +0300 Subject: [PATCH] feat: add PWA support for mobile installation Co-Authored-By: Claude Sonnet 4.6 --- .env | 5 + .gitignore | 24 + CLAUDE.md | 32 + README.md | 16 + .../plans/2026-05-07-admin-panel.md | 2372 ++++++++ eslint.config.js | 21 + index.html | 20 + package-lock.json | 5309 +++++++++++++++++ package.json | 2 + public/apple-touch-icon-180x180.png | Bin 0 -> 2202 bytes public/favicon.ico | Bin 0 -> 1192 bytes public/favicon.svg | 1 + public/icons.svg | 24 + public/maskable-icon-512x512.png | Bin 0 -> 13047 bytes public/oskolkam.png | Bin 0 -> 1649211 bytes public/pwa-192x192.png | Bin 0 -> 4299 bytes public/pwa-512x512.png | Bin 0 -> 24177 bytes public/pwa-64x64.png | Bin 0 -> 807 bytes src/components/Comparison.jsx | 119 + src/components/Footer.jsx | 85 + src/components/Hero.jsx | 130 + src/components/Navbar.jsx | 76 + src/components/PhysicsOfSafety.jsx | 150 + src/components/TrustBar.jsx | 53 + src/components/UseCases.jsx | 119 + src/main.jsx | 13 + supabase/.temp/cli-latest | 1 + supabase/.temp/gotrue-version | 1 + supabase/.temp/linked-project.json | 1 + supabase/.temp/pooler-url | 1 + supabase/.temp/postgres-version | 1 + supabase/.temp/project-ref | 1 + supabase/.temp/rest-version | 1 + supabase/.temp/storage-migration | 1 + supabase/.temp/storage-version | 1 + vite.config.js | 36 +- 36 files changed, 8615 insertions(+), 1 deletion(-) create mode 100644 .env create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docs/superpowers/plans/2026-05-07-admin-panel.md create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 public/apple-touch-icon-180x180.png create mode 100644 public/favicon.ico create mode 100644 public/favicon.svg create mode 100644 public/icons.svg create mode 100644 public/maskable-icon-512x512.png create mode 100644 public/oskolkam.png create mode 100644 public/pwa-192x192.png create mode 100644 public/pwa-512x512.png create mode 100644 public/pwa-64x64.png create mode 100644 src/components/Comparison.jsx create mode 100644 src/components/Footer.jsx create mode 100644 src/components/Hero.jsx create mode 100644 src/components/Navbar.jsx create mode 100644 src/components/PhysicsOfSafety.jsx create mode 100644 src/components/TrustBar.jsx create mode 100644 src/components/UseCases.jsx create mode 100644 src/main.jsx create mode 100644 supabase/.temp/cli-latest create mode 100644 supabase/.temp/gotrue-version create mode 100644 supabase/.temp/linked-project.json create mode 100644 supabase/.temp/pooler-url create mode 100644 supabase/.temp/postgres-version create mode 100644 supabase/.temp/project-ref create mode 100644 supabase/.temp/rest-version create mode 100644 supabase/.temp/storage-migration create mode 100644 supabase/.temp/storage-version diff --git a/.env b/.env new file mode 100644 index 0000000..a042e43 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# Скопируйте этот файл в .env и укажите ваши данные от Telegram бота +VITE_TG_BOT_TOKEN="8797771145:AAFenFqVF9QG2afC3tVcXeVE1fMfMAYnCzU" +VITE_TG_CHAT_ID="78171403" +VITE_SUPABASE_URL=https://magrumsifwlstasgencj.supabase.co +VITE_SUPABASE_ANON_KEY=sb_publishable_Zi3u9S5220J0yrktExYecQ_Y7ttrti1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ecda6eb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run dev # Start dev server with HMR (Vite) +npm run build # Production build to dist/ +npm run preview # Preview production build locally +npm run lint # ESLint check +``` + +No test suite is configured. + +## Project Overview + +Russian-language landing page for a ballistic window film (бронирование стекол) company. Single-page marketing site with a lead capture form that posts to a Telegram bot. + +## Architecture + +Single-page React 19 app with Vite, no routing. `App.jsx` composes all sections in vertical order: `Navbar → Hero → PhysicsOfSafety → UseCases → TrustBar → Comparison → Estimator → LeadForm → Footer`. + +**Key patterns:** +- All styling is inline `style={{}}` props — there are no CSS modules or Tailwind classes. The only utility classes are defined in `src/index.css`: `.glass-panel`, `.btn-primary`, `.btn-outline`, `.container`, `.text-accent-blue/yellow/orange`. +- Animations use `framer-motion` (`motion.*` components with `whileInView`, `whileHover`, `whileTap`). +- Icons come from `lucide-react`. +- CSS custom properties (set in `:root` in `index.css`) define the design tokens — use these for any new color or spacing values. + +**Lead form integration:** `LeadForm.jsx` reads `VITE_TG_BOT_TOKEN` and `VITE_TG_CHAT_ID` from `.env` and POSTs to the Telegram Bot API. Copy `.env.example` to `.env` to configure locally. + +**Estimator:** `Estimator.jsx` calculates cost client-side based on film thickness (200µm = 2000 ₽/m², 300µm = 3000 ₽/m²) and glazing area. The `windows` slider state exists but is not used in the cost formula. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a36934d --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/docs/superpowers/plans/2026-05-07-admin-panel.md b/docs/superpowers/plans/2026-05-07-admin-panel.md new file mode 100644 index 0000000..25f9afd --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-admin-panel.md @@ -0,0 +1,2372 @@ +# Admin Panel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a password-protected admin panel at `/admin` with a Kanban board, analytics dashboard, and completed-orders table — all backed by Supabase. + +**Architecture:** Feature-based folders under `src/admin/` with custom hooks for data fetching, React Router v6 for routing, and Supabase Realtime keeping the Kanban live. PublicSite stays untouched except `LeadForm.jsx` gains a silent Supabase insert alongside the existing Telegram call. + +**Tech Stack:** React 19, Vite, Supabase (PostgreSQL + Auth + Realtime), react-router-dom v6, @dnd-kit/core + @dnd-kit/sortable, recharts, Vitest + @testing-library/react + +--- + +## File Map + +| Action | Path | Responsibility | +|--------|------|----------------| +| Create | `src/lib/supabase.js` | Single Supabase client instance | +| Create | `src/test/setup.js` | Vitest global setup | +| Modify | `vite.config.js` | Add Vitest config | +| Modify | `package.json` | Add test scripts | +| Modify | `.env.example` | Add Supabase keys | +| Modify | `src/App.jsx` | Add BrowserRouter + /admin routes | +| Create | `src/admin/auth/useAuth.js` | Session, login, logout hook | +| Create | `src/admin/auth/ProtectedRoute.jsx` | Outlet or redirect | +| Create | `src/admin/auth/LoginPage.jsx` | Login form | +| Create | `src/admin/layout/Sidebar.jsx` | Dark sidebar nav | +| Create | `src/admin/layout/AdminLayout.jsx` | Sidebar + header + Outlet | +| Create | `src/admin/orders/useOrders.js` | CRUD + status + Realtime | +| Create | `src/admin/orders/OrderCard.jsx` | Draggable kanban card | +| Create | `src/admin/orders/KanbanColumn.jsx` | Droppable column | +| Create | `src/admin/orders/KanbanBoard.jsx` | DndContext + 5 columns | +| Create | `src/admin/orders/OrderModal.jsx` | Edit/create modal + event log | +| Create | `src/admin/analytics/useAnalytics.js` | Aggregated analytics queries | +| Create | `src/admin/analytics/charts/FunnelChart.jsx` | Horizontal bar by stage | +| Create | `src/admin/analytics/charts/RevenueChart.jsx` | Line chart by month | +| Create | `src/admin/analytics/charts/ObjectTypeChart.jsx` | Donut by object type | +| Create | `src/admin/analytics/charts/AvgTimeChart.jsx` | Bar: avg days per stage | +| Create | `src/admin/analytics/AnalyticsDashboard.jsx` | KPI cards + 4 charts | +| Create | `src/admin/analytics/CompletedOrdersTable.jsx` | Sortable table + CSV export | +| Modify | `src/components/LeadForm.jsx` | Add Supabase insert | +| Modify | `src/components/Estimator.jsx` | Remove unused windows slider | + +--- + +## Task 1: Install dependencies, test setup, Supabase client, env + +**Files:** +- Modify: `package.json` +- Modify: `vite.config.js` +- Create: `src/test/setup.js` +- Create: `src/lib/supabase.js` +- Modify: `.env.example` + +- [ ] **Step 1: Install packages** + +```bash +cd "/Users/houseassassin/My Projects/glass" +npm install @supabase/supabase-js react-router-dom recharts @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities +npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom +``` + +Expected: no errors, packages appear in `node_modules` + +- [ ] **Step 2: Add test scripts to package.json** + +In `package.json`, update the `"scripts"` block to: +```json +"scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run" +} +``` + +- [ ] **Step 3: Update vite.config.js** + +```js +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.js'], + }, +}) +``` + +- [ ] **Step 4: Create src/test/setup.js** + +```js +import '@testing-library/jest-dom' +``` + +- [ ] **Step 5: Create src/lib/supabase.js** + +```js +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY + +export const supabase = createClient(supabaseUrl, supabaseAnonKey) +``` + +- [ ] **Step 6: Update .env.example** + +Append to the existing file: +``` +VITE_SUPABASE_URL=https://your-project.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key +``` + +- [ ] **Step 7: Verify test runner works** + +```bash +npm run test:run +``` + +Expected output: `No test files found` (or 0 tests, 0 failures — not an error) + +- [ ] **Step 8: Commit** + +```bash +git init +git add package.json package-lock.json vite.config.js src/test/setup.js src/lib/supabase.js .env.example +git commit -m "feat: add Supabase, React Router, dnd-kit, recharts, Vitest" +``` + +--- + +## Task 2: Supabase database schema + +**Files:** None (SQL run in Supabase dashboard) + +- [ ] **Step 1: Log into Supabase dashboard → SQL Editor → New query** + +Paste and run: + +```sql +-- orders table +CREATE TABLE orders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz DEFAULT now(), + name text NOT NULL, + phone text NOT NULL, + object_type text, + area_sqm numeric, + film_thickness integer, + estimated_cost numeric, + final_cost numeric, + status text DEFAULT 'новая' + CHECK (status IN ('новая','замер','согласование','монтаж','закрыт')), + notes text, + assigned_to text, + closed_at timestamptz, + source text DEFAULT 'form' CHECK (source IN ('form','manual')) +); + +-- order_events table +CREATE TABLE order_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + order_id uuid REFERENCES orders(id) ON DELETE CASCADE NOT NULL, + created_at timestamptz DEFAULT now(), + created_by text, + event_type text CHECK (event_type IN ('status_change','note','edit')), + description text +); + +-- Enable RLS +ALTER TABLE orders ENABLE ROW LEVEL SECURITY; +ALTER TABLE order_events ENABLE ROW LEVEL SECURITY; + +-- Authenticated users get full access +CREATE POLICY "auth_all_orders" ON orders + FOR ALL TO authenticated USING (true) WITH CHECK (true); + +CREATE POLICY "auth_all_events" ON order_events + FOR ALL TO authenticated USING (true) WITH CHECK (true); + +-- Enable Realtime for orders +ALTER PUBLICATION supabase_realtime ADD TABLE orders; +``` + +- [ ] **Step 2: Verify in Table Editor** + +Navigate to Table Editor → confirm `orders` and `order_events` exist with the correct columns. + +- [ ] **Step 3: Create a test admin user** + +In Supabase → Authentication → Users → Add user. Use the email and password you'll log in with. + +--- + +## Task 3: React Router setup in App.jsx + +**Files:** +- Modify: `src/App.jsx` + +- [ ] **Step 1: Rewrite src/App.jsx** + +```jsx +import React from 'react'; +import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; +import Navbar from './components/Navbar'; +import Hero from './components/Hero'; +import PhysicsOfSafety from './components/PhysicsOfSafety'; +import UseCases from './components/UseCases'; +import Estimator from './components/Estimator'; +import TrustBar from './components/TrustBar'; +import Comparison from './components/Comparison'; +import LeadForm from './components/LeadForm'; +import Footer from './components/Footer'; + +// Admin imports (filled in later tasks) +const LoginPage = React.lazy(() => import('./admin/auth/LoginPage')); +const ProtectedRoute = React.lazy(() => import('./admin/auth/ProtectedRoute')); +const AdminLayout = React.lazy(() => import('./admin/layout/AdminLayout')); +const KanbanBoard = React.lazy(() => import('./admin/orders/KanbanBoard')); +const AnalyticsDashboard = React.lazy(() => import('./admin/analytics/AnalyticsDashboard')); +const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/CompletedOrdersTable')); + +function PublicSite() { + return ( +
+ +
+ + + + + + + +
+
+
+ ); +} + +function App() { + return ( + + Загрузка...}> + + } /> + } /> + } /> + }> + }> + } /> + } /> + } /> + + + + + + ); +} + +export default App; +``` + +- [ ] **Step 2: Start dev server and verify public site still works** + +```bash +npm run dev +``` + +Open http://localhost:5173 — public landing page renders normally. +Open http://localhost:5173/admin — shows «Загрузка...» then an error overlay (lazy modules don't exist yet). **This is expected.** The public site at `/` is unaffected. Admin routes will work correctly once Tasks 4–15 are complete. + +- [ ] **Step 3: Commit** + +```bash +git add src/App.jsx +git commit -m "feat: add React Router, lazy-load admin routes" +``` + +--- + +## Task 4: useAuth hook + +**Files:** +- Create: `src/admin/auth/useAuth.js` +- Create: `src/admin/auth/__tests__/useAuth.test.js` + +- [ ] **Step 1: Create the test file** + +Create directory: `src/admin/auth/__tests__/` + +```js +// src/admin/auth/__tests__/useAuth.test.js +import { renderHook, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +const mockUnsubscribe = vi.fn(); +const mockGetSession = vi.fn(); +const mockOnAuthStateChange = vi.fn(() => ({ + data: { subscription: { unsubscribe: mockUnsubscribe } }, +})); +const mockSignInWithPassword = vi.fn(); +const mockSignOut = vi.fn(); + +vi.mock('../../../lib/supabase', () => ({ + supabase: { + auth: { + getSession: mockGetSession, + onAuthStateChange: mockOnAuthStateChange, + signInWithPassword: mockSignInWithPassword, + signOut: mockSignOut, + }, + }, +})); + +import { useAuth } from '../useAuth'; + +describe('useAuth', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockOnAuthStateChange.mockReturnValue({ + data: { subscription: { unsubscribe: mockUnsubscribe } }, + }); + }); + + it('starts with loading=true and session=null', () => { + mockGetSession.mockReturnValue(new Promise(() => {})); // never resolves + const { result } = renderHook(() => useAuth()); + expect(result.current.loading).toBe(true); + expect(result.current.session).toBe(null); + }); + + it('sets loading=false and session after getSession resolves', async () => { + const fakeSession = { user: { email: 'admin@test.com' } }; + mockGetSession.mockResolvedValue({ data: { session: fakeSession } }); + const { result } = renderHook(() => useAuth()); + await act(async () => {}); + expect(result.current.loading).toBe(false); + expect(result.current.session).toEqual(fakeSession); + }); + + it('login calls signInWithPassword with email and password', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + mockSignInWithPassword.mockResolvedValue({ error: null }); + const { result } = renderHook(() => useAuth()); + await act(async () => { + await result.current.login('admin@test.com', 'secret'); + }); + expect(mockSignInWithPassword).toHaveBeenCalledWith({ + email: 'admin@test.com', + password: 'secret', + }); + }); + + it('login returns error when credentials are wrong', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + const fakeError = new Error('Invalid credentials'); + mockSignInWithPassword.mockResolvedValue({ error: fakeError }); + const { result } = renderHook(() => useAuth()); + let loginResult; + await act(async () => { + loginResult = await result.current.login('bad@test.com', 'wrong'); + }); + expect(loginResult.error).toBe(fakeError); + }); + + it('logout calls signOut', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + mockSignOut.mockResolvedValue({}); + const { result } = renderHook(() => useAuth()); + await act(async () => { + await result.current.logout(); + }); + expect(mockSignOut).toHaveBeenCalled(); + }); + + it('unsubscribes on unmount', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }); + const { unmount } = renderHook(() => useAuth()); + await act(async () => {}); + unmount(); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npm run test:run -- src/admin/auth/__tests__/useAuth.test.js +``` + +Expected: FAIL with `Cannot find module '../useAuth'` + +- [ ] **Step 3: Create src/admin/auth/useAuth.js** + +```js +import { useState, useEffect } from 'react'; +import { supabase } from '../../lib/supabase'; + +export function useAuth() { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + supabase.auth.getSession().then(({ data: { session } }) => { + setSession(session); + setLoading(false); + }); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + setSession(session); + }); + + return () => subscription.unsubscribe(); + }, []); + + const login = async (email, password) => { + const { error } = await supabase.auth.signInWithPassword({ email, password }); + return { error }; + }; + + const logout = async () => { + await supabase.auth.signOut(); + }; + + return { session, loading, login, logout }; +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npm run test:run -- src/admin/auth/__tests__/useAuth.test.js +``` + +Expected: `6 passed` + +- [ ] **Step 5: Commit** + +```bash +git add src/admin/auth/useAuth.js src/admin/auth/__tests__/useAuth.test.js +git commit -m "feat: add useAuth hook with Supabase Auth session management" +``` + +--- + +## Task 5: ProtectedRoute and LoginPage + +**Files:** +- Create: `src/admin/auth/ProtectedRoute.jsx` +- Create: `src/admin/auth/LoginPage.jsx` + +- [ ] **Step 1: Create src/admin/auth/ProtectedRoute.jsx** + +```jsx +import React from 'react'; +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useAuth } from './useAuth'; + +const ProtectedRoute = () => { + const { session, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return ( +
+ Загрузка... +
+ ); + } + + if (!session) { + return ; + } + + return ; +}; + +export default ProtectedRoute; +``` + +- [ ] **Step 2: Create src/admin/auth/LoginPage.jsx** + +```jsx +import React, { useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { Shield } from 'lucide-react'; +import { useAuth } from './useAuth'; + +const LoginPage = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const from = location.state?.from?.pathname || '/admin/kanban'; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSubmitting(true); + setError(''); + const { error } = await login(email, password); + if (error) { + setError('Неверный email или пароль'); + setSubmitting(false); + } else { + navigate(from, { replace: true }); + } + }; + + const inputStyle = { + width: '100%', padding: '14px', + background: 'var(--bg-lighter)', + border: '2px solid var(--border-light)', + borderRadius: '8px', + color: 'var(--text-main)', + fontSize: '1rem', + outline: 'none', + fontFamily: 'var(--font-body)', + }; + + const labelStyle = { + display: 'block', marginBottom: '8px', + fontSize: '0.85rem', fontWeight: 700, + color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', + }; + + return ( +
+
+
+ + + Осколкам.Нет + +
+ +

+ Вход в панель +

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

{error}

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

+ {title} +

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

+ {order.name} +

+
+ {order.object_type && ( + + {OBJECT_TYPE_LABELS[order.object_type] || order.object_type} + + )} + {order.area_sqm && ( + {order.area_sqm} м² + )} +
+ {(order.final_cost || order.estimated_cost) && ( +

+ {Number(order.final_cost || order.estimated_cost).toLocaleString('ru-RU')} ₽ +

+ )} +

+ {new Date(order.created_at).toLocaleDateString('ru-RU')} +

+
+
+ ); +}; + +export default OrderCard; +``` + +- [ ] **Step 2: Create src/admin/orders/KanbanColumn.jsx** + +```jsx +import React from 'react'; +import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import OrderCard from './OrderCard'; + +const STATUS_COLORS = { + 'новая': '#f59e0b', + 'замер': '#0ea5e9', + 'согласование': '#8b5cf6', + 'монтаж': '#f97316', + 'закрыт': '#22c55e', +}; + +const KanbanColumn = ({ status, orders, onCardClick }) => { + const { setNodeRef, isOver } = useDroppable({ id: status }); + + return ( +
+ {/* Header */} +
+ + {status} + + + {orders.length} + +
+ + {/* Drop zone */} +
+ o.id)} strategy={verticalListSortingStrategy}> + {orders.map((order) => ( + onCardClick(order)} /> + ))} + +
+
+ ); +}; + +export default KanbanColumn; +``` + +- [ ] **Step 3: Create src/admin/orders/KanbanBoard.jsx** + +```jsx +import React, { useState } from 'react'; +import { DndContext, closestCorners, DragOverlay, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { useOutletContext } from 'react-router-dom'; +import { useOrders } from './useOrders'; +import { useAuth } from '../auth/useAuth'; +import KanbanColumn from './KanbanColumn'; +import OrderCard from './OrderCard'; +import OrderModal from './OrderModal'; + +const STATUSES = ['новая', 'замер', 'согласование', 'монтаж', 'закрыт']; + +const KanbanBoard = () => { + const { ordersByStatus, loading, updateStatus } = useOrders(); + const { session } = useAuth(); + const { showNewOrder, setShowNewOrder } = useOutletContext() || {}; + const [activeOrder, setActiveOrder] = useState(null); + const [selectedOrder, setSelectedOrder] = useState(null); + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); + + const findOrderStatus = (orderId) => { + for (const status of STATUSES) { + if (ordersByStatus[status]?.find((o) => o.id === orderId)) return status; + } + return null; + }; + + const handleDragStart = ({ active }) => { + const status = findOrderStatus(active.id); + if (status) setActiveOrder(ordersByStatus[status].find((o) => o.id === active.id)); + }; + + const handleDragEnd = async ({ active, over }) => { + setActiveOrder(null); + if (!over) return; + + let targetStatus = STATUSES.includes(over.id) ? over.id : findOrderStatus(over.id); + const sourceStatus = findOrderStatus(active.id); + + if (targetStatus && sourceStatus !== targetStatus) { + await updateStatus(active.id, targetStatus, session?.user?.email); + } + }; + + if (loading) { + return ( +
+ Загрузка заказов... +
+ ); + } + + return ( + <> + +
+ {STATUSES.map((status) => ( + + ))} +
+ + {activeOrder && {}} />} + +
+ + {selectedOrder && ( + setSelectedOrder(null)} + onSaved={() => setSelectedOrder(null)} + /> + )} + + {showNewOrder && ( + setShowNewOrder(false)} + onSaved={() => setShowNewOrder(false)} + /> + )} + + ); +}; + +export default KanbanBoard; +``` + +- [ ] **Step 4: Verify Kanban renders** + +```bash +npm run dev +``` + +Log in → http://localhost:5173/admin/kanban. Should see 5 empty columns. Clicking «+ Заявка» in header will fail until OrderModal is built (next task). Console should be clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/admin/orders/OrderCard.jsx src/admin/orders/KanbanColumn.jsx src/admin/orders/KanbanBoard.jsx +git commit -m "feat: add Kanban board with drag-and-drop columns and cards" +``` + +--- + +## Task 9: OrderModal + +**Files:** +- Create: `src/admin/orders/OrderModal.jsx` + +- [ ] **Step 1: Create src/admin/orders/OrderModal.jsx** + +```jsx +import React, { useState, useEffect } from 'react'; +import { X, Save } from 'lucide-react'; +import { supabase } from '../../lib/supabase'; +import { useAuth } from '../auth/useAuth'; + +const STATUSES = ['новая', 'замер', 'согласование', 'монтаж', 'закрыт']; +const OBJECT_TYPES = [ + { value: 'industrial', label: 'Промышленный объект' }, + { value: 'commercial', label: 'Коммерческая штаб-квартира' }, + { value: 'infrastructure', label: 'Критическая инфраструктура' }, + { value: 'residential', label: 'Частная резиденция' }, + { value: 'other', label: 'Другое' }, +]; + +const inputStyle = { + width: '100%', padding: '10px 12px', + background: 'var(--bg-lighter)', + border: '1.5px solid var(--border-light)', + borderRadius: '6px', + color: 'var(--text-main)', fontSize: '0.92rem', + outline: 'none', fontFamily: 'var(--font-body)', + boxSizing: 'border-box', +}; + +const labelStyle = { + display: 'block', marginBottom: '5px', + fontSize: '0.75rem', fontWeight: 700, + color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', +}; + +const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => { + const { session } = useAuth(); + const [form, setForm] = useState({ + name: order?.name || '', + phone: order?.phone || '', + object_type: order?.object_type || '', + area_sqm: order?.area_sqm ?? '', + film_thickness: order?.film_thickness ?? 200, + estimated_cost: order?.estimated_cost ?? '', + final_cost: order?.final_cost ?? '', + notes: order?.notes || '', + assigned_to: order?.assigned_to || '', + status: order?.status || 'новая', + }); + const [events, setEvents] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (order?.id) { + supabase + .from('order_events') + .select('*') + .eq('order_id', order.id) + .order('created_at', { ascending: false }) + .then(({ data }) => setEvents(data || [])); + } + }, [order?.id]); + + const set = (e) => setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); + + const handleSave = async () => { + if (!form.name.trim() || !form.phone.trim()) { + setError('Имя и телефон обязательны'); + return; + } + setSaving(true); + setError(''); + const userEmail = session?.user?.email; + + if (mode === 'create') { + const { error } = await supabase + .from('orders') + .insert({ ...form, source: 'manual' }); + if (error) { setError(error.message); setSaving(false); return; } + // Realtime in KanbanBoard will pick up the new row automatically + } else { + const statusChanged = form.status !== order.status; + const updates = { ...form }; + if (statusChanged && form.status === 'закрыт') updates.closed_at = new Date().toISOString(); + + const { error } = await supabase.from('orders').update(updates).eq('id', order.id); + if (error) { setError(error.message); setSaving(false); return; } + + await supabase.from('order_events').insert({ + order_id: order.id, + created_by: userEmail, + event_type: statusChanged ? 'status_change' : 'edit', + description: statusChanged + ? `Статус изменён: ${order.status} → ${form.status}` + : 'Данные заказа обновлены', + }); + } + onSaved?.(); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

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

+ +
+ + {/* Fields */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +