Files
glass/docs/superpowers/plans/2026-05-10-admin-panel-v2.md
T

53 KiB
Raw Blame History

Admin Panel v2 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 user management (via Supabase Edge Functions), configurable pricing (DB-driven calculator), and three new contact fields (Telegram, Max, address) to the Осколкам.Нет admin panel.

Architecture: Three independent blocks — pricing stored in a settings table read by both public Estimator and admin SettingsPage; user management via three Edge Functions that proxy the Supabase Auth Admin API securely; new contact fields appended to OrderModal form state and UI. Each new hook has vitest unit tests following the vi.hoisted() pattern already used in this codebase.

Tech Stack: React 19 + Vite, Supabase (PostgreSQL + Auth + Edge Functions), react-router-dom v7, lucide-react icons, inline style={{}} throughout (no Tailwind, no CSS modules), vitest + @testing-library/react.


File Map

Action Path Purpose
Create src/hooks/usePricing.js Public hook: reads price_200/price_300 from settings. Used by Estimator.
Create src/hooks/__tests__/usePricing.test.js Unit tests for usePricing
Create src/admin/settings/useSettings.js Admin hook: read + update prices. Used by SettingsPage.
Create src/admin/settings/__tests__/useSettings.test.js Unit tests for useSettings
Create src/admin/settings/SettingsPage.jsx Admin UI: two price inputs + save button
Create src/admin/users/useAdminUsers.js Hook: calls Edge Functions via supabase.functions.invoke()
Create src/admin/users/__tests__/useAdminUsers.test.js Unit tests for useAdminUsers
Create src/admin/users/UserManagementPage.jsx Admin UI: user table + invite form + delete with confirm
Create supabase/functions/list-admin-users/index.ts Edge Function: lists all auth users
Create supabase/functions/invite-admin-user/index.ts Edge Function: invites user by email
Create supabase/functions/delete-admin-user/index.ts Edge Function: deletes user by id
Modify src/components/Estimator.jsx Replace hardcoded prices with usePricing()
Modify src/admin/orders/OrderModal.jsx Add telegram_username, max_username, address fields
Modify src/admin/layout/Sidebar.jsx Add Пользователи + Настройки nav items
Modify src/App.jsx Add lazy routes for /admin/users and /admin/settings

Task 1: Database Migrations

Files:

  • No code files — run SQL manually in Supabase SQL Editor (Dashboard → SQL Editor → New Query)

  • Step 1: Run the settings table migration

Paste and run in Supabase SQL Editor:

CREATE TABLE settings (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  updated_at TIMESTAMPTZ DEFAULT now()
);

INSERT INTO settings (key, value) VALUES
  ('price_200', '2000'),
  ('price_300', '3000');

ALTER TABLE settings ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Anyone can read settings"
  ON settings FOR SELECT USING (true);

CREATE POLICY "Auth users can update settings"
  ON settings FOR UPDATE USING (auth.role() = 'authenticated');

Expected: "Success. No rows returned."

  • Step 2: Run the orders column migration

Paste and run in Supabase SQL Editor:

ALTER TABLE orders
  ADD COLUMN IF NOT EXISTS telegram_username TEXT,
  ADD COLUMN IF NOT EXISTS max_username TEXT,
  ADD COLUMN IF NOT EXISTS address TEXT;

Expected: "Success. No rows returned."

  • Step 3: Verify

In Supabase Table Editor, confirm settings has 2 rows (price_200, price_300) and orders has the three new columns.

  • Step 4: Commit
git add -A
git commit -m "docs: record DB migrations for admin panel v2"

Task 2: usePricing Hook

Files:

  • Create: src/hooks/usePricing.js

  • Create: src/hooks/__tests__/usePricing.test.js

  • Step 1: Write the failing test

Create src/hooks/__tests__/usePricing.test.js:

import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';

const { mockIn } = vi.hoisted(() => ({
  mockIn: vi.fn(),
}));

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

import { usePricing } from '../usePricing';

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

  it('starts with default prices {200: 2000, 300: 3000} and loading=true', () => {
    mockIn.mockReturnValue(new Promise(() => {}));
    const { result } = renderHook(() => usePricing());
    expect(result.current.loading).toBe(true);
    expect(result.current.prices).toEqual({ 200: 2000, 300: 3000 });
  });

  it('updates prices from DB and sets loading=false', async () => {
    mockIn.mockResolvedValue({
      data: [
        { key: 'price_200', value: '1500' },
        { key: 'price_300', value: '2500' },
      ],
      error: null,
    });
    const { result } = renderHook(() => usePricing());
    await act(async () => {});
    expect(result.current.loading).toBe(false);
    expect(result.current.prices).toEqual({ 200: 1500, 300: 2500 });
  });

  it('keeps default prices when DB returns empty data', async () => {
    mockIn.mockResolvedValue({ data: null, error: null });
    const { result } = renderHook(() => usePricing());
    await act(async () => {});
    expect(result.current.prices).toEqual({ 200: 2000, 300: 3000 });
  });
});
  • Step 2: Run to confirm failure
