feat: add useAnalytics hook with KPI and chart data aggregation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:27:30 +03:00
parent 7089737f76
commit fcc86fd7b9
2 changed files with 182 additions and 0 deletions
@@ -0,0 +1,87 @@
// src/admin/analytics/__tests__/useAnalytics.test.js
import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
vi.mock('../../../lib/supabase', () => ({
supabase: {
from: vi.fn(),
},
}));
import { useAnalytics } from '../useAnalytics';
import { supabase } from '../../../lib/supabase';
const FROM = '2026-01-01T00:00:00.000Z';
const TO = '2026-12-31T23:59:59.999Z';
describe('useAnalytics', () => {
beforeEach(() => vi.clearAllMocks());
it('returns loading=true initially', () => {
supabase.from.mockReturnValue({
select: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnValue(new Promise(() => {})),
});
const { result } = renderHook(() => useAnalytics(FROM, TO));
expect(result.current.loading).toBe(true);
});
it('computes totalOrders, closedOrders, conversionRate correctly', async () => {
const orders = [
{ id: '1', status: 'новая', object_type: 'industrial', created_at: '2026-03-01', final_cost: null, closed_at: null },
{ id: '2', status: 'закрыт', object_type: 'commercial', created_at: '2026-03-02', final_cost: 50000, closed_at: '2026-03-10' },
{ id: '3', status: 'закрыт', object_type: 'industrial', created_at: '2026-04-01', final_cost: 80000, closed_at: '2026-04-15' },
];
const events = [];
supabase.from.mockImplementation((table) => {
if (table === 'orders') {
return {
select: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockResolvedValue({ data: orders, error: null }),
};
}
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({ data: events, error: null }),
};
});
const { result } = renderHook(() => useAnalytics(FROM, TO));
await act(async () => {});
expect(result.current.totalOrders).toBe(3);
expect(result.current.closedOrders).toBe(2);
expect(result.current.conversionRate).toBe(67);
expect(result.current.totalRevenue).toBe(130000);
expect(result.current.avgDeal).toBe(65000);
expect(result.current.loading).toBe(false);
});
it('builds byObjectType counts', async () => {
const orders = [
{ id: '1', status: 'новая', object_type: 'industrial', created_at: '2026-03-01', final_cost: null, closed_at: null },
{ id: '2', status: 'новая', object_type: 'industrial', created_at: '2026-03-02', final_cost: null, closed_at: null },
{ id: '3', status: 'новая', object_type: 'commercial', created_at: '2026-03-03', final_cost: null, closed_at: null },
];
supabase.from.mockImplementation((table) => {
if (table === 'orders') {
return { select: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), lte: vi.fn().mockResolvedValue({ data: orders, error: null }) };
}
return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), in: vi.fn().mockReturnThis(), order: vi.fn().mockResolvedValue({ data: [], error: null }) };
});
const { result } = renderHook(() => useAnalytics(FROM, TO));
await act(async () => {});
const industrial = result.current.byObjectType.find((x) => x.type === 'industrial');
expect(industrial?.count).toBe(2);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '../../lib/supabase';
const STATUSES = ['новая', 'замер', 'согласование', 'монтаж', 'закрыт'];
export function useAnalytics(dateFrom, dateTo) {
const [analytics, setAnalytics] = useState({
totalOrders: 0, closedOrders: 0, conversionRate: 0,
totalRevenue: 0, avgDeal: 0,
funnelData: STATUSES.map((s) => ({ status: s, count: 0 })),
revenueByMonth: [], byObjectType: [], avgTimeData: [],
});
const [loading, setLoading] = useState(true);
const fetchAnalytics = useCallback(async () => {
setLoading(true);
const { data: orders } = await supabase
.from('orders')
.select('*')
.gte('created_at', dateFrom)
.lte('created_at', dateTo);
const safeOrders = orders || [];
const orderIds = safeOrders.map((o) => o.id);
const { data: rawEvents } = orderIds.length > 0
? await supabase
.from('order_events')
.select('*')
.eq('event_type', 'status_change')
.in('order_id', orderIds)
.order('created_at', { ascending: true })
: { data: [] };
const events = rawEvents || [];
const closed = safeOrders.filter((o) => o.status === 'закрыт');
const totalRevenue = closed.reduce((s, o) => s + (Number(o.final_cost) || 0), 0);
const avgDeal = closed.length > 0 ? Math.round(totalRevenue / closed.length) : 0;
const funnelData = STATUSES.map((status) => ({
status,
count: safeOrders.filter((o) => o.status === status).length,
}));
const revMap = {};
closed.forEach((o) => {
const key = new Date(o.closed_at || o.created_at)
.toLocaleString('ru-RU', { month: 'short', year: '2-digit' });
revMap[key] = (revMap[key] || 0) + (Number(o.final_cost) || 0);
});
const revenueByMonth = Object.entries(revMap).map(([month, revenue]) => ({ month, revenue }));
const typeMap = {};
safeOrders.forEach((o) => {
const type = o.object_type || 'other';
typeMap[type] = (typeMap[type] || 0) + 1;
});
const byObjectType = Object.entries(typeMap).map(([type, count]) => ({ type, count }));
const timeMap = Object.fromEntries(STATUSES.slice(0, -1).map((s) => [s, []]));
safeOrders.forEach((order) => {
const oEvents = events.filter((e) => e.order_id === order.id);
let prevTime = new Date(order.created_at);
let prevStatus = 'новая';
oEvents.forEach((e) => {
const match = e.description?.match(/→ (.+)$/);
if (match) {
const days = (new Date(e.created_at) - prevTime) / 86400000;
if (timeMap[prevStatus]) timeMap[prevStatus].push(days);
prevStatus = match[1].trim();
prevTime = new Date(e.created_at);
}
});
});
const avgTimeData = STATUSES.slice(0, -1).map((status) => ({
status,
avgDays: timeMap[status].length > 0
? Math.round((timeMap[status].reduce((a, b) => a + b, 0) / timeMap[status].length) * 10) / 10
: 0,
}));
setAnalytics({
totalOrders: safeOrders.length,
closedOrders: closed.length,
conversionRate: safeOrders.length > 0 ? Math.round((closed.length / safeOrders.length) * 100) : 0,
totalRevenue, avgDeal, funnelData, revenueByMonth, byObjectType, avgTimeData,
});
setLoading(false);
}, [dateFrom, dateTo]);
useEffect(() => { fetchAnalytics(); }, [fetchAnalytics]);
return { ...analytics, loading };
}