# 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 6–11) 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
{pricesLoading ? '...' : estimatedCost.toLocaleString('ru-RU')} ₽
``` 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 (

КАЛЬКУЛЯТОР ЗАЩИТЫ

Рассчитайте примерную стоимость обеспечения безопасности вашего периметра. Окончательная стоимость определяется после профессионального осмотра объекта.

{area}
setArea(parseInt(e.target.value))} style={{ width: '100%', accentColor: 'var(--accent-blue)', height: '8px', borderRadius: '4px' }} />

Ориентировочная стоимость

{pricesLoading ? '...' : estimatedCost.toLocaleString('ru-RU')} ₽

Включает бронематериалы, специализированный монтаж и финальную проверку.

Запросить точный расчет
); }; 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
Загрузка...
; } return (

Настройки

Цены на бронирование (₽/м²)

handleChange(200, e.target.value)} style={inputStyle} min="0" />
handleChange(300, e.target.value)} style={inputStyle} min="0" />
{fetchError && (

{fetchError}

)} {saveError && (

{saveError}

)} {savedMsg && (

{savedMsg}

)}
); }; 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 " **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
Загрузка...
; } return (

Пользователи

{/* Invite form */}

Пригласить администратора

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)', }} />
{inviteError && (

{inviteError}

)}

Supabase отправит письмо с ссылкой для входа.

{/* Users table */} {error &&

{error}

}
{['Email', 'Зарегистрирован', 'Последний вход', ''].map((h) => ( ))} {users.map((u) => ( ))} {users.length === 0 && ( )}
{h}
{u.email} {new Date(u.created_at).toLocaleDateString('ru-RU')} {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString('ru-RU') : '—'} {deleteConfirm === u.id ? ( Удалить? ) : ( )}
Нет пользователей
); }; 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 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): ```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
``` Replace it with (same assigned_to block plus the three new fields before closing the grid): ```jsx