npm run test:run -- src/hooks/__tests__/usePricing.test.js

Expected: FAIL — "Cannot find module '../usePricing'"

  • Step 3: Implement usePricing

Create src/hooks/usePricing.js:

import { useState, useEffect } from 'react';
import { supabase } from '../lib/supabase';

export function usePricing() {
  const [prices, setPrices] = useState({ 200: 2000, 300: 3000 });
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    supabase
      .from('settings')
      .select('key, value')
      .in('key', ['price_200', 'price_300'])
      .then(({ data }) => {
        if (data) {
          const map = {};
          data.forEach(({ key, value }) => {
            const thickness = parseInt(key.replace('price_', ''), 10);
            map[thickness] = Number(value);
          });
          setPrices((prev) => ({ ...prev, ...map }));
        }
        setLoading(false);
      });
  }, []);

  return { prices, loading };
}
  • Step 4: Run tests to confirm pass
npm run test:run -- src/hooks/__tests__/usePricing.test.js

Expected: PASS — 3 tests

  • Step 5: Commit
git add src/hooks/usePricing.js src/hooks/__tests__/usePricing.test.js
git commit -m "feat: add usePricing hook reading prices from settings table"

Task 3: Update Estimator to Use usePricing

Files:

  • Modify: src/components/Estimator.jsx

The file is at src/components/Estimator.jsx. It currently has:

const costPerSqM = thickness === 200 ? 2000 : 3000;
const estimatedCost = area * costPerSqM;
  • Step 1: Replace hardcoded prices with usePricing

Replace the top of the Estimator component function (lines 611) and the price display:

import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Calculator } from 'lucide-react';
import { usePricing } from '../hooks/usePricing';

const Estimator = () => {
  const [area, setArea] = useState(20);
  const [thickness, setThickness] = useState(200);
  const { prices, loading: pricesLoading } = usePricing();

  const costPerSqM = prices[thickness] ?? (thickness === 200 ? 2000 : 3000);
  const estimatedCost = area * costPerSqM;

Then in the JSX, replace the cost display div (currently: {estimatedCost.toLocaleString('ru-RU')} ₽) with:

<div style={{ fontSize: '3.8rem', fontWeight: 800, fontFamily: 'var(--font-heading)', color: 'var(--text-main)', marginBottom: '20px' }}>
  {pricesLoading ? '...' : estimatedCost.toLocaleString('ru-RU')} 
</div>

The full updated file content:

import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Calculator } from 'lucide-react';
import { usePricing } from '../hooks/usePricing';

