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

1608 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```sql
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:
```sql
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**
```bash
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`:
```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**
```bash
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`:
```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**
```bash
npm run test:run -- src/hooks/__tests__/usePricing.test.js
```
Expected: PASS — 3 tests
- [ ] **Step 5: Commit**
```bash
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:
```js
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:
```jsx
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:
```jsx
<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:
```jsx
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**
```bash
npm run build
```
Expected: no errors
- [ ] **Step 3: Commit**
```bash
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`:
```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**
```bash
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`:
```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**
```bash
npm run test:run -- src/admin/settings/__tests__/useSettings.test.js
```
Expected: PASS — 4 tests
- [ ] **Step 5: Commit**
```bash
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`:
```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**
```bash
npm run build
```
Expected: no errors
- [ ] **Step 3: Commit**
```bash
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`:
```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`:
```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`:
```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:
```bash
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:
```bash
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 <id>"
**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**
```bash
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`:
```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**
```bash
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`:
```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**
```bash
npm run test:run -- src/admin/users/__tests__/useAdminUsers.test.js
```
Expected: PASS — 4 tests
- [ ] **Step 5: Commit**
```bash
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`:
```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**
```bash
npm run build
```
Expected: no errors
- [ ] **Step 3: Commit**
```bash
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):
```js
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:
```js
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):
```jsx
<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):
```jsx
<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**
```bash
npm run build
```
Expected: no errors
- [ ] **Step 4: Commit**
```bash
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:
```jsx
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`):
```js
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:
```jsx
<Route path="users" element={<UserManagementPage />} />
<Route path="settings" element={<SettingsPage />} />
```
The full updated `src/App.jsx`:
```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**
```bash
npm run test:run
```
Expected: all tests pass (existing 17 + 3 new test files = should be ~29 tests total)
- [ ] **Step 4: Run lint**
```bash
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**
```bash
npm run build
```
Expected: build succeeds, no errors
- [ ] **Step 6: Commit**
```bash
git add src/admin/layout/Sidebar.jsx src/App.jsx
git commit -m "feat: wire admin users and settings routes into sidebar and router"
```