Add stale refresh indicator
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
This commit is contained in:
18
src/components/RefreshStatus.tsx
Normal file
18
src/components/RefreshStatus.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
type RefreshStatusProps = {
|
||||||
|
isRefreshing: boolean;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RefreshStatus = ({ isRefreshing, label = 'Atualizando dados', className = '' }: RefreshStatusProps) => {
|
||||||
|
if (!isRefreshing) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`h-1 overflow-hidden rounded-full border border-dark-border bg-dark-input/70 ${className}`} role="status" aria-live="polite">
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
<span className="refresh-progress block h-full w-1/3 rounded-full bg-brand-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RefreshStatus;
|
||||||
@@ -102,6 +102,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.refresh-progress {
|
||||||
|
animation: refresh-progress-slide 1.05s ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 12px color-mix(in srgb, var(--color-brand-primary) 38%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshing-content {
|
||||||
|
opacity: 0.72;
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes refresh-progress-slide {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(320%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0) 24rem),
|
linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0) 24rem),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, Link, useOutletContext } from 'react-router-dom';
|
|||||||
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronDown, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react';
|
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronDown, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react';
|
||||||
import { AreaChart, Area, BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { AreaChart, Area, BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types';
|
import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types';
|
||||||
import { fetchClientDetailsAnalytics } from '../dataService';
|
import { fetchClientDetailsAnalytics } from '../dataService';
|
||||||
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
|
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
|
||||||
@@ -204,7 +205,7 @@ const ClientDetails = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading && !details) {
|
||||||
return <ClientDetailsSkeleton />;
|
return <ClientDetailsSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +238,7 @@ const ClientDetails = () => {
|
|||||||
const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage);
|
const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage);
|
||||||
const hasWeekdayPattern = purchaseWeekdays.some(day => day.value > 0);
|
const hasWeekdayPattern = purchaseWeekdays.some(day => day.value > 0);
|
||||||
const hasHourPattern = purchaseHours.some(hour => hour.value > 0);
|
const hasHourPattern = purchaseHours.some(hour => hour.value > 0);
|
||||||
|
const isRefreshing = isLoading && Boolean(details);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -278,6 +280,9 @@ const ClientDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
|
|
||||||
|
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<div className="bg-dark-card p-5 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
|
<div className="bg-dark-card p-5 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
@@ -582,6 +587,7 @@ const ClientDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react';
|
import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react';
|
||||||
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types';
|
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types';
|
||||||
import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
|
import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
|
||||||
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
|
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
|
||||||
@@ -439,6 +440,7 @@ const Clients = () => {
|
|||||||
(clientTypeFilter === 'all' ? 0 : 1) +
|
(clientTypeFilter === 'all' ? 0 : 1) +
|
||||||
(sortBy === 'recent' ? 0 : 1);
|
(sortBy === 'recent' ? 0 : 1);
|
||||||
const hasActiveFilters = activeFilterCount > 0;
|
const hasActiveFilters = activeFilterCount > 0;
|
||||||
|
const isRefreshing = isLoading && clientAnalytics.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -637,10 +639,12 @@ const Clients = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
|
|
||||||
{isLoading && clientAnalytics.length === 0 ? (
|
{isLoading && clientAnalytics.length === 0 ? (
|
||||||
<ClientsTableSkeleton />
|
<ClientsTableSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
<div className={`bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
|
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useOutletContext, useNavigate } from 'react-router-dom';
|
|||||||
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||||
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
|
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { DashboardAnalytics, OrderData, DateRange } from '../types';
|
import type { DashboardAnalytics, OrderData, DateRange } from '../types';
|
||||||
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
|
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
|
||||||
import { fetchDashboardAnalytics } from '../dataService';
|
import { fetchDashboardAnalytics } from '../dataService';
|
||||||
@@ -459,6 +460,7 @@ const Dashboard = () => {
|
|||||||
void loadDashboardMetrics(dateRange, { force: true });
|
void loadDashboardMetrics(dateRange, { force: true });
|
||||||
};
|
};
|
||||||
const shouldShowSkeleton = isMetricsLoading && !serverMetrics;
|
const shouldShowSkeleton = isMetricsLoading && !serverMetrics;
|
||||||
|
const isRefreshing = isMetricsLoading && Boolean(serverMetrics);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -476,10 +478,12 @@ const Dashboard = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
|
|
||||||
{shouldShowSkeleton ? (
|
{shouldShowSkeleton ? (
|
||||||
<DashboardSkeleton />
|
<DashboardSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
@@ -738,7 +742,7 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, Link, useOutletContext } from 'react-router-dom';
|
|||||||
import { ArrowLeft, Package, DollarSign } from 'lucide-react';
|
import { ArrowLeft, Package, DollarSign } from 'lucide-react';
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
||||||
import { fetchProductDetailsAnalytics } from '../dataService';
|
import { fetchProductDetailsAnalytics } from '../dataService';
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ const ProductDetails = () => {
|
|||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading && !details) {
|
||||||
return <ProductDetailsSkeleton />;
|
return <ProductDetailsSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +125,7 @@ const ProductDetails = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { productInfo, chartData, totalSold, totalRevenue } = details;
|
const { productInfo, chartData, totalSold, totalRevenue } = details;
|
||||||
|
const isRefreshing = isLoading && Boolean(details);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -150,6 +152,9 @@ const ProductDetails = () => {
|
|||||||
onChange={setDateRange}
|
onChange={setDateRange}
|
||||||
/> </div>
|
/> </div>
|
||||||
|
|
||||||
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
|
|
||||||
|
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
@@ -205,6 +210,7 @@ const ProductDetails = () => {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download, Loader2 } from 'lucide-react';
|
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||||
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
||||||
import type { ProductSummary } from '../analytics/products';
|
import type { ProductSummary } from '../analytics/products';
|
||||||
@@ -94,6 +95,7 @@ const Products = () => {
|
|||||||
const totalPages = Math.ceil(productsData.length / itemsPerPage);
|
const totalPages = Math.ceil(productsData.length / itemsPerPage);
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
const paginatedData = productsData.slice(startIndex, startIndex + itemsPerPage);
|
const paginatedData = productsData.slice(startIndex, startIndex + itemsPerPage);
|
||||||
|
const isRefreshing = isLoading && productAnalytics.length > 0;
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
@@ -150,17 +152,12 @@ const Products = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && productAnalytics.length > 0 && (
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
|
||||||
Atualizando produtos
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && productAnalytics.length === 0 ? (
|
{isLoading && productAnalytics.length === 0 ? (
|
||||||
<ProductTableSkeleton />
|
<ProductTableSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
<div className={`bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
|
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { ChevronLeft, ChevronRight, Download, Filter, Search, Users } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Download, Filter, Search, Users } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { exportToCSV, fetchRfmAnalytics, getCachedRfmAnalytics } from '../dataService';
|
import { exportToCSV, fetchRfmAnalytics, getCachedRfmAnalytics } from '../dataService';
|
||||||
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
|
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
|
||||||
|
|
||||||
@@ -451,6 +452,7 @@ const Rfm = () => {
|
|||||||
exportToCSV(exportData, `rfv_${new Date().toISOString().split('T')[0]}.csv`);
|
exportToCSV(exportData, `rfv_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
};
|
};
|
||||||
const shouldShowSkeleton = isLoading && !analytics;
|
const shouldShowSkeleton = isLoading && !analytics;
|
||||||
|
const isRefreshing = isLoading && Boolean(analytics);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -471,10 +473,12 @@ const Rfm = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
|
|
||||||
{shouldShowSkeleton ? (
|
{shouldShowSkeleton ? (
|
||||||
<RfmSkeleton />
|
<RfmSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
@@ -804,7 +808,7 @@ const Rfm = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user