const Estimator = () => {
  const [area, setArea] = useState(20);
  const [thickness, setThickness] = useState(200);
  const { prices, loading: pricesLoading } = usePricing();

  const costPerSqM = prices[thickness] ?? (thickness === 200 ? 2000 : 3000);
  const estimatedCost = area * costPerSqM;

  return (
    <section id="estimator" style={{ backgroundColor: 'var(--bg-lighter)' }}>
      <div className="container">
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: '50px', alignItems: 'center' }}>
          
          <div style={{ flex: '1 1 400px' }}>
            <motion.div
              initial={{ opacity: 0, x: -50 }}
              whileInView={{ opacity: 1, x: 0 }}
              transition={{ duration: 0.6, type: "spring" }}
              viewport={{ once: true }}
            >
              <h2 style={{ fontSize: '3.5rem', marginBottom: '20px' }}>
                КАЛЬКУЛЯТОР <span className="text-accent-blue">ЗАЩИТЫ</span>
              </h2>
              <p style={{ color: 'var(--text-muted)', fontSize: '1.2rem', marginBottom: '40px', lineHeight: '1.6' }}>
                Рассчитайте примерную стоимость обеспечения безопасности вашего периметра. Окончательная стоимость определяется после профессионального осмотра объекта.
              </p>
              
              <div className="glass-panel" style={{ padding: '40px' }}>
                <div style={{ marginBottom: '35px' }}>
                  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '15px' }}>
                    <label style={{ fontWeight: 600, color: 'var(--text-main)', fontSize: '1.1rem' }}>Толщина пленки</label>
                  </div>
                  <select 
                    value={thickness} 
                    onChange={(e) => setThickness(parseInt(e.target.value))}
                    style={{ 
                      width: '100%', padding: '12px', 
                      background: 'var(--bg-lighter)', 
                      border: '2px solid var(--border-light)', 
                      borderRadius: '8px',
                      color: 'var(--text-main)',
                      fontSize: '1rem',
                      outline: 'none',
                      cursor: 'pointer'
                    }}
                  >
                    <option value={200}>200 микрон (от {prices[200].toLocaleString('ru-RU')} /м²)</option>
                    <option value={300}>300 микрон (от {prices[300].toLocaleString('ru-RU')} /м²)</option>
                  </select>
                </div>

                <div style={{ marginBottom: '20px' }}>
                  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '15px' }}>
                    <label style={{ fontWeight: 600, color: 'var(--text-main)', fontSize: '1.1rem' }}>Общая площадь (м²)</label>
                    <span className="text-accent-blue" style={{ fontWeight: '800', fontSize: '1.2rem' }}>{area}</span>
                  </div>
                  <input 
                    type="range" 
                    min="5" 
                    max="500" 
                    step="5"
                    value={area} 
                    onChange={(e) => setArea(parseInt(e.target.value))}
                    style={{ width: '100%', accentColor: 'var(--accent-blue)', height: '8px', borderRadius: '4px' }}
                  />
                </div>
              </div>
            </motion.div>
          </div>

          <div style={{ flex: '1 1 400px' }}>
            <motion.div
              initial={{ opacity: 0, scale: 0.9 }}
              whileInView={{ opacity: 1, scale: 1 }}
              transition={{ duration: 0.6, delay: 0.2, type: "spring" }}
              viewport={{ once: true }}
              className="glass-panel"
              style={{ 
                padding: '50px', 
                textAlign: 'center',
                border: '2px solid rgba(14, 165, 233, 0.2)',
                boxShadow: '0 20px 50px rgba(14, 165, 233, 0.1)'
              }}
            >
              <motion.div
                animate={{ y: [0, -10, 0] }}
                transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
              >
                <Calculator size={56} className="text-accent-blue" style={{ margin: '0 auto 25px' }} />
              </motion.div>
              
              <h3 style={{ fontSize: '1.3rem', color: 'var(--text-muted)', marginBottom: '15px' }}>Ориентировочная стоимость</h3>
              <div style={{ fontSize: '3.8rem', fontWeight: 800, fontFamily: 'var(--font-heading)', color: 'var(--text-main)', marginBottom: '20px' }}>
                {pricesLoading ? '...' : estimatedCost.toLocaleString('ru-RU')} 
              </div>
              <p style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '35px', lineHeight: '1.6' }}>
                Включает бронематериалы, специализированный монтаж и финальную проверку.
              </p>
              
              <motion.a 
                whileHover={{ scale: 1.05 }}
                whileTap={{ scale: 0.95 }}
                href="#contact" 
                className="btn-primary" 
                style={{ display: 'block', width: '100%', textAlign: 'center' }}
              >
                Запросить точный расчет
              </motion.a>
            </motion.div>
          </div>

        </div>
      </div>
    </section>
  );
};

export default Estimator;
  • Step 2: Verify the app builds
npm run build

Expected: no errors

  • Step 3: Commit
git add src/components/Estimator.jsx
git commit -m "feat: estimator reads prices from DB via usePricing"

Task 4: useSettings Hook

Files:

  • Create: src/admin/settings/useSettings.js

  • Create: src/admin/settings/__tests__/useSettings.test.js

  • Step 1: Write the failing test

Create src/admin/settings/__tests__/useSettings.test.js:

import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';

const { mockIn, mockEq } = vi.hoisted(() => ({
  mockIn: vi.fn(),
  mockEq: vi.fn(),
}));

vi.mock('../../../lib/supabase', () => ({
  supabase: {
    from: vi.fn(() => ({
      select: vi.fn(() => ({ in: mockIn })),
      update: vi.fn(() => ({ eq: mockEq })),
    })),
  },
}));

