1608 lines
53 KiB
Markdown
1608 lines
53 KiB
Markdown
# 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
|
||
<div style={{ fontSize: '3.8rem', fontWeight: 800, fontFamily: 'var(--font-heading)', color: 'var(--text-main)', marginBottom: '20px' }}>
|
||
{pricesLoading ? '...' : estimatedCost.toLocaleString('ru-RU')} ₽
|
||
</div>
|
||
```
|
||
|
||
The full updated file content:
|
||
|
||
```jsx
|
||
import React, { useState } from 'react';
|
||
import { motion } from 'framer-motion';
|
||
import { Calculator } from 'lucide-react';
|
||
import { usePricing } from '../hooks/usePricing';
|
||
|
||
const Estimator = () => {
|
||
const [area, setArea] = useState(20);
|
||
const [thickness, setThickness] = useState(200);
|
||
const { prices, loading: pricesLoading } = usePricing();
|
||
|
||
const costPerSqM = prices[thickness] ?? (thickness === 200 ? 2000 : 3000);
|
||
const estimatedCost = area * costPerSqM;
|
||
|
||
return (
|
||
<section id="estimator" style={{ backgroundColor: 'var(--bg-lighter)' }}>
|
||
<div className="container">
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '50px', alignItems: 'center' }}>
|
||
|
||
<div style={{ flex: '1 1 400px' }}>
|
||
<motion.div
|
||
initial={{ opacity: 0, x: -50 }}
|
||
whileInView={{ opacity: 1, x: 0 }}
|
||
transition={{ duration: 0.6, type: "spring" }}
|
||
viewport={{ once: true }}
|
||
>
|
||
<h2 style={{ fontSize: '3.5rem', marginBottom: '20px' }}>
|
||
КАЛЬКУЛЯТОР <span className="text-accent-blue">ЗАЩИТЫ</span>
|
||
</h2>
|
||
<p style={{ color: 'var(--text-muted)', fontSize: '1.2rem', marginBottom: '40px', lineHeight: '1.6' }}>
|
||
Рассчитайте примерную стоимость обеспечения безопасности вашего периметра. Окончательная стоимость определяется после профессионального осмотра объекта.
|
||
</p>
|
||
|
||
<div className="glass-panel" style={{ padding: '40px' }}>
|
||
<div style={{ marginBottom: '35px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '15px' }}>
|
||
<label style={{ fontWeight: 600, color: 'var(--text-main)', fontSize: '1.1rem' }}>Толщина пленки</label>
|
||
</div>
|
||
<select
|
||
value={thickness}
|
||
onChange={(e) => setThickness(parseInt(e.target.value))}
|
||
style={{
|
||
width: '100%', padding: '12px',
|
||
background: 'var(--bg-lighter)',
|
||
border: '2px solid var(--border-light)',
|
||
borderRadius: '8px',
|
||
color: 'var(--text-main)',
|
||
fontSize: '1rem',
|
||
outline: 'none',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
<option value={200}>200 микрон (от {prices[200].toLocaleString('ru-RU')} ₽/м²)</option>
|
||
<option value={300}>300 микрон (от {prices[300].toLocaleString('ru-RU')} ₽/м²)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '15px' }}>
|
||
<label style={{ fontWeight: 600, color: 'var(--text-main)', fontSize: '1.1rem' }}>Общая площадь (м²)</label>
|
||
<span className="text-accent-blue" style={{ fontWeight: '800', fontSize: '1.2rem' }}>{area}</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min="5"
|
||
max="500"
|
||
step="5"
|
||
value={area}
|
||
onChange={(e) => setArea(parseInt(e.target.value))}
|
||
style={{ width: '100%', accentColor: 'var(--accent-blue)', height: '8px', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
|
||
<div style={{ flex: '1 1 400px' }}>
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.9 }}
|
||
whileInView={{ opacity: 1, scale: 1 }}
|
||
transition={{ duration: 0.6, delay: 0.2, type: "spring" }}
|
||
viewport={{ once: true }}
|
||
className="glass-panel"
|
||
style={{
|
||
padding: '50px',
|
||
textAlign: 'center',
|
||
border: '2px solid rgba(14, 165, 233, 0.2)',
|
||
boxShadow: '0 20px 50px rgba(14, 165, 233, 0.1)'
|
||
}}
|
||
>
|
||
<motion.div
|
||
animate={{ y: [0, -10, 0] }}
|
||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
||
>
|
||
<Calculator size={56} className="text-accent-blue" style={{ margin: '0 auto 25px' }} />
|
||
</motion.div>
|
||
|
||
<h3 style={{ fontSize: '1.3rem', color: 'var(--text-muted)', marginBottom: '15px' }}>Ориентировочная стоимость</h3>
|
||
<div style={{ fontSize: '3.8rem', fontWeight: 800, fontFamily: 'var(--font-heading)', color: 'var(--text-main)', marginBottom: '20px' }}>
|
||
{pricesLoading ? '...' : estimatedCost.toLocaleString('ru-RU')} ₽
|
||
</div>
|
||
<p style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '35px', lineHeight: '1.6' }}>
|
||
Включает бронематериалы, специализированный монтаж и финальную проверку.
|
||
</p>
|
||
|
||
<motion.a
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
href="#contact"
|
||
className="btn-primary"
|
||
style={{ display: 'block', width: '100%', textAlign: 'center' }}
|
||
>
|
||
Запросить точный расчет
|
||
</motion.a>
|
||
</motion.div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
export default Estimator;
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the app builds**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: no errors
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/Estimator.jsx
|
||
git commit -m "feat: estimator reads prices from DB via usePricing"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: useSettings Hook
|
||
|
||
**Files:**
|
||
- Create: `src/admin/settings/useSettings.js`
|
||
- Create: `src/admin/settings/__tests__/useSettings.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/admin/settings/__tests__/useSettings.test.js`:
|
||
|
||
```js
|
||
import { renderHook, act } from '@testing-library/react';
|
||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||
|
||
const { mockIn, mockEq } = vi.hoisted(() => ({
|
||
mockIn: vi.fn(),
|
||
mockEq: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../../lib/supabase', () => ({
|
||
supabase: {
|
||
from: vi.fn(() => ({
|
||
select: vi.fn(() => ({ in: mockIn })),
|
||
update: vi.fn(() => ({ eq: mockEq })),
|
||
})),
|
||
},
|
||
}));
|
||
|
||
import { useSettings } from '../useSettings';
|
||
|
||
describe('useSettings', () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
it('loads prices on mount', async () => {
|
||
mockIn.mockResolvedValue({
|
||
data: [{ key: 'price_200', value: '2000' }, { key: 'price_300', value: '3000' }],
|
||
error: null,
|
||
});
|
||
const { result } = renderHook(() => useSettings());
|
||
await act(async () => {});
|
||
expect(result.current.loading).toBe(false);
|
||
expect(result.current.prices).toEqual({ 200: 2000, 300: 3000 });
|
||
});
|
||
|
||
it('sets error when fetch fails', async () => {
|
||
mockIn.mockResolvedValue({ data: null, error: { message: 'DB error' } });
|
||
const { result } = renderHook(() => useSettings());
|
||
await act(async () => {});
|
||
expect(result.current.error).toBe('DB error');
|
||
});
|
||
|
||
it('updatePrice calls update().eq() and updates local state', async () => {
|
||
mockIn.mockResolvedValue({
|
||
data: [{ key: 'price_200', value: '2000' }, { key: 'price_300', value: '3000' }],
|
||
error: null,
|
||
});
|
||
mockEq.mockResolvedValue({ error: null });
|
||
const { result } = renderHook(() => useSettings());
|
||
await act(async () => {});
|
||
await act(async () => {
|
||
await result.current.updatePrice('price_200', 1800);
|
||
});
|
||
expect(mockEq).toHaveBeenCalledWith('key', 'price_200');
|
||
expect(result.current.prices[200]).toBe(1800);
|
||
});
|
||
|
||
it('updatePrice returns error without mutating state when DB fails', async () => {
|
||
mockIn.mockResolvedValue({
|
||
data: [{ key: 'price_200', value: '2000' }, { key: 'price_300', value: '3000' }],
|
||
error: null,
|
||
});
|
||
mockEq.mockResolvedValue({ error: { message: 'update failed' } });
|
||
const { result } = renderHook(() => useSettings());
|
||
await act(async () => {});
|
||
let updateResult;
|
||
await act(async () => {
|
||
updateResult = await result.current.updatePrice('price_200', 9999);
|
||
});
|
||
expect(updateResult.error.message).toBe('update failed');
|
||
expect(result.current.prices[200]).toBe(2000);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to confirm failure**
|
||
|
||
```bash
|
||
npm run test:run -- src/admin/settings/__tests__/useSettings.test.js
|
||
```
|
||
|
||
Expected: FAIL — "Cannot find module '../useSettings'"
|
||
|
||
- [ ] **Step 3: Implement useSettings**
|
||
|
||
Create `src/admin/settings/useSettings.js`:
|
||
|
||
```js
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { supabase } from '../../lib/supabase';
|
||
|
||
export function useSettings() {
|
||
const [prices, setPrices] = useState({ 200: 2000, 300: 3000 });
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
|
||
const fetchPrices = useCallback(async () => {
|
||
const { data, error } = await supabase
|
||
.from('settings')
|
||
.select('key, value')
|
||
.in('key', ['price_200', 'price_300']);
|
||
|
||
if (error) {
|
||
setError(error.message);
|
||
} else if (data) {
|
||
const map = {};
|
||
data.forEach(({ key, value }) => {
|
||
const thickness = parseInt(key.replace('price_', ''), 10);
|
||
map[thickness] = Number(value);
|
||
});
|
||
setPrices((prev) => ({ ...prev, ...map }));
|
||
}
|
||
setLoading(false);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchPrices();
|
||
}, [fetchPrices]);
|
||
|
||
const updatePrice = async (key, value) => {
|
||
const { error } = await supabase
|
||
.from('settings')
|
||
.update({ value: String(value), updated_at: new Date().toISOString() })
|
||
.eq('key', key);
|
||
|
||
if (!error) {
|
||
const thickness = parseInt(key.replace('price_', ''), 10);
|
||
setPrices((prev) => ({ ...prev, [thickness]: Number(value) }));
|
||
}
|
||
return { error };
|
||
};
|
||
|
||
return { prices, loading, error, updatePrice };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to confirm pass**
|
||
|
||
```bash
|
||
npm run test:run -- src/admin/settings/__tests__/useSettings.test.js
|
||
```
|
||
|
||
Expected: PASS — 4 tests
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/admin/settings/useSettings.js src/admin/settings/__tests__/useSettings.test.js
|
||
git commit -m "feat: add useSettings hook for admin price management"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: SettingsPage Component
|
||
|
||
**Files:**
|
||
- Create: `src/admin/settings/SettingsPage.jsx`
|
||
|
||
- [ ] **Step 1: Create SettingsPage.jsx**
|
||
|
||
Create `src/admin/settings/SettingsPage.jsx`:
|
||
|
||
```jsx
|
||
import { useState } from 'react';
|
||
import { Save, DollarSign } from 'lucide-react';
|
||
import { useSettings } from './useSettings';
|
||
|
||
const inputStyle = {
|
||
width: '100%', padding: '10px 12px',
|
||
background: 'var(--bg-lighter)',
|
||
border: '1.5px solid var(--border-light)',
|
||
borderRadius: '6px',
|
||
color: 'var(--text-main)', fontSize: '0.92rem',
|
||
outline: 'none', fontFamily: 'var(--font-body)',
|
||
boxSizing: 'border-box',
|
||
};
|
||
|
||
const labelStyle = {
|
||
display: 'block', marginBottom: '5px',
|
||
fontSize: '0.75rem', fontWeight: 700,
|
||
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px',
|
||
};
|
||
|
||
const SettingsPage = () => {
|
||
const { prices, loading, error: fetchError, updatePrice } = useSettings();
|
||
const [form, setForm] = useState(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [savedMsg, setSavedMsg] = useState('');
|
||
const [saveError, setSaveError] = useState('');
|
||
|
||
// form is null until user edits; fall back to loaded prices
|
||
const formValues = form ?? prices;
|
||
|
||
const handleChange = (thickness, value) => {
|
||
setForm((prev) => ({ ...(prev ?? prices), [thickness]: Number(value) }));
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
setSavedMsg('');
|
||
setSaveError('');
|
||
const [r200, r300] = await Promise.all([
|
||
updatePrice('price_200', formValues[200]),
|
||
updatePrice('price_300', formValues[300]),
|
||
]);
|
||
setSaving(false);
|
||
if (r200.error || r300.error) {
|
||
setSaveError(r200.error?.message ?? r300.error?.message ?? 'Ошибка сохранения');
|
||
} else {
|
||
setSavedMsg('Цены сохранены');
|
||
setTimeout(() => setSavedMsg(''), 3000);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return <div style={{ padding: '30px', color: 'var(--text-muted)' }}>Загрузка...</div>;
|
||
}
|
||
|
||
return (
|
||
<div style={{ padding: '30px', maxWidth: '480px' }}>
|
||
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.5rem', fontWeight: 800, marginBottom: '24px' }}>
|
||
Настройки
|
||
</h1>
|
||
|
||
<div className="glass-panel" style={{ padding: '28px' }}>
|
||
<h2 style={{
|
||
fontFamily: 'var(--font-heading)', fontSize: '1rem', fontWeight: 700,
|
||
marginBottom: '20px', display: 'flex', alignItems: 'center', gap: '8px',
|
||
}}>
|
||
<DollarSign size={16} color="var(--accent-blue)" />
|
||
Цены на бронирование (₽/м²)
|
||
</h2>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '20px' }}>
|
||
<div>
|
||
<label style={labelStyle}>200 микрон</label>
|
||
<input
|
||
type="number"
|
||
value={formValues[200]}
|
||
onChange={(e) => handleChange(200, e.target.value)}
|
||
style={inputStyle}
|
||
min="0"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={labelStyle}>300 микрон</label>
|
||
<input
|
||
type="number"
|
||
value={formValues[300]}
|
||
onChange={(e) => handleChange(300, e.target.value)}
|
||
style={inputStyle}
|
||
min="0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{fetchError && (
|
||
<p style={{ color: '#ef4444', fontSize: '0.85rem', marginBottom: '12px' }}>{fetchError}</p>
|
||
)}
|
||
{saveError && (
|
||
<p style={{ color: '#ef4444', fontSize: '0.85rem', marginBottom: '12px' }}>{saveError}</p>
|
||
)}
|
||
{savedMsg && (
|
||
<p style={{ color: '#22c55e', fontSize: '0.85rem', marginBottom: '12px' }}>{savedMsg}</p>
|
||
)}
|
||
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="btn-primary"
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '8px',
|
||
opacity: saving ? 0.7 : 1,
|
||
cursor: saving ? 'not-allowed' : 'pointer',
|
||
}}
|
||
>
|
||
<Save size={15} />
|
||
{saving ? 'Сохранение...' : 'Сохранить цены'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SettingsPage;
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: no errors
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/admin/settings/SettingsPage.jsx
|
||
git commit -m "feat: add admin SettingsPage for configurable pricing"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Supabase Edge Functions
|
||
|
||
**Files:**
|
||
- Create: `supabase/functions/list-admin-users/index.ts`
|
||
- Create: `supabase/functions/invite-admin-user/index.ts`
|
||
- Create: `supabase/functions/delete-admin-user/index.ts`
|
||
|
||
These are Deno TypeScript functions deployed to Supabase. `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_ROLE_KEY` are automatically injected by the Supabase runtime — no manual env setup needed.
|
||
|
||
- [ ] **Step 1: Create list-admin-users**
|
||
|
||
Create `supabase/functions/list-admin-users/index.ts`:
|
||
|
||
```ts
|
||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||
|
||
const corsHeaders = {
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||
};
|
||
|
||
Deno.serve(async (req) => {
|
||
if (req.method === 'OPTIONS') {
|
||
return new Response('ok', { headers: corsHeaders });
|
||
}
|
||
|
||
const authHeader = req.headers.get('Authorization');
|
||
if (!authHeader) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// Verify the caller is a valid authenticated user
|
||
const supabaseClient = createClient(
|
||
Deno.env.get('SUPABASE_URL')!,
|
||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||
{ global: { headers: { Authorization: authHeader } } },
|
||
);
|
||
const { data: { user }, error: userError } = await supabaseClient.auth.getUser();
|
||
if (userError || !user) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// Use service role to list all users
|
||
const supabaseAdmin = createClient(
|
||
Deno.env.get('SUPABASE_URL')!,
|
||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||
);
|
||
const { data: { users }, error } = await supabaseAdmin.auth.admin.listUsers();
|
||
if (error) {
|
||
return new Response(JSON.stringify({ error: error.message }), {
|
||
status: 500,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
const result = users.map(({ id, email, created_at, last_sign_in_at }) => ({
|
||
id, email, created_at, last_sign_in_at,
|
||
}));
|
||
|
||
return new Response(JSON.stringify(result), {
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Create invite-admin-user**
|
||
|
||
Create `supabase/functions/invite-admin-user/index.ts`:
|
||
|
||
```ts
|
||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||
|
||
const corsHeaders = {
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||
};
|
||
|
||
Deno.serve(async (req) => {
|
||
if (req.method === 'OPTIONS') {
|
||
return new Response('ok', { headers: corsHeaders });
|
||
}
|
||
|
||
const authHeader = req.headers.get('Authorization');
|
||
if (!authHeader) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
const supabaseClient = createClient(
|
||
Deno.env.get('SUPABASE_URL')!,
|
||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||
{ global: { headers: { Authorization: authHeader } } },
|
||
);
|
||
const { data: { user }, error: userError } = await supabaseClient.auth.getUser();
|
||
if (userError || !user) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
const body = await req.json().catch(() => ({}));
|
||
const { email } = body;
|
||
if (!email || typeof email !== 'string') {
|
||
return new Response(JSON.stringify({ error: 'email is required' }), {
|
||
status: 400,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
const supabaseAdmin = createClient(
|
||
Deno.env.get('SUPABASE_URL')!,
|
||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||
);
|
||
const { data, error } = await supabaseAdmin.auth.admin.inviteUserByEmail(email);
|
||
if (error) {
|
||
return new Response(JSON.stringify({ error: error.message }), {
|
||
status: 500,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
return new Response(
|
||
JSON.stringify({ id: data.user.id, email: data.user.email }),
|
||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||
);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Create delete-admin-user**
|
||
|
||
Create `supabase/functions/delete-admin-user/index.ts`:
|
||
|
||
```ts
|
||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||
|
||
const corsHeaders = {
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||
};
|
||
|
||
Deno.serve(async (req) => {
|
||
if (req.method === 'OPTIONS') {
|
||
return new Response('ok', { headers: corsHeaders });
|
||
}
|
||
|
||
const authHeader = req.headers.get('Authorization');
|
||
if (!authHeader) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
const supabaseClient = createClient(
|
||
Deno.env.get('SUPABASE_URL')!,
|
||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||
{ global: { headers: { Authorization: authHeader } } },
|
||
);
|
||
const { data: { user }, error: userError } = await supabaseClient.auth.getUser();
|
||
if (userError || !user) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
const body = await req.json().catch(() => ({}));
|
||
const { userId } = body;
|
||
if (!userId || typeof userId !== 'string') {
|
||
return new Response(JSON.stringify({ error: 'userId is required' }), {
|
||
status: 400,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
if (userId === user.id) {
|
||
return new Response(JSON.stringify({ error: 'Cannot delete your own account' }), {
|
||
status: 400,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
const supabaseAdmin = createClient(
|
||
Deno.env.get('SUPABASE_URL')!,
|
||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||
);
|
||
const { error } = await supabaseAdmin.auth.admin.deleteUser(userId);
|
||
if (error) {
|
||
return new Response(JSON.stringify({ error: error.message }), {
|
||
status: 500,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
return new Response(JSON.stringify({ success: true }), {
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Deploy the functions**
|
||
|
||
You need the Supabase CLI. If not installed:
|
||
```bash
|
||
npm install -g supabase
|
||
supabase login # opens browser for auth
|
||
supabase link # link to your project (follow prompts, uses project ref from Supabase dashboard URL)
|
||
```
|
||
|
||
Then deploy all three:
|
||
```bash
|
||
supabase functions deploy list-admin-users
|
||
supabase functions deploy invite-admin-user
|
||
supabase functions deploy delete-admin-user
|
||
```
|
||
|
||
Expected output for each: "Deployed Function list-admin-users on project <id>"
|
||
|
||
**Alternative — deploy via Supabase Dashboard (no CLI needed):**
|
||
1. Go to Supabase Dashboard → Edge Functions → New Function
|
||
2. Name it `list-admin-users`, paste the code from `supabase/functions/list-admin-users/index.ts`
|
||
3. Repeat for `invite-admin-user` and `delete-admin-user`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add supabase/
|
||
git commit -m "feat: add Edge Functions for admin user management"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: useAdminUsers Hook
|
||
|
||
**Files:**
|
||
- Create: `src/admin/users/useAdminUsers.js`
|
||
- Create: `src/admin/users/__tests__/useAdminUsers.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/admin/users/__tests__/useAdminUsers.test.js`:
|
||
|
||
```js
|
||
import { renderHook, act } from '@testing-library/react';
|
||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||
|
||
const { mockInvoke } = vi.hoisted(() => ({
|
||
mockInvoke: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../../lib/supabase', () => ({
|
||
supabase: {
|
||
functions: { invoke: mockInvoke },
|
||
},
|
||
}));
|
||
|
||
import { useAdminUsers } from '../useAdminUsers';
|
||
|
||
describe('useAdminUsers', () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
it('fetches users on mount and sets loading=false', async () => {
|
||
const mockUsers = [
|
||
{ id: '1', email: 'a@test.com', created_at: '2026-01-01', last_sign_in_at: null },
|
||
];
|
||
mockInvoke.mockResolvedValue({ data: mockUsers, error: null });
|
||
const { result } = renderHook(() => useAdminUsers());
|
||
expect(result.current.loading).toBe(true);
|
||
await act(async () => {});
|
||
expect(result.current.loading).toBe(false);
|
||
expect(result.current.users).toEqual(mockUsers);
|
||
expect(mockInvoke).toHaveBeenCalledWith('list-admin-users');
|
||
});
|
||
|
||
it('sets error when fetch fails', async () => {
|
||
mockInvoke.mockResolvedValue({ data: null, error: { message: 'Function error' } });
|
||
const { result } = renderHook(() => useAdminUsers());
|
||
await act(async () => {});
|
||
expect(result.current.error).toBe('Function error');
|
||
});
|
||
|
||
it('inviteUser calls invite-admin-user with email body', async () => {
|
||
mockInvoke
|
||
.mockResolvedValueOnce({ data: [], error: null }) // initial fetch
|
||
.mockResolvedValueOnce({ data: { id: '2', email: 'b@test.com' }, error: null }) // invite
|
||
.mockResolvedValueOnce({ data: [], error: null }); // refetch
|
||
const { result } = renderHook(() => useAdminUsers());
|
||
await act(async () => {});
|
||
await act(async () => {
|
||
await result.current.inviteUser('b@test.com');
|
||
});
|
||
expect(mockInvoke).toHaveBeenCalledWith('invite-admin-user', { body: { email: 'b@test.com' } });
|
||
});
|
||
|
||
it('deleteUser calls delete-admin-user with userId body', async () => {
|
||
mockInvoke
|
||
.mockResolvedValueOnce({ data: [{ id: '1', email: 'a@test.com', created_at: '2026-01-01', last_sign_in_at: null }], error: null })
|
||
.mockResolvedValueOnce({ data: null, error: null }) // delete
|
||
.mockResolvedValueOnce({ data: [], error: null }); // refetch
|
||
const { result } = renderHook(() => useAdminUsers());
|
||
await act(async () => {});
|
||
await act(async () => {
|
||
await result.current.deleteUser('1');
|
||
});
|
||
expect(mockInvoke).toHaveBeenCalledWith('delete-admin-user', { body: { userId: '1' } });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to confirm failure**
|
||
|
||
```bash
|
||
npm run test:run -- src/admin/users/__tests__/useAdminUsers.test.js
|
||
```
|
||
|
||
Expected: FAIL — "Cannot find module '../useAdminUsers'"
|
||
|
||
- [ ] **Step 3: Implement useAdminUsers**
|
||
|
||
Create `src/admin/users/useAdminUsers.js`:
|
||
|
||
```js
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { supabase } from '../../lib/supabase';
|
||
|
||
export function useAdminUsers() {
|
||
const [users, setUsers] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
|
||
const fetchUsers = useCallback(async () => {
|
||
const { data, error } = await supabase.functions.invoke('list-admin-users');
|
||
if (error) {
|
||
setError(error.message);
|
||
} else {
|
||
setUsers(data || []);
|
||
}
|
||
setLoading(false);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchUsers();
|
||
}, [fetchUsers]);
|
||
|
||
const inviteUser = async (email) => {
|
||
const { data, error } = await supabase.functions.invoke('invite-admin-user', {
|
||
body: { email },
|
||
});
|
||
if (!error) await fetchUsers();
|
||
return { data, error };
|
||
};
|
||
|
||
const deleteUser = async (userId) => {
|
||
const { error } = await supabase.functions.invoke('delete-admin-user', {
|
||
body: { userId },
|
||
});
|
||
if (!error) await fetchUsers();
|
||
return { error };
|
||
};
|
||
|
||
return { users, loading, error, inviteUser, deleteUser };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to confirm pass**
|
||
|
||
```bash
|
||
npm run test:run -- src/admin/users/__tests__/useAdminUsers.test.js
|
||
```
|
||
|
||
Expected: PASS — 4 tests
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/admin/users/useAdminUsers.js src/admin/users/__tests__/useAdminUsers.test.js
|
||
git commit -m "feat: add useAdminUsers hook calling Edge Functions"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: UserManagementPage Component
|
||
|
||
**Files:**
|
||
- Create: `src/admin/users/UserManagementPage.jsx`
|
||
|
||
- [ ] **Step 1: Create UserManagementPage.jsx**
|
||
|
||
Create `src/admin/users/UserManagementPage.jsx`:
|
||
|
||
```jsx
|
||
import { useState } from 'react';
|
||
import { Trash2, UserPlus } from 'lucide-react';
|
||
import { useAdminUsers } from './useAdminUsers';
|
||
|
||
const UserManagementPage = () => {
|
||
const { users, loading, error, inviteUser, deleteUser } = useAdminUsers();
|
||
const [email, setEmail] = useState('');
|
||
const [inviting, setInviting] = useState(false);
|
||
const [inviteError, setInviteError] = useState('');
|
||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||
|
||
const handleInvite = async () => {
|
||
if (!email.trim()) return;
|
||
setInviting(true);
|
||
setInviteError('');
|
||
const { error } = await inviteUser(email.trim());
|
||
setInviting(false);
|
||
if (error) {
|
||
setInviteError(error.message);
|
||
} else {
|
||
setEmail('');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (userId) => {
|
||
await deleteUser(userId);
|
||
setDeleteConfirm(null);
|
||
};
|
||
|
||
if (loading) {
|
||
return <div style={{ padding: '30px', color: 'var(--text-muted)' }}>Загрузка...</div>;
|
||
}
|
||
|
||
return (
|
||
<div style={{ padding: '30px' }}>
|
||
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.5rem', fontWeight: 800, marginBottom: '24px' }}>
|
||
Пользователи
|
||
</h1>
|
||
|
||
{/* Invite form */}
|
||
<div className="glass-panel" style={{ padding: '24px', marginBottom: '24px', maxWidth: '480px' }}>
|
||
<h2 style={{
|
||
fontFamily: 'var(--font-heading)', fontSize: '1rem', fontWeight: 700,
|
||
marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px',
|
||
}}>
|
||
<UserPlus size={16} color="var(--accent-blue)" />
|
||
Пригласить администратора
|
||
</h2>
|
||
<div style={{ display: 'flex', gap: '10px' }}>
|
||
<input
|
||
type="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
placeholder="email@example.com"
|
||
onKeyDown={(e) => e.key === 'Enter' && handleInvite()}
|
||
style={{
|
||
flex: 1, padding: '10px 12px',
|
||
background: 'var(--bg-lighter)',
|
||
border: '1.5px solid var(--border-light)',
|
||
borderRadius: '6px',
|
||
color: 'var(--text-main)', fontSize: '0.92rem',
|
||
outline: 'none', fontFamily: 'var(--font-body)',
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={handleInvite}
|
||
disabled={inviting || !email.trim()}
|
||
className="btn-primary"
|
||
style={{
|
||
opacity: (inviting || !email.trim()) ? 0.7 : 1,
|
||
cursor: (inviting || !email.trim()) ? 'not-allowed' : 'pointer',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{inviting ? 'Отправка...' : 'Пригласить'}
|
||
</button>
|
||
</div>
|
||
{inviteError && (
|
||
<p style={{ color: '#ef4444', fontSize: '0.85rem', marginTop: '8px' }}>{inviteError}</p>
|
||
)}
|
||
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: '10px' }}>
|
||
Supabase отправит письмо с ссылкой для входа.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Users table */}
|
||
{error && <p style={{ color: '#ef4444', marginBottom: '16px', fontSize: '0.9rem' }}>{error}</p>}
|
||
|
||
<div className="glass-panel" style={{ padding: 0, overflow: 'hidden' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '1px solid var(--border-light)' }}>
|
||
{['Email', 'Зарегистрирован', 'Последний вход', ''].map((h) => (
|
||
<th key={h} style={{
|
||
padding: '12px 16px', textAlign: 'left',
|
||
fontSize: '0.75rem', fontWeight: 700,
|
||
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px',
|
||
}}>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((u) => (
|
||
<tr key={u.id} style={{ borderBottom: '1px solid var(--border-light)' }}>
|
||
<td style={{ padding: '12px 16px', fontSize: '0.9rem', color: 'var(--text-main)' }}>
|
||
{u.email}
|
||
</td>
|
||
<td style={{ padding: '12px 16px', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||
{new Date(u.created_at).toLocaleDateString('ru-RU')}
|
||
</td>
|
||
<td style={{ padding: '12px 16px', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||
{u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString('ru-RU') : '—'}
|
||
</td>
|
||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||
{deleteConfirm === u.id ? (
|
||
<span style={{ display: 'inline-flex', gap: '8px', alignItems: 'center', fontSize: '0.82rem' }}>
|
||
<span style={{ color: 'var(--text-muted)' }}>Удалить?</span>
|
||
<button
|
||
onClick={() => handleDelete(u.id)}
|
||
style={{ background: '#ef4444', border: 'none', color: '#fff', borderRadius: '4px', padding: '3px 10px', cursor: 'pointer', fontSize: '0.82rem' }}
|
||
>
|
||
Да
|
||
</button>
|
||
<button
|
||
onClick={() => setDeleteConfirm(null)}
|
||
style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: '0.82rem' }}
|
||
>
|
||
Отмена
|
||
</button>
|
||
</span>
|
||
) : (
|
||
<button
|
||
onClick={() => setDeleteConfirm(u.id)}
|
||
aria-label={`Удалить ${u.email}`}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(239,68,68,0.6)', padding: '4px' }}
|
||
>
|
||
<Trash2 size={15} />
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{users.length === 0 && (
|
||
<tr>
|
||
<td colSpan={4} style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||
Нет пользователей
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default UserManagementPage;
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: no errors
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/admin/users/UserManagementPage.jsx
|
||
git commit -m "feat: add UserManagementPage with invite and delete"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: OrderModal New Contact Fields
|
||
|
||
**Files:**
|
||
- Modify: `src/admin/orders/OrderModal.jsx`
|
||
|
||
The modal at `src/admin/orders/OrderModal.jsx` currently has a `form` state (line 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
|
||
<div style={{ gridColumn: '1 / -1' }}>
|
||
<label htmlFor="modal-assigned" style={labelStyle}>Ответственный (email)</label>
|
||
<input id="modal-assigned" name="assigned_to" value={form.assigned_to} onChange={set} style={inputStyle} placeholder="manager@company.com" />
|
||
</div>
|
||
```
|
||
|
||
Replace it with (same assigned_to block plus the three new fields before closing the grid):
|
||
|
||
```jsx
|
||
<div style={{ gridColumn: '1 / -1' }}>
|
||
<label htmlFor="modal-assigned" style={labelStyle}>Ответственный (email)</label>
|
||
<input id="modal-assigned" name="assigned_to" value={form.assigned_to} onChange={set} style={inputStyle} placeholder="manager@company.com" />
|
||
</div>
|
||
<div>
|
||
<label htmlFor="modal-telegram" style={labelStyle}>Telegram</label>
|
||
<input id="modal-telegram" name="telegram_username" value={form.telegram_username} onChange={set} style={inputStyle} placeholder="@username" />
|
||
</div>
|
||
<div>
|
||
<label htmlFor="modal-max" style={labelStyle}>Max (ВКонтакте)</label>
|
||
<input id="modal-max" name="max_username" value={form.max_username} onChange={set} style={inputStyle} placeholder="@username" />
|
||
</div>
|
||
<div style={{ gridColumn: '1 / -1' }}>
|
||
<label htmlFor="modal-address" style={labelStyle}>Адрес объекта</label>
|
||
<textarea id="modal-address" name="address" value={form.address} onChange={set} style={{ ...inputStyle, height: '64px', resize: 'vertical' }} />
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: Verify build**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: no errors
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/admin/orders/OrderModal.jsx
|
||
git commit -m "feat: add telegram, max messenger, address fields to OrderModal"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Sidebar Navigation + Route Wiring
|
||
|
||
**Files:**
|
||
- Modify: `src/admin/layout/Sidebar.jsx`
|
||
- Modify: `src/App.jsx`
|
||
|
||
- [ ] **Step 1: Update Sidebar.jsx**
|
||
|
||
In `src/admin/layout/Sidebar.jsx`, replace the entire file content with:
|
||
|
||
```jsx
|
||
import { NavLink, useNavigate } from 'react-router-dom';
|
||
import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings } from 'lucide-react';
|
||
import { useAuth } from '../auth/useAuth';
|
||
|
||
const NAV_ITEMS = [
|
||
{ to: '/admin/kanban', icon: LayoutDashboard, label: 'Канбан' },
|
||
{ to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' },
|
||
{ to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' },
|
||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||
{ to: '/admin/settings', icon: Settings, label: 'Настройки' },
|
||
];
|
||
|
||
const Sidebar = () => {
|
||
const { session, logout } = useAuth();
|
||
const navigate = useNavigate();
|
||
|
||
const handleLogout = async () => {
|
||
try {
|
||
await logout();
|
||
} catch (err) {
|
||
console.error('Logout failed:', err);
|
||
}
|
||
navigate('/admin/login');
|
||
};
|
||
|
||
return (
|
||
<aside style={{
|
||
width: '240px', minHeight: '100vh',
|
||
background: '#0f172a',
|
||
display: 'flex', flexDirection: 'column',
|
||
position: 'fixed', top: 0, left: 0,
|
||
zIndex: 100,
|
||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
||
}}>
|
||
{/* Logo */}
|
||
<div style={{ padding: '22px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||
<Shield size={20} color="var(--accent-blue)" />
|
||
<span style={{ fontFamily: 'var(--font-heading)', fontWeight: 800, color: '#fff', fontSize: '1.05rem' }}>
|
||
Осколкам.<span style={{ color: 'var(--accent-blue)' }}>Нет</span>
|
||
</span>
|
||
</div>
|
||
<p style={{ fontSize: '0.72rem', color: 'rgba(255,255,255,0.35)', marginTop: '3px', marginLeft: '30px' }}>
|
||
Панель управления
|
||
</p>
|
||
</div>
|
||
|
||
{/* Navigation */}
|
||
<nav style={{ flex: 1, padding: '14px 10px' }}>
|
||
{NAV_ITEMS.map(({ to, icon: Icon, label }) => (
|
||
<NavLink
|
||
key={to}
|
||
to={to}
|
||
style={({ isActive }) => ({
|
||
display: 'flex', alignItems: 'center', gap: '12px',
|
||
padding: '11px 14px', borderRadius: '8px', marginBottom: '2px',
|
||
color: isActive ? '#fff' : 'rgba(255,255,255,0.45)',
|
||
background: isActive ? 'rgba(14, 165, 233, 0.15)' : 'transparent',
|
||
textDecoration: 'none',
|
||
fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.88rem',
|
||
transition: 'all 0.15s',
|
||
borderLeft: isActive ? '3px solid var(--accent-blue)' : '3px solid transparent',
|
||
})}
|
||
>
|
||
<Icon size={17} />
|
||
{label}
|
||
</NavLink>
|
||
))}
|
||
</nav>
|
||
|
||
{/* User + logout */}
|
||
<div style={{ padding: '14px 18px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
||
<p style={{
|
||
fontSize: '0.75rem', color: 'rgba(255,255,255,0.35)',
|
||
marginBottom: '10px', overflow: 'hidden',
|
||
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}}>
|
||
{session?.user?.email}
|
||
</p>
|
||
<button
|
||
onClick={handleLogout}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: '8px',
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
color: 'rgba(255,255,255,0.45)',
|
||
fontFamily: 'var(--font-body)', fontSize: '0.82rem', padding: '4px 0',
|
||
transition: 'color 0.15s',
|
||
}}
|
||
onMouseEnter={(e) => e.currentTarget.style.color = '#fff'}
|
||
onMouseLeave={(e) => e.currentTarget.style.color = 'rgba(255,255,255,0.45)'}
|
||
>
|
||
<LogOut size={15} />
|
||
Выйти
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
};
|
||
|
||
export default Sidebar;
|
||
```
|
||
|
||
- [ ] **Step 2: Update App.jsx with new lazy routes**
|
||
|
||
In `src/App.jsx`, add two lazy imports after the existing ones (after line 18, `CompletedOrdersTable`):
|
||
|
||
```js
|
||
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
|
||
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));
|
||
```
|
||
|
||
Then inside the `<Route element={<AdminLayout />}>` block (after the `completed` route), add:
|
||
|
||
```jsx
|
||
<Route path="users" element={<UserManagementPage />} />
|
||
<Route path="settings" element={<SettingsPage />} />
|
||
```
|
||
|
||
The full updated `src/App.jsx`:
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||
import Navbar from './components/Navbar';
|
||
import Hero from './components/Hero';
|
||
import PhysicsOfSafety from './components/PhysicsOfSafety';
|
||
import UseCases from './components/UseCases';
|
||
import Estimator from './components/Estimator';
|
||
import TrustBar from './components/TrustBar';
|
||
import Comparison from './components/Comparison';
|
||
import LeadForm from './components/LeadForm';
|
||
import Footer from './components/Footer';
|
||
|
||
const LoginPage = React.lazy(() => import('./admin/auth/LoginPage'));
|
||
const ProtectedRoute = React.lazy(() => import('./admin/auth/ProtectedRoute'));
|
||
const AdminLayout = React.lazy(() => import('./admin/layout/AdminLayout'));
|
||
const KanbanBoard = React.lazy(() => import('./admin/orders/KanbanBoard'));
|
||
const AnalyticsDashboard = React.lazy(() => import('./admin/analytics/AnalyticsDashboard'));
|
||
const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/CompletedOrdersTable'));
|
||
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
|
||
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));
|
||
|
||
function PublicSite() {
|
||
return (
|
||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||
<Navbar />
|
||
<main style={{ flex: '1' }}>
|
||
<Hero />
|
||
<PhysicsOfSafety />
|
||
<UseCases />
|
||
<TrustBar />
|
||
<Comparison />
|
||
<Estimator />
|
||
<LeadForm />
|
||
</main>
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
return (
|
||
<BrowserRouter>
|
||
<React.Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', fontFamily: 'sans-serif', color: '#475569' }}>Загрузка...</div>}>
|
||
<Routes>
|
||
<Route path="/" element={<PublicSite />} />
|
||
<Route path="/admin/login" element={<LoginPage />} />
|
||
<Route path="/admin" element={<Navigate to="/admin/kanban" replace />} />
|
||
<Route path="/admin/*" element={<ProtectedRoute />}>
|
||
<Route element={<AdminLayout />}>
|
||
<Route path="kanban" element={<KanbanBoard />} />
|
||
<Route path="analytics" element={<AnalyticsDashboard />} />
|
||
<Route path="completed" element={<CompletedOrdersTable />} />
|
||
<Route path="users" element={<UserManagementPage />} />
|
||
<Route path="settings" element={<SettingsPage />} />
|
||
</Route>
|
||
</Route>
|
||
</Routes>
|
||
</React.Suspense>
|
||
</BrowserRouter>
|
||
);
|
||
}
|
||
|
||
export default App;
|
||
```
|
||
|
||
- [ ] **Step 3: Run full test suite**
|
||
|
||
```bash
|
||
npm run test:run
|
||
```
|
||
|
||
Expected: all tests pass (existing 17 + 3 new test files = should be ~29 tests total)
|
||
|
||
- [ ] **Step 4: Run lint**
|
||
|
||
```bash
|
||
npm run lint
|
||
```
|
||
|
||
Expected: 0 new errors (12 pre-existing unused React import warnings in public components are out of scope)
|
||
|
||
- [ ] **Step 5: Final build check**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: build succeeds, no errors
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/admin/layout/Sidebar.jsx src/App.jsx
|
||
git commit -m "feat: wire admin users and settings routes into sidebar and router"
|
||
```
|