From 72407fa9b823f45ef57d3ffb19d74a45a621fe7d Mon Sep 17 00:00:00 2001 From: houseassassin Date: Thu, 7 May 2026 20:18:15 +0300 Subject: [PATCH] feat: add useOrders hook with CRUD, status updates, and Realtime Co-Authored-By: Claude Sonnet 4.6 --- src/admin/orders/__tests__/useOrders.test.js | 82 ++++++++++++++++++ src/admin/orders/useOrders.js | 91 ++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/admin/orders/__tests__/useOrders.test.js create mode 100644 src/admin/orders/useOrders.js diff --git a/src/admin/orders/__tests__/useOrders.test.js b/src/admin/orders/__tests__/useOrders.test.js new file mode 100644 index 0000000..9ed347a --- /dev/null +++ b/src/admin/orders/__tests__/useOrders.test.js @@ -0,0 +1,82 @@ +// src/admin/orders/__tests__/useOrders.test.js +import { renderHook, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +const mockChannel = vi.hoisted(() => ({ + on: vi.fn().mockReturnThis(), + subscribe: vi.fn().mockReturnThis(), +})); + +vi.mock('../../../lib/supabase', () => ({ + supabase: { + from: vi.fn(), + channel: vi.fn(() => mockChannel), + removeChannel: vi.fn(), + }, +})); + +import { useOrders } from '../useOrders'; +import { supabase } from '../../../lib/supabase'; + +function makeFromMock(resolvedValue) { + return { + select: vi.fn().mockReturnThis(), + order: vi.fn().mockResolvedValue(resolvedValue), + update: vi.fn().mockReturnThis(), + insert: vi.fn().mockResolvedValue({ error: null }), + eq: vi.fn().mockResolvedValue({ data: null, error: null }), + single: vi.fn().mockResolvedValue({ data: null, error: null }), + }; +} + +describe('useOrders', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockChannel.on.mockReturnThis(); + mockChannel.subscribe.mockReturnThis(); + }); + + it('starts with loading=true and empty orders', () => { + supabase.from.mockReturnValue({ + select: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnValue(new Promise(() => {})), + }); + const { result } = renderHook(() => useOrders()); + expect(result.current.loading).toBe(true); + expect(result.current.orders).toEqual([]); + }); + + it('populates orders and sets loading=false after fetch', async () => { + const mockOrders = [ + { id: '1', name: 'Иванов', status: 'новая', created_at: '2026-01-01' }, + { id: '2', name: 'Петров', status: 'замер', created_at: '2026-01-02' }, + ]; + supabase.from.mockReturnValue(makeFromMock({ data: mockOrders, error: null })); + const { result } = renderHook(() => useOrders()); + await act(async () => {}); + expect(result.current.loading).toBe(false); + expect(result.current.orders).toEqual(mockOrders); + }); + + it('groups orders by status in ordersByStatus', async () => { + const mockOrders = [ + { id: '1', name: 'А', status: 'новая', created_at: '2026-01-01' }, + { id: '2', name: 'Б', status: 'замер', created_at: '2026-01-02' }, + { id: '3', name: 'В', status: 'новая', created_at: '2026-01-03' }, + ]; + supabase.from.mockReturnValue(makeFromMock({ data: mockOrders, error: null })); + const { result } = renderHook(() => useOrders()); + await act(async () => {}); + expect(result.current.ordersByStatus['новая']).toHaveLength(2); + expect(result.current.ordersByStatus['замер']).toHaveLength(1); + expect(result.current.ordersByStatus['монтаж']).toHaveLength(0); + expect(result.current.ordersByStatus['закрыт']).toHaveLength(0); + }); + + it('sets error when fetch fails', async () => { + supabase.from.mockReturnValue(makeFromMock({ data: null, error: { message: 'DB error' } })); + const { result } = renderHook(() => useOrders()); + await act(async () => {}); + expect(result.current.error).toBe('DB error'); + }); +}); diff --git a/src/admin/orders/useOrders.js b/src/admin/orders/useOrders.js new file mode 100644 index 0000000..ec65182 --- /dev/null +++ b/src/admin/orders/useOrders.js @@ -0,0 +1,91 @@ +import { useState, useEffect, useCallback } from 'react'; +import { supabase } from '../../lib/supabase'; + +const STATUSES = ['новая', 'замер', 'согласование', 'монтаж', 'закрыт']; + +export function useOrders() { + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchOrders = useCallback(async () => { + const { data, error } = await supabase + .from('orders') + .select('*') + .order('created_at', { ascending: false }); + + if (error) { + setError(error.message); + } else { + setOrders(data || []); + } + setLoading(false); + }, []); + + useEffect(() => { + fetchOrders(); + + const channel = supabase + .channel('orders-realtime') + .on('postgres_changes', { event: '*', schema: 'public', table: 'orders' }, fetchOrders) + .subscribe(); + + return () => supabase.removeChannel(channel); + }, [fetchOrders]); + + const ordersByStatus = STATUSES.reduce((acc, status) => { + acc[status] = orders.filter((o) => o.status === status); + return acc; + }, {}); + + const updateStatus = async (orderId, newStatus, userEmail) => { + const order = orders.find((o) => o.id === orderId); + const updates = { status: newStatus }; + if (newStatus === 'закрыт') updates.closed_at = new Date().toISOString(); + + const { error } = await supabase.from('orders').update(updates).eq('id', orderId); + + if (!error) { + await supabase.from('order_events').insert({ + order_id: orderId, + created_by: userEmail, + event_type: 'status_change', + description: `Статус изменён: ${order?.status} → ${newStatus}`, + }); + } + return { error }; + }; + + const createOrder = async (orderData, userEmail) => { + const { data, error } = await supabase + .from('orders') + .insert({ ...orderData, source: 'manual', status: 'новая' }) + .select() + .single(); + + if (!error && data) { + await supabase.from('order_events').insert({ + order_id: data.id, + created_by: userEmail, + event_type: 'status_change', + description: 'Заявка создана вручную', + }); + } + return { data, error }; + }; + + const updateOrder = async (orderId, updates, userEmail) => { + const { error } = await supabase.from('orders').update(updates).eq('id', orderId); + if (!error) { + await supabase.from('order_events').insert({ + order_id: orderId, + created_by: userEmail, + event_type: 'edit', + description: 'Данные заказа обновлены', + }); + } + return { error }; + }; + + return { orders, ordersByStatus, loading, error, updateStatus, createOrder, updateOrder, refetch: fetchOrders }; +}