import { useSettings } from '../useSettings';

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

  it('loads prices on mount', async () => {
    mockIn.mockResolvedValue({
      data: [{ key: 'price_200', value: '2000' }, { key: 'price_300', value: '3000' }],
      error: null,
    });
    const { result } = renderHook(() => useSettings());
    await act(async () => {});
    expect(result.current.loading).toBe(false);
    expect(result.current.prices).toEqual({ 200: 2000, 300: 3000 });
  });

  it('sets error when fetch fails', async () => {
    mockIn.mockResolvedValue({ data: null, error: { message: 'DB error' } });
    const { result } = renderHook(() => useSettings());
    await act(async () => {});
    expect(result.current.error).toBe('DB error');
  });

  it('updatePrice calls update().eq() and updates local state', async () => {
    mockIn.mockResolvedValue({
      data: [{ key: 'price_200', value: '2000' }, { key: 'price_300', value: '3000' }],
      error: null,
    });
    mockEq.mockResolvedValue({ error: null });
    const { result } = renderHook(() => useSettings());
    await act(async () => {});
    await act(async () => {
      await result.current.updatePrice('price_200', 1800);
    });
    expect(mockEq).toHaveBeenCalledWith('key', 'price_200');
    expect(result.current.prices[200]).toBe(1800);
  });

  it('updatePrice returns error without mutating state when DB fails', async () => {
    mockIn.mockResolvedValue({
      data: [{ key: 'price_200', value: '2000' }, { key: 'price_300', value: '3000' }],
      error: null,
    });
    mockEq.mockResolvedValue({ error: { message: 'update failed' } });
    const { result } = renderHook(() => useSettings());
    await act(async () => {});
    let updateResult;
    await act(async () => {
      updateResult = await result.current.updatePrice('price_200', 9999);
    });
    expect(updateResult.error.message).toBe('update failed');
    expect(result.current.prices[200]).toBe(2000);
  });
});
  • Step 2: Run to confirm failure
npm run test:run -- src/admin/settings/__tests__/useSettings.test.js

Expected: FAIL — "Cannot find module '../useSettings'"

  • Step 3: Implement useSettings

Create src/admin/settings/useSettings.js:

import { useState, useEffect, useCallback } from 'react';
import { supabase } from '../../lib/supabase';

export function useSettings() {
  const [prices, setPrices] = useState({ 200: 2000, 300: 3000 });
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchPrices = useCallback(async () => {
    const { data, error } = await supabase
      .from('settings')
      .select('key, value')
      .in('key', ['price_200', 'price_300']);

    if (error) {
      setError(error.message);
    } else if (data) {
      const map = {};
      data.forEach(({ key, value }) => {
        const thickness = parseInt(key.replace('price_', ''), 10);
        map[thickness] = Number(value);
      });
      setPrices((prev) => ({ ...prev, ...map }));
    }
    setLoading(false);
  }, []);

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

  const updatePrice = async (key, value) => {
    const { error } = await supabase
      .from('settings')
      .update({ value: String(value), updated_at: new Date().toISOString() })
      .eq('key', key);

    if (!error) {
      const thickness = parseInt(key.replace('price_', ''), 10);
      setPrices((prev) => ({ ...prev, [thickness]: Number(value) }));
    }
    return { error };
  };

  return { prices, loading, error, updatePrice };
}
  • Step 4: Run tests to confirm pass
npm run test:run -- src/admin/settings/__tests__/useSettings.test.js

Expected: PASS — 4 tests

  • Step 5: Commit
git add src/admin/settings/useSettings.js src/admin/settings/__tests__/useSettings.test.js
git commit -m "feat: add useSettings hook for admin price management"

Task 5: SettingsPage Component

Files:

  • Create: src/admin/settings/SettingsPage.jsx

  • Step 1: Create SettingsPage.jsx

Create src/admin/settings/SettingsPage.jsx:

import { useState } from 'react';
import { Save, DollarSign } from 'lucide-react';
import { useSettings } from './useSettings';

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 SettingsPage = () => {
  const { prices, loading, error: fetchError, updatePrice } = useSettings();
  const [form, setForm] = useState(null);
  const [saving, setSaving] = useState(false);
  const [savedMsg, setSavedMsg] = useState('');
  const [saveError, setSaveError] = useState('');

  // form is null until user edits; fall back to loaded prices
  const formValues = form ?? prices;

  const handleChange = (thickness, value) => {
    setForm((prev) => ({ ...(prev ?? prices), [thickness]: Number(value) }));
  };

  const handleSave = async () => {
    setSaving(true);
    setSavedMsg('');
    setSaveError('');
    const [r200, r300] = await Promise.all([
      updatePrice('price_200', formValues[200]),
      updatePrice('price_300', formValues[300]),
    ]);
    setSaving(false);
    if (r200.error || r300.error) {
      setSaveError(r200.error?.message ?? r300.error?.message ?? 'Ошибка сохранения');
    } else {
      setSavedMsg('Цены сохранены');
      setTimeout(() => setSavedMsg(''), 3000);
    }
  };

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

  return (
    <div style={{ padding: '30px', maxWidth: '480px' }}>
      <h1 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.5rem', fontWeight: 800, marginBottom: '24px' }}>
        Настройки
      </h1>

      <div className="glass-panel" style={{ padding: '28px' }}>
        <h2 style={{
          fontFamily: 'var(--font-heading)', fontSize: '1rem', fontWeight: 700,
          marginBottom: '20px', display: 'flex', alignItems: 'center', gap: '8px',
        }}>
          <DollarSign size={16} color="var(--accent-blue)" />
          Цены на бронирование (/м²)
        </h2>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '20px' }}>
          <div>
            <label style={labelStyle}>200 микрон</label>
            <input
              type="number"
              value={formValues[200]}
              onChange={(e) => handleChange(200, e.target.value)}
              style={inputStyle}
              min="0"
            />
          </div>
          <div>
            <label style={labelStyle}>300 микрон</label>
            <input
              type="number"
              value={formValues[300]}
              onChange={(e) => handleChange(300, e.target.value)}
              style={inputStyle}
              min="0"
            />
          </div>
        </div>

        {fetchError && (
          <p style={{ color: '#ef4444', fontSize: '0.85rem', marginBottom: '12px' }}>{fetchError}</p>
        )}
        {saveError && (
          <p style={{ color: '#ef4444', fontSize: '0.85rem', marginBottom: '12px' }}>{saveError}</p>
        )}
        {savedMsg && (
          <p style={{ color: '#22c55e', fontSize: '0.85rem', marginBottom: '12px' }}>{savedMsg}</p>
        )}

        <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>
    </div>
  );
};

