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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user