From fcc86fd7b937d8d5da14ccf193d1c48d3ea3b47f Mon Sep 17 00:00:00 2001 From: houseassassin Date: Thu, 7 May 2026 20:27:30 +0300 Subject: [PATCH] feat: add useAnalytics hook with KPI and chart data aggregation Co-Authored-By: Claude Sonnet 4.6 --- .../analytics/__tests__/useAnalytics.test.js | 87 +++++++++++++++++ src/admin/analytics/useAnalytics.js | 95 +++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/admin/analytics/__tests__/useAnalytics.test.js create mode 100644 src/admin/analytics/useAnalytics.js diff --git a/src/admin/analytics/__tests__/useAnalytics.test.js b/src/admin/analytics/__tests__/useAnalytics.test.js new file mode 100644 index 0000000..fe5bf06 --- /dev/null +++ b/src/admin/analytics/__tests__/useAnalytics.test.js @@ -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); + }); +}); diff --git a/src/admin/analytics/useAnalytics.js b/src/admin/analytics/useAnalytics.js new file mode 100644 index 0000000..0205643 --- /dev/null +++ b/src/admin/analytics/useAnalytics.js @@ -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 }; +}