Add dashboard and RFV skeleton loaders
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
This commit is contained in:
@@ -75,6 +75,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
.skeleton {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: color-mix(in srgb, var(--color-dark-input) 82%, var(--color-dark-text) 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
color-mix(in srgb, var(--color-dark-text) 14%, transparent),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: skeleton-shimmer 1.45s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.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),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useOutletContext, useNavigate } from 'react-router-dom';
|
import { useOutletContext, useNavigate } from 'react-router-dom';
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LabelList } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LabelList } from 'recharts';
|
||||||
import { DollarSign, Loader2, ShoppingCart, TrendingUp } from 'lucide-react';
|
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
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';
|
||||||
@@ -52,6 +52,69 @@ const kpiIconStyle = (color: string) => ({
|
|||||||
color
|
color
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DashboardSkeleton = () => (
|
||||||
|
<div className="space-y-6" aria-label="Carregando dashboard">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{[0, 1, 2].map(item => (
|
||||||
|
<div key={`dashboard-kpi-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="flex justify-between gap-6">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="skeleton h-4 w-32" />
|
||||||
|
<div className="skeleton mt-3 h-8 w-44" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-12 w-12 shrink-0 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{[0, 1].map(item => (
|
||||||
|
<div key={`dashboard-seller-chart-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="skeleton h-5 w-48" />
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
{[0, 1, 2, 3, 4, 5].map(row => (
|
||||||
|
<div key={`dashboard-bar-skeleton-${item}-${row}`} className="flex items-center gap-4">
|
||||||
|
<div className="skeleton h-3 w-28 shrink-0" />
|
||||||
|
<div className="skeleton h-7" style={{ width: `${88 - row * 10}%` }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="skeleton h-5 w-44" />
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
{[0, 1, 2, 3, 4].map(row => (
|
||||||
|
<div key={`dashboard-table-skeleton-${row}`} className="grid grid-cols-[1fr_120px_80px_120px] gap-4">
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{[0, 1].map(item => (
|
||||||
|
<div key={`dashboard-product-chart-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="skeleton h-5 w-44" />
|
||||||
|
<div className="mt-8 h-80">
|
||||||
|
<div className="flex h-full items-end justify-center gap-3">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6, 7].map(bar => (
|
||||||
|
<div key={`dashboard-column-skeleton-${item}-${bar}`} className="skeleton w-10" style={{ height: `${35 + ((bar * 17) % 55)}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
type ChartTooltipPayload = {
|
type ChartTooltipPayload = {
|
||||||
value: number;
|
value: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -194,6 +257,7 @@ const Dashboard = () => {
|
|||||||
const handleManualRefresh = () => {
|
const handleManualRefresh = () => {
|
||||||
void loadDashboardMetrics(dateRange, { force: true });
|
void loadDashboardMetrics(dateRange, { force: true });
|
||||||
};
|
};
|
||||||
|
const shouldShowSkeleton = isMetricsLoading && !serverMetrics;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -211,12 +275,10 @@ const Dashboard = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMetricsLoading && (
|
{shouldShowSkeleton ? (
|
||||||
<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">
|
<DashboardSkeleton />
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
) : (
|
||||||
Atualizando indicadores
|
<>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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">
|
||||||
@@ -444,6 +506,8 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
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, Loader2, 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 { 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';
|
||||||
@@ -157,6 +157,99 @@ const ScoreBadge = ({ value }: { value: number }) => (
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const RfmSkeleton = () => (
|
||||||
|
<div className="space-y-6" aria-label="Carregando RFV">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{[0, 1, 2].map(item => (
|
||||||
|
<div key={`rfv-kpi-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="skeleton h-4 w-36" />
|
||||||
|
<div className="skeleton mt-3 h-8 w-24" />
|
||||||
|
<div className="skeleton mt-3 h-3 w-44" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-6">
|
||||||
|
<section className="bg-dark-card p-5 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="skeleton h-5 w-28" />
|
||||||
|
<div className="skeleton mt-2 h-3 w-72 max-w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-3 w-44" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 overflow-x-auto">
|
||||||
|
<div className="grid min-w-[720px] grid-cols-[104px_repeat(3,minmax(0,1fr))] gap-2">
|
||||||
|
{[0, 1, 2, 3].map(item => (
|
||||||
|
<div key={`rfv-matrix-head-skeleton-${item}`} className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||||
|
<div className="skeleton mx-auto h-3 w-16" />
|
||||||
|
<div className="skeleton mx-auto mt-2 h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{[0, 1, 2].map(row => (
|
||||||
|
<div key={`rfv-matrix-row-skeleton-${row}`} className="contents">
|
||||||
|
{[0, 1, 2, 3].map(column => (
|
||||||
|
<div key={`rfv-matrix-cell-skeleton-${row}-${column}`} className="min-h-28 rounded-xl border border-dark-border bg-dark-input/50 p-3">
|
||||||
|
<div className="skeleton h-2 w-8" />
|
||||||
|
<div className="skeleton mt-5 h-4 w-28" />
|
||||||
|
<div className="skeleton mt-2 h-3 w-36" />
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||||
|
<div className="skeleton h-5" />
|
||||||
|
<div className="skeleton h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="skeleton h-5 w-28" />
|
||||||
|
<div className="mt-5 space-y-2">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => (
|
||||||
|
<div key={`rfv-segment-skeleton-${item}`} className="rounded-xl border border-dark-border p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-1 items-center gap-3">
|
||||||
|
<div className="skeleton h-2.5 w-2.5 rounded-full" />
|
||||||
|
<div className="skeleton h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-4 w-8" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton mt-3 h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
||||||
|
<div className="flex flex-col gap-3 border-b border-dark-border p-4 sm:flex-row">
|
||||||
|
<div className="skeleton h-10 w-48" />
|
||||||
|
<div className="skeleton h-10 w-72" />
|
||||||
|
<div className="skeleton h-10 w-28 sm:ml-auto" />
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-[1.5fr_1fr_120px_1fr_1fr_1fr_1fr] gap-5">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6].map(item => (
|
||||||
|
<div key={`rfv-table-head-skeleton-${item}`} className="skeleton h-3" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 space-y-5">
|
||||||
|
{[0, 1, 2, 3, 4, 5].map(row => (
|
||||||
|
<div key={`rfv-table-row-skeleton-${row}`} className="grid grid-cols-[1.5fr_1fr_120px_1fr_1fr_1fr_1fr] gap-5">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6].map(column => (
|
||||||
|
<div key={`rfv-table-cell-skeleton-${row}-${column}`} className="skeleton h-4" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const Rfm = () => {
|
const Rfm = () => {
|
||||||
const { dateRange, setDateRange, refreshInterval, setRefreshInterval, themeMode } = useOutletContext<{
|
const { dateRange, setDateRange, refreshInterval, setRefreshInterval, themeMode } = useOutletContext<{
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
@@ -342,6 +435,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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -362,12 +456,10 @@ const Rfm = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{shouldShowSkeleton ? (
|
||||||
<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">
|
<RfmSkeleton />
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
) : (
|
||||||
Atualizando RFV
|
<>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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">
|
||||||
@@ -697,6 +789,8 @@ const Rfm = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user