export default SettingsPage;
  • Step 2: Verify build
npm run build

Expected: no errors

  • Step 3: Commit
git add src/admin/settings/SettingsPage.jsx
git commit -m "feat: add admin SettingsPage for configurable pricing"

Task 6: Supabase Edge Functions

Files:

  • Create: supabase/functions/list-admin-users/index.ts
  • Create: supabase/functions/invite-admin-user/index.ts
  • Create: supabase/functions/delete-admin-user/index.ts

These are Deno TypeScript functions deployed to Supabase. SUPABASE_URL, SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY are automatically injected by the Supabase runtime — no manual env setup needed.

  • Step 1: Create list-admin-users

Create supabase/functions/list-admin-users/index.ts:

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders });
  }

  const authHeader = req.headers.get('Authorization');
  if (!authHeader) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  // Verify the caller is a valid authenticated user
  const supabaseClient = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: authHeader } } },
  );
  const { data: { user }, error: userError } = await supabaseClient.auth.getUser();
  if (userError || !user) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  // Use service role to list all users
  const supabaseAdmin = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  );
  const { data: { users }, error } = await supabaseAdmin.auth.admin.listUsers();
  if (error) {
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  const result = users.map(({ id, email, created_at, last_sign_in_at }) => ({
    id, email, created_at, last_sign_in_at,
  }));

  return new Response(JSON.stringify(result), {
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  });
});
  • Step 2: Create invite-admin-user

