From 464cb3cab78c214a640ca7dec5dca8af33327afa Mon Sep 17 00:00:00 2001 From: houseassassin Date: Sun, 10 May 2026 12:37:27 +0300 Subject: [PATCH] docs: add admin panel v2 implementation plan (10 tasks) --- .../plans/2026-05-10-admin-panel-v2.md | 1607 +++++++++++++++++ 1 file changed, 1607 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-admin-panel-v2.md diff --git a/docs/superpowers/plans/2026-05-10-admin-panel-v2.md b/docs/superpowers/plans/2026-05-10-admin-panel-v2.md new file mode 100644 index 0000000..e6c2b66 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-admin-panel-v2.md @@ -0,0 +1,1607 @@ +# 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 +
+ + +
+
+ + +
+
+ + +
+
+ +