53 KiB
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 6–11) 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):
- Go to Supabase Dashboard → Edge Functions → New Function
- Name it
list-admin-users, paste the code fromsupabase/functions/list-admin-users/index.ts - Repeat for
invite-admin-useranddelete-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 33–44) 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 33–44):
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"