Create supabase/functions/invite-admin-user/index.ts:

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders });
  }

  const authHeader = req.headers.get('Authorization');
  if (!authHeader) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  const supabaseClient = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: authHeader } } },
  );
  const { data: { user }, error: userError } = await supabaseClient.auth.getUser();
  if (userError || !user) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  const body = await req.json().catch(() => ({}));
  const { email } = body;
  if (!email || typeof email !== 'string') {
    return new Response(JSON.stringify({ error: 'email is required' }), {
      status: 400,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  const supabaseAdmin = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  );
  const { data, error } = await supabaseAdmin.auth.admin.inviteUserByEmail(email);
  if (error) {
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  return new Response(
    JSON.stringify({ id: data.user.id, email: data.user.email }),
    { headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
  );
});
  • Step 3: Create delete-admin-user

Create supabase/functions/delete-admin-user/index.ts:

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders });
  }

  const authHeader = req.headers.get('Authorization');
  if (!authHeader) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  const supabaseClient = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: authHeader } } },
  );
  const { data: { user }, error: userError } = await supabaseClient.auth.getUser();
  if (userError || !user) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  const body = await req.json().catch(() => ({}));
  const { userId } = body;
  if (!userId || typeof userId !== 'string') {
    return new Response(JSON.stringify({ error: 'userId is required' }), {
      status: 400,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  if (userId === user.id) {
    return new Response(JSON.stringify({ error: 'Cannot delete your own account' }), {
      status: 400,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  const supabaseAdmin = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  );
  const { error } = await supabaseAdmin.auth.admin.deleteUser(userId);
  if (error) {
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  return new Response(JSON.stringify({ success: true }), {
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  });
});
  • Step 4: Deploy the functions

You need the Supabase CLI. If not installed:

npm install -g supabase
supabase login   # opens browser for auth
supabase link    # link to your project (follow prompts, uses project ref from Supabase dashboard URL)

Then deploy all three:

supabase functions deploy list-admin-users
supabase functions deploy invite-admin-user
supabase functions deploy delete-admin-user

Expected output for each: "Deployed Function list-admin-users on project "

Alternative — deploy via Supabase Dashboard (no CLI needed):

  1. Go to Supabase Dashboard → Edge Functions → New Function
  2. Name it list-admin-users, paste the code from supabase/functions/list-admin-users/index.ts
  3. Repeat for invite-admin-user and delete-admin-user
  • Step 5: Commit
git add supabase/
git commit -m "feat: add Edge Functions for admin user management"

Task 7: useAdminUsers Hook

Files:

  • Create: src/admin/users/useAdminUsers.js

  • Create: src/admin/users/__tests__/useAdminUsers.test.js

  • Step 1: Write the failing test

Create src/admin/users/__tests__/useAdminUsers.test.js:

import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';

const { mockInvoke } = vi.hoisted(() => ({
  mockInvoke: vi.fn(),
}));

vi.mock('../../../lib/supabase', () => ({
  supabase: {
    functions: { invoke: mockInvoke },
  },
}));

import { useAdminUsers } from '../useAdminUsers';

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

  it('fetches users on mount and sets loading=false', async () => {
    const mockUsers = [
      { id: '1', email: 'a@test.com', created_at: '2026-01-01', last_sign_in_at: null },
    ];
    mockInvoke.mockResolvedValue({ data: mockUsers, error: null });
    const { result } = renderHook(() => useAdminUsers());
    expect(result.current.loading).toBe(true);
    await act(async () => {});
    expect(result.current.loading).toBe(false);
    expect(result.current.users).toEqual(mockUsers);
    expect(mockInvoke).toHaveBeenCalledWith('list-admin-users');
  });

  it('sets error when fetch fails', async () => {
    mockInvoke.mockResolvedValue({ data: null, error: { message: 'Function error' } });
    const { result } = renderHook(() => useAdminUsers());
    await act(async () => {});
    expect(result.current.error).toBe('Function error');
  });

  it('inviteUser calls invite-admin-user with email body', async () => {
    mockInvoke
      .mockResolvedValueOnce({ data: [], error: null })                                    // initial fetch
      .mockResolvedValueOnce({ data: { id: '2', email: 'b@test.com' }, error: null })     // invite
      .mockResolvedValueOnce({ data: [], error: null });                                   // refetch
    const { result } = renderHook(() => useAdminUsers());
    await act(async () => {});
    await act(async () => {
      await result.current.inviteUser('b@test.com');
    });
    expect(mockInvoke).toHaveBeenCalledWith('invite-admin-user', { body: { email: 'b@test.com' } });
  });

  it('deleteUser calls delete-admin-user with userId body', async () => {
    mockInvoke
      .mockResolvedValueOnce({ data: [{ id: '1', email: 'a@test.com', created_at: '2026-01-01', last_sign_in_at: null }], error: null })
      .mockResolvedValueOnce({ data: null, error: null })    // delete
      .mockResolvedValueOnce({ data: [], error: null });     // refetch
    const { result } = renderHook(() => useAdminUsers());
    await act(async () => {});
    await act(async () => {
      await result.current.deleteUser('1');
    });
    expect(mockInvoke).toHaveBeenCalledWith('delete-admin-user', { body: { userId: '1' } });
  });
});
  • Step 2: Run to confirm failure
npm run test:run -- src/admin/users/__tests__/useAdminUsers.test.js

Expected: FAIL — "Cannot find module '../useAdminUsers'"

  • Step 3: Implement useAdminUsers

Create src/admin/users/useAdminUsers.js:

import { useState, useEffect, useCallback } from 'react';
import { supabase } from '../../lib/supabase';

export function useAdminUsers() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchUsers = useCallback(async () => {
    const { data, error } = await supabase.functions.invoke('list-admin-users');
    if (error) {
      setError(error.message);
    } else {
      setUsers(data || []);
    }
    setLoading(false);
  }, []);

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

  const inviteUser = async (email) => {
    const { data, error } = await supabase.functions.invoke('invite-admin-user', {
      body: { email },
    });
    if (!error) await fetchUsers();
    return { data, error };
  };

  const deleteUser = async (userId) => {
    const { error } = await supabase.functions.invoke('delete-admin-user', {
      body: { userId },
    });
    if (!error) await fetchUsers();
    return { error };
  };

  return { users, loading, error, inviteUser, deleteUser };
}
  • Step 4: Run tests to confirm pass
npm run test:run -- src/admin/users/__tests__/useAdminUsers.test.js

Expected: PASS — 4 tests

  • Step 5: Commit
git add src/admin/users/useAdminUsers.js src/admin/users/__tests__/useAdminUsers.test.js
git commit -m "feat: add useAdminUsers hook calling Edge Functions"

Task 8: UserManagementPage Component

Files:

  • Create: src/admin/users/UserManagementPage.jsx

  • Step 1: Create UserManagementPage.jsx

Create src/admin/users/UserManagementPage.jsx:

import { useState } from 'react';
import { Trash2, UserPlus } from 'lucide-react';
import { useAdminUsers } from './useAdminUsers';

const UserManagementPage = () => {
  const { users, loading, error, inviteUser, deleteUser } = useAdminUsers();
  const [email, setEmail] = useState('');
  const [inviting, setInviting] = useState(false);
  const [inviteError, setInviteError] = useState('');
  const [deleteConfirm, setDeleteConfirm] = useState(null);

  const handleInvite = async () => {
    if (!email.trim()) return;
    setInviting(true);
    setInviteError('');
    const { error } = await inviteUser(email.trim());
    setInviting(false);
    if (error) {
      setInviteError(error.message);
    } else {
      setEmail('');
    }
  };

  const handleDelete = async (userId) => {
    await deleteUser(userId);
    setDeleteConfirm(null);
  };

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

  return (
    <div style={{ padding: '30px' }}>
      <h1 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.5rem', fontWeight: 800, marginBottom: '24px' }}>
        Пользователи
      </h1>

      {/* Invite form */}
      <div className="glass-panel" style={{ padding: '24px', marginBottom: '24px', maxWidth: '480px' }}>
        <h2 style={{
          fontFamily: 'var(--font-heading)', fontSize: '1rem', fontWeight: 700,
          marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px',
        }}>
          <UserPlus size={16} color="var(--accent-blue)" />
          Пригласить администратора
        </h2>
        <div style={{ display: 'flex', gap: '10px' }}>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="email@example.com"
            onKeyDown={(e) => e.key === 'Enter' && handleInvite()}
            style={{
              flex: 1, 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)',
            }}
          />
          <button
            onClick={handleInvite}
            disabled={inviting || !email.trim()}
            className="btn-primary"
            style={{
              opacity: (inviting || !email.trim()) ? 0.7 : 1,
              cursor: (inviting || !email.trim()) ? 'not-allowed' : 'pointer',
              whiteSpace: 'nowrap',
            }}
          >
            {inviting ? 'Отправка...' : 'Пригласить'}
          </button>
        </div>
        {inviteError && (
          <p style={{ color: '#ef4444', fontSize: '0.85rem', marginTop: '8px' }}>{inviteError}</p>
        )}
        <p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: '10px' }}>
          Supabase отправит письмо с ссылкой для входа.
        </p>
      </div>

      {/* Users table */}
      {error && <p style={{ color: '#ef4444', marginBottom: '16px', fontSize: '0.9rem' }}>{error}</p>}

      <div className="glass-panel" style={{ padding: 0, overflow: 'hidden' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse' }}>
          <thead>
            <tr style={{ borderBottom: '1px solid var(--border-light)' }}>
              {['Email', 'Зарегистрирован', 'Последний вход', ''].map((h) => (
                <th key={h} style={{
                  padding: '12px 16px', textAlign: 'left',
                  fontSize: '0.75rem', fontWeight: 700,
                  color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px',
                }}>
                  {h}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {users.map((u) => (
              <tr key={u.id} style={{ borderBottom: '1px solid var(--border-light)' }}>
                <td style={{ padding: '12px 16px', fontSize: '0.9rem', color: 'var(--text-main)' }}>
                  {u.email}
                </td>
                <td style={{ padding: '12px 16px', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
                  {new Date(u.created_at).toLocaleDateString('ru-RU')}
                </td>
                <td style={{ padding: '12px 16px', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
                  {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString('ru-RU') : '—'}
                </td>
                <td style={{ padding: '12px 16px', textAlign: 'right' }}>
                  {deleteConfirm === u.id ? (
                    <span style={{ display: 'inline-flex', gap: '8px', alignItems: 'center', fontSize: '0.82rem' }}>
                      <span style={{ color: 'var(--text-muted)' }}>Удалить?</span>
                      <button
                        onClick={() => handleDelete(u.id)}
                        style={{ background: '#ef4444', border: 'none', color: '#fff', borderRadius: '4px', padding: '3px 10px', cursor: 'pointer', fontSize: '0.82rem' }}
                      >
                        Да
                      </button>
                      <button
                        onClick={() => setDeleteConfirm(null)}
                        style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: '0.82rem' }}
                      >
                        Отмена
                      </button>
                    </span>
                  ) : (
                    <button
                      onClick={() => setDeleteConfirm(u.id)}
                      aria-label={`Удалить ${u.email}`}
                      style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(239,68,68,0.6)', padding: '4px' }}
                    >
                      <Trash2 size={15} />
                    </button>
                  )}
                </td>
              </tr>
            ))}
            {users.length === 0 && (
              <tr>
                <td colSpan={4} style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
                  Нет пользователей
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
};

export default UserManagementPage;
  • Step 2: Verify build
npm run build

Expected: no errors

  • Step 3: Commit
git add src/admin/users/UserManagementPage.jsx
git commit -m "feat: add UserManagementPage with invite and delete"

Task 9: OrderModal New Contact Fields

Files:

  • Modify: src/admin/orders/OrderModal.jsx

The modal at src/admin/orders/OrderModal.jsx currently has a form state (line 3344) and a 2-column grid of inputs (line 151). We add three new fields.

  • Step 1: Add new fields to form state

In OrderModal.jsx, find the useState initializer for form (currently ends with status: order?.status || 'новая'). Add three lines before the closing }):

Old block (lines 3344):

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 || 'новая',
});

New block:

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 || 'новая',
  telegram_username: order?.telegram_username || '',
  max_username: order?.max_username || '',
  address: order?.address || '',
});
  • Step 2: Add new field inputs to the JSX

In OrderModal.jsx, find the assigned_to full-width field block (currently the last item before the Notes section):

<div style={{ gridColumn: '1 / -1' }}>
  <label htmlFor="modal-assigned" style={labelStyle}>Ответственный (email)</label>
  <input id="modal-assigned" name="assigned_to" value={form.assigned_to} onChange={set} style={inputStyle} placeholder="manager@company.com" />
</div>

Replace it with (same assigned_to block plus the three new fields before closing the grid):

<div style={{ gridColumn: '1 / -1' }}>
  <label htmlFor="modal-assigned" style={labelStyle}>Ответственный (email)</label>
  <input id="modal-assigned" name="assigned_to" value={form.assigned_to} onChange={set} style={inputStyle} placeholder="manager@company.com" />
</div>
<div>
  <label htmlFor="modal-telegram" style={labelStyle}>Telegram</label>
  <input id="modal-telegram" name="telegram_username" value={form.telegram_username} onChange={set} style={inputStyle} placeholder="@username" />
</div>
<div>
  <label htmlFor="modal-max" style={labelStyle}>Max (ВКонтакте)</label>
  <input id="modal-max" name="max_username" value={form.max_username} onChange={set} style={inputStyle} placeholder="@username" />
</div>
<div style={{ gridColumn: '1 / -1' }}>
  <label htmlFor="modal-address" style={labelStyle}>Адрес объекта</label>
  <textarea id="modal-address" name="address" value={form.address} onChange={set} style={{ ...inputStyle, height: '64px', resize: 'vertical' }} />
</div>
  • Step 3: Verify build
npm run build

Expected: no errors

  • Step 4: Commit
git add src/admin/orders/OrderModal.jsx
git commit -m "feat: add telegram, max messenger, address fields to OrderModal"

Task 10: Sidebar Navigation + Route Wiring

Files:

  • Modify: src/admin/layout/Sidebar.jsx

  • Modify: src/App.jsx

  • Step 1: Update Sidebar.jsx

In src/admin/layout/Sidebar.jsx, replace the entire file content with:

import { NavLink, useNavigate } from 'react-router-dom';
import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings } 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: 'Завершённые' },
  { to: '/admin/users', icon: Users, label: 'Пользователи' },
  { to: '/admin/settings', icon: Settings, label: 'Настройки' },
];

