feat: add useOrders hook with CRUD, status updates, and Realtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:18:15 +03:00
parent bb8d20a853
commit 72407fa9b8
2 changed files with 173 additions and 0 deletions
@@ -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');
});
});
+91
View File
@@ -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 };
}