Optimize client details with opaque tokens
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
This commit is contained in:
@@ -46,7 +46,7 @@ function App() {
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="products/:id" element={<ProductDetails />} />
|
||||
<Route path="clients" element={<Clients />} />
|
||||
<Route path="clients/:customerKey" element={<ClientDetails />} />
|
||||
<Route path="clients/:clientToken" element={<ClientDetails />} />
|
||||
<Route path="rfm" element={<Rfm />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
||||
|
||||
@@ -13,6 +13,7 @@ export type ClientSortOption =
|
||||
|
||||
export interface ClientSummary {
|
||||
customerKey: string;
|
||||
clientToken: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
totalSpent: number;
|
||||
@@ -98,6 +99,7 @@ const enrichClientsWithRfmType = (
|
||||
|
||||
return {
|
||||
...client,
|
||||
clientToken: client.clientToken || client.customerKey,
|
||||
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
|
||||
clientType: getClientType(recencyScore, valueScore),
|
||||
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
|
||||
@@ -147,6 +149,7 @@ export const buildClientsSummary = (
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(customerKey => ({
|
||||
customerKey,
|
||||
clientToken: customerKey,
|
||||
name: clientMap[customerKey].name,
|
||||
phone: clientMap[customerKey].phone,
|
||||
totalSpent: clientMap[customerKey].totalSpent,
|
||||
|
||||
@@ -7,8 +7,7 @@ import { rangeForLastDays } from '../dateRanges';
|
||||
|
||||
const Layout = () => {
|
||||
const location = useLocation();
|
||||
const needsRawData = location.pathname.startsWith('/products') ||
|
||||
(location.pathname.startsWith('/clients/') && location.pathname !== '/clients');
|
||||
const needsRawData = location.pathname.startsWith('/products');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
||||
});
|
||||
@@ -46,7 +45,7 @@ const Layout = () => {
|
||||
useEffect(() => {
|
||||
if (!needsRawData) return;
|
||||
|
||||
// Product pages and client details still depend on raw orders until their API migration is complete.
|
||||
// Product pages still depend on raw orders until their API migration is complete.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadData(true);
|
||||
}, [loadData, needsRawData]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types';
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -154,6 +154,21 @@ export const fetchClientAnalytics = async (dateRange: DateRange): Promise<Client
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise<ClientDetailsAnalytics | null> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start: formatDateParam(dateRange.start),
|
||||
end: formatDateParam(dateRange.end)
|
||||
});
|
||||
const response = await authFetch(`/analytics/clients/${encodeURIComponent(clientToken)}/details?${params.toString()}`);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch client details analytics failed', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
|
||||
try {
|
||||
const response = await authFetch('/campaigns');
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { buildClientDetailsMetrics } from '../analytics/clients';
|
||||
import type { ClientDetailsAnalytics, DateRange } from '../types';
|
||||
import { fetchClientDetailsAnalytics } from '../dataService';
|
||||
|
||||
type CustomTooltipProps = {
|
||||
active?: boolean;
|
||||
@@ -27,34 +27,44 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
||||
};
|
||||
|
||||
const ClientDetails = () => {
|
||||
const { customerKey } = useParams<{ customerKey: string }>();
|
||||
const decodedCustomerKey = customerKey ? decodeURIComponent(customerKey) : '';
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedName = searchParams.get('name') || '';
|
||||
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
|
||||
const { clientToken } = useParams<{ clientToken: string }>();
|
||||
const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : '';
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void,
|
||||
ordersData: OrderData[],
|
||||
isDataLoading: boolean
|
||||
setDateRange: (range: DateRange) => void
|
||||
}>();
|
||||
const [details, setDetails] = useState<ClientDetailsAnalytics | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [ordersPerPage, setOrdersPerPage] = useState(5);
|
||||
|
||||
const {
|
||||
chartData,
|
||||
groupedOrders,
|
||||
allTimeOrderCount,
|
||||
clientName,
|
||||
clientPhone,
|
||||
hasClient,
|
||||
periodAverageTicket,
|
||||
periodItems,
|
||||
periodOrderCount,
|
||||
periodSpent
|
||||
} = useMemo(() => {
|
||||
return buildClientDetailsMetrics(ordersData, decodedCustomerKey, dateRange);
|
||||
}, [dateRange, decodedCustomerKey, ordersData]);
|
||||
const displayName = requestedName || clientName || decodedCustomerKey.replace(/^name:/, '');
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadClientDetails = async () => {
|
||||
if (!decodedClientToken) {
|
||||
if (isMounted) {
|
||||
setDetails(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const nextDetails = await fetchClientDetailsAnalytics(decodedClientToken, dateRange);
|
||||
|
||||
if (isMounted) {
|
||||
setDetails(nextDetails);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadClientDetails();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [dateRange, decodedClientToken]);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
@@ -69,12 +79,7 @@ const ClientDetails = () => {
|
||||
setDateRange(range);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(groupedOrders.length / ordersPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * ordersPerPage;
|
||||
const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage);
|
||||
|
||||
if (!hasClient && isDataLoading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p>
|
||||
@@ -82,7 +87,7 @@ const ClientDetails = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasClient) {
|
||||
if (!details?.hasClient) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Cliente não encontrado.</p>
|
||||
@@ -91,6 +96,23 @@ const ClientDetails = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
chartData,
|
||||
groupedOrders,
|
||||
allTimeOrderCount,
|
||||
clientName,
|
||||
clientPhone,
|
||||
periodAverageTicket,
|
||||
periodItems,
|
||||
periodOrderCount,
|
||||
periodSpent
|
||||
} = details;
|
||||
const displayName = clientName || 'Cliente';
|
||||
const totalPages = Math.ceil(groupedOrders.length / ordersPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * ordersPerPage;
|
||||
const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Area */}
|
||||
|
||||
@@ -109,6 +109,7 @@ const Clients = () => {
|
||||
const rfmClient = rfmByCustomerKey.get(client.customerKey);
|
||||
return {
|
||||
customerKey: client.customerKey,
|
||||
clientToken: client.clientToken,
|
||||
name: client.name,
|
||||
phone: client.phone,
|
||||
totalSpent: client.totalSpent,
|
||||
@@ -315,7 +316,7 @@ const Clients = () => {
|
||||
</td>
|
||||
<td className="px-6 py-2.5 text-right">
|
||||
<Link
|
||||
to={`/clients/${encodeURIComponent(client.customerKey)}?name=${encodeURIComponent(client.name)}`}
|
||||
to={`/clients/${encodeURIComponent(client.clientToken)}`}
|
||||
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
Ver detalhes
|
||||
|
||||
@@ -533,7 +533,7 @@ const Rfm = () => {
|
||||
return (
|
||||
<tr key={client.customerKey} className="hover:bg-dark-input/50 transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<Link to={`/clients/${encodeURIComponent(client.customerKey)}?name=${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
||||
<Link to={`/clients/${encodeURIComponent(client.clientToken)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
||||
<Users className="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
30
src/types.ts
30
src/types.ts
@@ -65,6 +65,7 @@ export interface DashboardAnalytics {
|
||||
|
||||
export interface ClientAnalyticsItem {
|
||||
customerKey: string;
|
||||
clientToken: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
quantityPurchased: number;
|
||||
@@ -75,6 +76,7 @@ export interface ClientAnalyticsItem {
|
||||
|
||||
export interface RfmClient {
|
||||
customerKey: string;
|
||||
clientToken: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
monetary: number;
|
||||
@@ -112,6 +114,34 @@ export interface RfmAnalytics {
|
||||
};
|
||||
}
|
||||
|
||||
export interface GroupedClientOrder {
|
||||
date: string;
|
||||
orderId: string;
|
||||
orderTotal: number;
|
||||
items: OrderData[];
|
||||
}
|
||||
|
||||
export interface ClientDetailsAnalytics {
|
||||
range: {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
};
|
||||
clientToken: string;
|
||||
chartData: Array<{
|
||||
date: string;
|
||||
value: number;
|
||||
}>;
|
||||
groupedOrders: GroupedClientOrder[];
|
||||
allTimeOrderCount: number;
|
||||
clientName: string;
|
||||
clientPhone: string;
|
||||
hasClient: boolean;
|
||||
periodAverageTicket: number;
|
||||
periodOrderCount: number;
|
||||
periodSpent: number;
|
||||
periodItems: number;
|
||||
}
|
||||
|
||||
export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped';
|
||||
|
||||
export interface CampaignQueueItem {
|
||||
|
||||
Reference in New Issue
Block a user