const Sidebar = () => {
  const { session, logout } = useAuth();
  const navigate = useNavigate();

  const handleLogout = async () => {
    try {
      await logout();
    } catch (err) {
      console.error('Logout failed:', err);
    }
    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: Update App.jsx with new lazy routes

In src/App.jsx, add two lazy imports after the existing ones (after line 18, CompletedOrdersTable):

const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));

Then inside the <Route element={<AdminLayout />}> block (after the completed route), add:

<Route path="users" element={<UserManagementPage />} />
<Route path="settings" element={<SettingsPage />} />

The full updated src/App.jsx:

import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } 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';

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'));
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));

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 path="users" element={<UserManagementPage />} />
              <Route path="settings" element={<SettingsPage />} />
            </Route>
          </Route>
        </Routes>
      </React.Suspense>
    </BrowserRouter>
  );
}

export default App;
  • Step 3: Run full test suite
npm run test:run

Expected: all tests pass (existing 17 + 3 new test files = should be ~29 tests total)

  • Step 4: Run lint
npm run lint

Expected: 0 new errors (12 pre-existing unused React import warnings in public components are out of scope)

  • Step 5: Final build check
npm run build

Expected: build succeeds, no errors

  • Step 6: Commit
git add src/admin/layout/Sidebar.jsx src/App.jsx
git commit -m "feat: wire admin users and settings routes into sidebar and router"