From 3524405712a1626a9edd6cd347ef72c5a2aa31a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 23 Jun 2026 13:46:55 -0300 Subject: [PATCH] New filter panel layout at client page --- backend/routes/analyticsRoutes.js | 21 +- backend/services/analyticsService.js | 134 +++++++- backend/test/analyticsService.test.js | 72 ++++ src/dataService.ts | 30 +- src/pages/Clients.tsx | 474 ++++++++++++++++++++------ src/types.ts | 18 + 6 files changed, 642 insertions(+), 107 deletions(-) diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index 5fee1b5..ac33eb9 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -3,6 +3,7 @@ const { verifyToken } = require('../auth'); const { getClientAnalytics, getClientDetailsAnalytics, + getClientFilterOptions, getDashboardAnalytics, getProductAnalytics, getProductDetailsAnalytics, @@ -16,6 +17,13 @@ const getRange = (query) => ({ end: query.end }); +const getClientAnalyticsFilters = (query) => ({ + ...getRange(query), + marketplace: query.marketplace, + canal_venda: query.canal_venda, + seller: query.seller +}); + router.get('/analytics/dashboard', verifyToken, async (req, res) => { try { res.json(await getDashboardAnalytics(getRange(req.query))); @@ -51,13 +59,22 @@ router.get('/analytics/products/:productId/details', verifyToken, async (req, re router.get('/analytics/clients', verifyToken, async (req, res) => { try { - res.json(await getClientAnalytics(getRange(req.query))); + res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query))); } catch (error) { console.error('Error fetching client analytics:', error); res.status(500).json({ error: 'Internal Server Error' }); } }); +router.get('/analytics/clients/filters', verifyToken, async (req, res) => { + try { + res.json(await getClientFilterOptions(getRange(req.query))); + } catch (error) { + console.error('Error fetching client filter options:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => { try { const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query)); @@ -75,7 +92,7 @@ router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, r router.get('/analytics/rfm', verifyToken, async (req, res) => { try { - res.json(await getRfmAnalytics(getRange(req.query))); + res.json(await getRfmAnalytics(getClientAnalyticsFilters(req.query))); } catch (error) { console.error('Error fetching RFM analytics:', error); res.status(500).json({ error: 'Internal Server Error' }); diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 1b42c6f..b6e94c9 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -125,25 +125,83 @@ const normalizeDateParam = (value) => { return `${yearValue}-${monthValue}-${dayValue}`; }; -const buildDateFilter = ({ start, end } = {}) => { +const normalizeTextFilter = (value) => { + if (value === undefined || value === null) return ''; + return String(value).trim(); +}; + +const normalizeSellerFilter = (value) => { + const normalizedValue = normalizeTextFilter(value); + if (!normalizedValue) return null; + + if (normalizedValue.startsWith('id:')) { + const id = normalizeTextFilter(normalizedValue.slice(3)); + return id ? { type: 'id', value: id } : null; + } + + if (normalizedValue.startsWith('name:')) { + const name = normalizeTextFilter(normalizedValue.slice(5)); + return name ? { type: 'name', value: name } : null; + } + + return { type: 'any', value: normalizedValue }; +}; + +const appendOrderMetadataFilters = (params, filters, range = {}) => { + const marketplace = normalizeTextFilter(range.marketplace); + const salesChannel = normalizeTextFilter(range.canal_venda || range.canalVenda); + const seller = normalizeSellerFilter(range.seller || range.vendedor); + + if (marketplace) { + params.push(marketplace); + filters.push(`NULLIF(TRIM(marketplace), '') = $${params.length}`); + } + + if (salesChannel) { + params.push(salesChannel); + filters.push(`NULLIF(TRIM(canal_venda), '') = $${params.length}`); + } + + if (seller?.type === 'id') { + params.push(seller.value); + filters.push(`NULLIF(TRIM(id_vendedor), '') = $${params.length}`); + } else if (seller?.type === 'name') { + params.push(seller.value); + filters.push(`NULLIF(TRIM(nome_vendedor), '') = $${params.length}`); + } else if (seller?.type === 'any') { + params.push(seller.value); + filters.push(`(NULLIF(TRIM(id_vendedor), '') = $${params.length} OR NULLIF(TRIM(nome_vendedor), '') = $${params.length})`); + } +}; + +const buildDateFilter = (range = {}) => { + const { start, end } = range; const params = []; const filters = ['data_pedido_date IS NOT NULL']; const normalizedStart = normalizeDateParam(start); const normalizedEnd = normalizeDateParam(end); + let startParamIndex = null; + let endParamIndex = null; if (normalizedStart) { params.push(normalizedStart); + startParamIndex = params.length; filters.push(`data_pedido_date >= $${params.length}::date`); } if (normalizedEnd) { params.push(normalizedEnd); + endParamIndex = params.length; filters.push(`data_pedido_date <= $${params.length}::date`); } + appendOrderMetadataFilters(params, filters, range); + return { params, - whereClause: `WHERE ${filters.join(' AND ')}` + whereClause: `WHERE ${filters.join(' AND ')}`, + startParamIndex, + endParamIndex }; }; @@ -576,6 +634,63 @@ const getClientAnalytics = async (range = {}) => { return clients; }; +const getClientFilterOptions = async (range = {}) => { + const dateRange = { + start: range.start, + end: range.end + }; + const { params, whereClause } = buildDateFilter(dateRange); + const [marketplaceResult, salesChannelResult, sellerResult] = await Promise.all([ + pool.query(` + SELECT DISTINCT NULLIF(TRIM(marketplace), '') as value + FROM orders + ${whereClause} + AND NULLIF(TRIM(marketplace), '') IS NOT NULL + ORDER BY value ASC; + `, params), + pool.query(` + SELECT DISTINCT NULLIF(TRIM(canal_venda), '') as value + FROM orders + ${whereClause} + AND NULLIF(TRIM(canal_venda), '') IS NOT NULL + ORDER BY value ASC; + `, params), + pool.query(` + SELECT + NULLIF(TRIM(id_vendedor), '') as id, + NULLIF(TRIM(nome_vendedor), '') as name + FROM orders + ${whereClause} + AND ( + NULLIF(TRIM(id_vendedor), '') IS NOT NULL + OR NULLIF(TRIM(nome_vendedor), '') IS NOT NULL + ) + GROUP BY id, name + ORDER BY COALESCE(name, id) ASC, id ASC; + `, params) + ]); + const sellerOptionsByValue = new Map(); + + sellerResult.rows.forEach(row => { + const id = row.id || ''; + const name = row.name || ''; + const value = id ? `id:${id}` : `name:${name}`; + if (!value || sellerOptionsByValue.has(value)) return; + + sellerOptionsByValue.set(value, { + value, + id, + name: name || id + }); + }); + + return { + marketplaces: marketplaceResult.rows.map(row => row.value).filter(Boolean), + salesChannels: salesChannelResult.rows.map(row => row.value).filter(Boolean), + sellers: [...sellerOptionsByValue.values()] + }; +}; + const getOrderGroupKey = (row) => ( row.pedido_id || `${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}` @@ -720,14 +835,14 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { }; const getRfmAnalytics = async (range = {}) => { - const { params, whereClause } = buildDateFilter(range); + const { params, whereClause, endParamIndex } = buildDateFilter(range); const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); - const periodRecencyReferenceDate = normalizedEnd ? `$${params.length}::date` : 'CURRENT_DATE'; + const periodRecencyReferenceDate = normalizedEnd && endParamIndex ? `$${endParamIndex}::date` : 'CURRENT_DATE'; const usePeriodAsHistory = !normalizedStart || normalizedStart <= '2000-01-01'; if (usePeriodAsHistory) { - const clientRows = await getClientAnalytics({ end: normalizedEnd }); + const clientRows = await getClientAnalytics({ ...range, start: undefined, end: normalizedEnd }); const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10); const clients = buildRfmClients(clientRows.map(row => ({ customerKey: row.customerKey, @@ -803,6 +918,11 @@ const getRfmAnalytics = async (range = {}) => { const recencyReferenceDate = normalizedEnd ? `$${historyParams.push(normalizedEnd)}::date` : 'CURRENT_DATE'; + const historyFilters = [ + 'data_pedido_date IS NOT NULL', + `data_pedido_date <= ${recencyReferenceDate}` + ]; + appendOrderMetadataFilters(historyParams, historyFilters, range); const customerKeysParam = `$${historyParams.push(periodCustomerKeys)}::text[]`; const historyResult = await client.query(` @@ -817,8 +937,7 @@ const getRfmAnalytics = async (range = {}) => { MAX(data_pedido_date) as last_purchase_date, GREATEST((${recencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days FROM orders - WHERE data_pedido_date IS NOT NULL - AND data_pedido_date <= ${recencyReferenceDate} + WHERE ${historyFilters.join(' AND ')} GROUP BY customer_key ), scored_history AS MATERIALIZED ( @@ -923,6 +1042,7 @@ module.exports = { isClientToken, getFrequencyScore, getClientDetailsAnalytics, + getClientFilterOptions, getPreviousDate, getRecencyScore, getRfmAnalytics, diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 8a0e433..d03dbdd 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -10,6 +10,7 @@ const { isClientToken, getClientAnalytics, getClientDetailsAnalytics, + getClientFilterOptions, getPreviousDate, getProductAnalytics, getProductDetailsAnalytics, @@ -91,6 +92,33 @@ test('buildDateFilter ignores invalid bounds', () => { ); }); +test('buildDateFilter composes client metadata predicates after date predicates', () => { + const filter = buildDateFilter({ + start: '2026-05-01', + end: '2026-05-28', + marketplace: ' Mercado Livre ', + canal_venda: ' Online ', + seller: 'id:VEN-1' + }); + + assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28', 'Mercado Livre', 'Online', 'VEN-1']); + assert.equal(filter.endParamIndex, 2); + assert.equal( + filter.whereClause, + "WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date AND NULLIF(TRIM(marketplace), '') = $3 AND NULLIF(TRIM(canal_venda), '') = $4 AND NULLIF(TRIM(id_vendedor), '') = $5" + ); +}); + +test('buildDateFilter can filter seller by display name when no seller id is selected', () => { + const filter = buildDateFilter({ seller: 'name:Maria' }); + + assert.deepEqual(filter.params, ['Maria']); + assert.equal( + filter.whereClause, + "WHERE data_pedido_date IS NOT NULL AND NULLIF(TRIM(nome_vendedor), '') = $1" + ); +}); + test('getPreviousDate returns the calendar day before an ISO date', () => { assert.equal(getPreviousDate('2026-06-15'), '2026-06-14'); assert.equal(getPreviousDate('2026-03-01'), '2026-02-28'); @@ -540,6 +568,50 @@ test('getClientAnalytics returns opaque client tokens', async () => { } }); +test('getClientFilterOptions returns distinct date-scoped order metadata options', async () => { + const originalQuery = pool.query; + const calls = []; + + pool.query = async (sql, params = []) => { + calls.push({ sql, params }); + + if (sql.includes('SELECT DISTINCT NULLIF(TRIM(marketplace)')) { + return { rows: [{ value: 'Mercado Livre' }, { value: 'Shopee' }] }; + } + + if (sql.includes('SELECT DISTINCT NULLIF(TRIM(canal_venda)')) { + return { rows: [{ value: 'Online' }] }; + } + + return { + rows: [ + { id: 'VEN-1', name: 'Maria' }, + { id: '', name: 'Sem ID' }, + { id: 'VEN-1', name: 'Maria' } + ] + }; + }; + + try { + const options = await getClientFilterOptions({ start: '2026-06-01', end: '2026-06-15' }); + + assert.equal(calls.length, 3); + calls.forEach(call => { + assert.deepEqual(call.params, ['2026-06-01', '2026-06-15']); + assert.match(call.sql, /data_pedido_date >= \$1::date/); + assert.match(call.sql, /data_pedido_date <= \$2::date/); + }); + assert.deepEqual(options.marketplaces, ['Mercado Livre', 'Shopee']); + assert.deepEqual(options.salesChannels, ['Online']); + assert.deepEqual(options.sellers, [ + { value: 'id:VEN-1', id: 'VEN-1', name: 'Maria' }, + { value: 'name:Sem ID', id: '', name: 'Sem ID' } + ]); + } finally { + pool.query = originalQuery; + } +}); + test('getClientDetailsAnalytics fetches only the tokenized client and period rows', async () => { const originalQuery = pool.query; const calls = []; diff --git a/src/dataService.ts b/src/dataService.ts index 27f6f56..db1a6e6 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -154,12 +154,35 @@ export const fetchProductDetailsAnalytics = async (productId: string, dateRange: } }; -export const fetchRfmAnalytics = async (dateRange: DateRange): Promise => { +const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial) => { + if (!filters) return; + if (filters.marketplace) params.set('marketplace', filters.marketplace); + if (filters.canal_venda) params.set('canal_venda', filters.canal_venda); + if (filters.seller) params.set('seller', filters.seller); +}; + +export const fetchClientFilterOptions = async (dateRange: DateRange): Promise => { try { const params = new URLSearchParams({ start: formatDateParam(dateRange.start), end: formatDateParam(dateRange.end) }); + const response = await authFetch(`/analytics/clients/filters?${params.toString()}`); + if (!response.ok) return { marketplaces: [], salesChannels: [], sellers: [] }; + return await response.json(); + } catch (error) { + console.error('Fetch client filter options failed', error); + return { marketplaces: [], salesChannels: [], sellers: [] }; + } +}; + +export const fetchRfmAnalytics = async (dateRange: DateRange, filters?: Partial): Promise => { + try { + const params = new URLSearchParams({ + start: formatDateParam(dateRange.start), + end: formatDateParam(dateRange.end) + }); + appendClientMetadataFilterParams(params, filters); const response = await authFetch(`/analytics/rfm?${params.toString()}`); if (!response.ok) return null; return await response.json(); @@ -169,12 +192,13 @@ export const fetchRfmAnalytics = async (dateRange: DateRange): Promise => { +export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Partial): Promise => { try { const params = new URLSearchParams({ start: formatDateParam(dateRange.start), end: formatDateParam(dateRange.end) }); + appendClientMetadataFilterParams(params, filters); const response = await authFetch(`/analytics/clients?${params.toString()}`); if (!response.ok) return []; return await response.json(); diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 08b5775..e1a7356 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; -import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react'; -import type { ClientAnalyticsItem, DateRange, RfmAnalytics, RfmClient } from '../types'; -import { exportToCSV, fetchClientAnalytics, fetchRfmAnalytics } from '../dataService'; -import DateRangePicker from '../components/DateRangePicker'; +import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react'; +import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types'; +import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics } from '../dataService'; +import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges'; import type { ClientSortOption, ClientSummary } from '../analytics/clients'; const clientTypeStyles: Record = { @@ -64,6 +64,47 @@ const sortClients = (clients: ClientSummary[], sortBy: ClientSortOption) => { }); }; +const emptyClientFilters: ClientMetadataFilters = { + marketplace: '', + canal_venda: '', + seller: '' +}; + +const emptyClientFilterOptions: ClientFilterOptions = { + marketplaces: [], + salesChannels: [], + sellers: [] +}; + +const dateFilterPresets = [ + { value: 'today', label: 'Hoje', getRange: () => rangeForDay(new Date()) }, + { value: 'yesterday', label: 'Ontem', getRange: () => rangeForPreviousDay() }, + { value: '7d', label: 'Últimos 7 dias', getRange: () => rangeForLastDays(7) }, + { value: '30d', label: 'Últimos 30 dias', getRange: () => rangeForLastDays(30) }, + { value: 'month', label: 'Este mês', getRange: () => { + const end = endOfLocalDay(new Date()); + return { start: startOfLocalDay(new Date(end.getFullYear(), end.getMonth(), 1)), end }; + } }, + { value: 'previous-month', label: 'Mês passado', getRange: () => { + const today = new Date(); + return { + start: startOfLocalDay(new Date(today.getFullYear(), today.getMonth() - 1, 1)), + end: endOfLocalDay(new Date(today.getFullYear(), today.getMonth(), 0)) + }; + } }, + { value: '90d', label: 'Últimos 90 dias', getRange: () => rangeForLastDays(90) }, + { value: 'year', label: 'Este ano', getRange: () => { + const end = endOfLocalDay(new Date()); + return { start: startOfLocalDay(new Date(end.getFullYear(), 0, 1)), end }; + } }, + { value: 'all', label: 'Todo o período', getRange: () => ({ + start: startOfLocalDay(new Date(2000, 0, 1)), + end: endOfLocalDay(new Date()) + }) } +]; + +const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer"; + const Clients = () => { const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange, @@ -72,6 +113,10 @@ const Clients = () => { const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState('recent'); const [clientTypeFilter, setClientTypeFilter] = useState('all'); + const [metadataFilters, setMetadataFilters] = useState(emptyClientFilters); + const [filterOptions, setFilterOptions] = useState(emptyClientFilterOptions); + const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); + const filterMenuRef = useRef(null); const [rfmAnalytics, setRfmAnalytics] = useState(null); const [clientAnalytics, setClientAnalytics] = useState([]); @@ -83,13 +128,15 @@ const Clients = () => { let isMounted = true; const loadClients = async () => { - const clientsData = await fetchClientAnalytics(dateRange); + const options = await fetchClientFilterOptions(dateRange); + const clientsData = await fetchClientAnalytics(dateRange, metadataFilters); if (isMounted) { + setFilterOptions(options); setClientAnalytics(clientsData); setRfmAnalytics(null); } - const rfmData = await fetchRfmAnalytics(dateRange); + const rfmData = await fetchRfmAnalytics(dateRange, metadataFilters); if (isMounted) { setRfmAnalytics(rfmData); } @@ -100,23 +147,59 @@ const Clients = () => { return () => { isMounted = false; }; - }, [dateRange]); + }, [dateRange, metadataFilters]); + + useEffect(() => { + if (!isFilterMenuOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if (!filterMenuRef.current?.contains(event.target as Node)) { + setIsFilterMenuOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsFilterMenuOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isFilterMenuOpen]); const allClientsData = useMemo(() => { const normalizedSearch = searchTerm.trim().toLowerCase(); const rfmByCustomerKey = new Map((rfmAnalytics?.clients || []).map(client => [client.customerKey, client])); const clients = clientAnalytics.map((client): ClientSummary => { + const rawClient = client as ClientAnalyticsItem & { + client_token?: string; + customer_key?: string; + last_purchase_date?: string; + order_count?: number; + quantity_purchased?: number; + total_spent?: number; + }; const rfmClient = rfmByCustomerKey.get(client.customerKey); + const orderCount = Number(client.orderCount ?? rawClient.order_count ?? 0); + const totalSpent = Number(client.totalSpent ?? rawClient.total_spent ?? 0); + const totalItems = Number(client.quantityPurchased ?? rawClient.quantity_purchased ?? 0); + const lastPurchaseDate = client.lastPurchaseDate ?? rawClient.last_purchase_date; + return { - customerKey: client.customerKey, - clientToken: client.clientToken, + customerKey: client.customerKey ?? rawClient.customer_key ?? '', + clientToken: client.clientToken ?? rawClient.client_token ?? '', name: client.name, phone: client.phone, - totalSpent: client.totalSpent, - averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0, - totalItems: client.quantityPurchased, - orderCount: client.orderCount, - lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0, + totalSpent, + averageTicket: orderCount ? totalSpent / orderCount : 0, + totalItems, + orderCount, + lastPurchase: lastPurchaseDate ? new Date(lastPurchaseDate).getTime() : 0, clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Sem análise', rfmScore: rfmClient?.rfmScore || '000', rfmPriority: rfmClient ? getRfmPriority(rfmClient) : 0 @@ -158,50 +241,133 @@ const Clients = () => { return new Intl.NumberFormat('pt-BR').format(value); }; + const datePresetValue = useMemo(() => { + const currentStart = formatDateParam(dateRange.start); + const currentEnd = formatDateParam(dateRange.end); + const preset = dateFilterPresets.find(option => { + const range = option.getRange(); + return formatDateParam(range.start) === currentStart && formatDateParam(range.end) === currentEnd; + }); + + return preset?.value || 'custom'; + }, [dateRange]); + + const updateDatePreset = (value: string) => { + if (value === 'custom') return; + + const preset = dateFilterPresets.find(option => option.value === value); + if (!preset) return; + + setDateRange(preset.getRange()); + setCurrentPage(1); + }; + + const updateDateStart = (value: string) => { + const nextStart = parseLocalDateInput(value); + if (!nextStart) return; + + const start = startOfLocalDay(nextStart); + const end = dateRange.end < start ? endOfLocalDay(start) : dateRange.end; + setDateRange({ start, end }); + setCurrentPage(1); + }; + + const updateDateEnd = (value: string) => { + const nextEnd = parseLocalDateInput(value); + if (!nextEnd) return; + + const end = endOfLocalDay(nextEnd); + const start = dateRange.start > end ? startOfLocalDay(end) : dateRange.start; + setDateRange({ start, end }); + setCurrentPage(1); + }; + const selectClientType = (type: string) => { setClientTypeFilter(current => current === type ? 'all' : type); setCurrentPage(1); }; + const updateMetadataFilter = (key: keyof ClientMetadataFilters, value: string) => { + setMetadataFilters(current => ({ + ...current, + [key]: value + })); + setCurrentPage(1); + }; + + const resetMetadataFilters = () => { + setMetadataFilters(emptyClientFilters); + setClientTypeFilter('all'); + setSortBy('recent'); + setCurrentPage(1); + }; + + const getSellerOptionLabel = (seller: ClientFilterOptions['sellers'][number]) => { + if (seller.id && seller.name && seller.id !== seller.name) return `${seller.name} (${seller.id})`; + return seller.name || seller.id; + }; + + const marketplaceOptions = useMemo(() => { + if (!metadataFilters.marketplace || filterOptions.marketplaces.includes(metadataFilters.marketplace)) { + return filterOptions.marketplaces; + } + + return [metadataFilters.marketplace, ...filterOptions.marketplaces]; + }, [filterOptions.marketplaces, metadataFilters.marketplace]); + + const salesChannelOptions = useMemo(() => { + if (!metadataFilters.canal_venda || filterOptions.salesChannels.includes(metadataFilters.canal_venda)) { + return filterOptions.salesChannels; + } + + return [metadataFilters.canal_venda, ...filterOptions.salesChannels]; + }, [filterOptions.salesChannels, metadataFilters.canal_venda]); + + const sellerOptions = useMemo(() => { + if (!metadataFilters.seller || filterOptions.sellers.some(seller => seller.value === metadataFilters.seller)) { + return filterOptions.sellers; + } + + const fallbackSeller = metadataFilters.seller.startsWith('id:') + ? { value: metadataFilters.seller, id: metadataFilters.seller.slice(3), name: metadataFilters.seller.slice(3) } + : { value: metadataFilters.seller, id: '', name: metadataFilters.seller.replace(/^name:/, '') }; + + return [fallbackSeller, ...filterOptions.sellers]; + }, [filterOptions.sellers, metadataFilters.seller]); + + const activeMetadataFilters = useMemo(() => { + const filters = []; + const selectedSeller = sellerOptions.find(seller => seller.value === metadataFilters.seller); + + if (metadataFilters.marketplace) { + filters.push({ key: 'marketplace' as const, label: `Marketplace: ${metadataFilters.marketplace}` }); + } + + if (metadataFilters.canal_venda) { + filters.push({ key: 'canal_venda' as const, label: `Canal: ${metadataFilters.canal_venda}` }); + } + + if (metadataFilters.seller) { + filters.push({ key: 'seller' as const, label: `Vendedor: ${selectedSeller ? getSellerOptionLabel(selectedSeller) : metadataFilters.seller}` }); + } + + return filters; + }, [metadataFilters, sellerOptions]); + const activeFilterCount = activeMetadataFilters.length + + (clientTypeFilter === 'all' ? 0 : 1) + + (sortBy === 'recent' ? 0 : 1); + const hasActiveFilters = activeFilterCount > 0; + return (
-
+

Clientes

-

Métricas de engajamento e histórico de consumo dos seus clientes.

+

Métricas de engajamento e histórico de consumo dos seus clientes.

- -
- { - setDateRange(range); - setCurrentPage(1); - }} - /> - -
- - -
-
+
+
{ setSearchTerm(e.target.value); setCurrentPage(1); }} - className="w-full sm:w-64 bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm" + className="w-full bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm" />
- - -
-
-
-
-
-

Tipos de Clientes

-

- Classificação RFV sincronizada com a página RFV quando disponível. -

-
-
- -
- {clientTypes.map(type => ( +
- ))} + + {isFilterMenuOpen && ( +
+
+

Filtros

+ +
+ +
+ + + + + + + + + + + + + + + +
+ +
+ + +
+
+ )} +
diff --git a/src/types.ts b/src/types.ts index 1b4d9fc..e7d4426 100644 --- a/src/types.ts +++ b/src/types.ts @@ -110,6 +110,24 @@ export interface ClientAnalyticsItem { lastPurchaseDate: string; } +export interface ClientMetadataFilters { + marketplace: string; + canal_venda: string; + seller: string; +} + +export interface ClientSellerFilterOption { + value: string; + id: string; + name: string; +} + +export interface ClientFilterOptions { + marketplaces: string[]; + salesChannels: string[]; + sellers: ClientSellerFilterOption[]; +} + export interface RfmClient { customerKey: string; clientToken: string;