mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-03-10 04:51:47 +00:00
feat(relatorios): adicionar página de relatório de cartões
- Criar página /relatorios/cartoes com visão geral dos cartões - Adicionar componentes: cards-overview, card-usage-chart, card-top-expenses - Adicionar componentes: card-category-breakdown, card-invoice-status - Criar função de busca de dados para relatório de cartões Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
110
components/relatorios/cartoes/card-category-breakdown.tsx
Normal file
110
components/relatorios/cartoes/card-category-breakdown.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
|
||||
import { getIconComponent } from "@/lib/utils/icons";
|
||||
import { RiPieChartLine } from "@remixicon/react";
|
||||
|
||||
type CardCategoryBreakdownProps = {
|
||||
data: CardDetailData["categoryBreakdown"];
|
||||
};
|
||||
|
||||
const COLORS = [
|
||||
"#ef4444",
|
||||
"#3b82f6",
|
||||
"#10b981",
|
||||
"#f59e0b",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#14b8a6",
|
||||
"#f97316",
|
||||
"#6366f1",
|
||||
"#84cc16",
|
||||
];
|
||||
|
||||
export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Gastos por Categoria
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<RiPieChartLine className="size-8 mb-2" />
|
||||
<p className="text-sm">Nenhum gasto neste período</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Gastos por Categoria
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.map((category, index) => {
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
const color = COLORS[index % COLORS.length];
|
||||
|
||||
return (
|
||||
<div key={category.id} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent ? (
|
||||
<IconComponent
|
||||
className="size-4"
|
||||
style={{ color }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="size-4 rounded-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate max-w-[150px]">
|
||||
{category.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatCurrency(category.amount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${category.percent}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-10 text-right">
|
||||
{category.percent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
99
components/relatorios/cartoes/card-invoice-status.tsx
Normal file
99
components/relatorios/cartoes/card-invoice-status.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
|
||||
|
||||
type CardInvoiceStatusProps = {
|
||||
data: CardDetailData["invoiceStatus"];
|
||||
};
|
||||
|
||||
const monthLabels = [
|
||||
"Jan",
|
||||
"Fev",
|
||||
"Mar",
|
||||
"Abr",
|
||||
"Mai",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Ago",
|
||||
"Set",
|
||||
"Out",
|
||||
"Nov",
|
||||
"Dez",
|
||||
];
|
||||
|
||||
export function CardInvoiceStatus({ data }: CardInvoiceStatusProps) {
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string | null) => {
|
||||
switch (status) {
|
||||
case "pago":
|
||||
return (
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-400 dark:border-green-900">
|
||||
Pago
|
||||
</Badge>
|
||||
);
|
||||
case "pendente":
|
||||
return (
|
||||
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-950 dark:text-yellow-400 dark:border-yellow-900">
|
||||
Pendente
|
||||
</Badge>
|
||||
);
|
||||
case "atrasado":
|
||||
return (
|
||||
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-400 dark:border-red-900">
|
||||
Atrasado
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
—
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPeriod = (period: string) => {
|
||||
const [year, month] = period.split("-");
|
||||
return `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Status das Faturas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{data.map((invoice) => (
|
||||
<div
|
||||
key={invoice.period}
|
||||
className="flex items-center justify-between py-2 border-b last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium w-16">
|
||||
{formatPeriod(invoice.period)}
|
||||
</span>
|
||||
{getStatusBadge(invoice.status)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatCurrency(invoice.amount)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
83
components/relatorios/cartoes/card-top-expenses.tsx
Normal file
83
components/relatorios/cartoes/card-top-expenses.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
|
||||
import { RiShoppingBag3Line } from "@remixicon/react";
|
||||
|
||||
type CardTopExpensesProps = {
|
||||
data: CardDetailData["topExpenses"];
|
||||
};
|
||||
|
||||
export function CardTopExpenses({ data }: CardTopExpensesProps) {
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Maiores Gastos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<RiShoppingBag3Line className="size-8 mb-2" />
|
||||
<p className="text-sm">Nenhum gasto neste período</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Top 10 Gastos do Mês
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{data.map((expense, index) => (
|
||||
<div
|
||||
key={expense.id}
|
||||
className="flex items-center justify-between py-2 border-b last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-xs text-muted-foreground w-5">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate max-w-[200px]">
|
||||
{expense.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{expense.date}</span>
|
||||
{expense.category && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="truncate max-w-[100px]">
|
||||
{expense.category}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-red-500 shrink-0">
|
||||
{formatCurrency(expense.amount)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
143
components/relatorios/cartoes/card-usage-chart.tsx
Normal file
143
components/relatorios/cartoes/card-usage-chart.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, ReferenceLine } from "recharts";
|
||||
|
||||
type CardUsageChartProps = {
|
||||
data: CardDetailData["monthlyUsage"];
|
||||
limit: number;
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
amount: {
|
||||
label: "Uso",
|
||||
color: "#3b82f6",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function CardUsageChart({ data, limit }: CardUsageChartProps) {
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatCurrencyCompact = (value: number) => {
|
||||
if (Math.abs(value) >= 1000) {
|
||||
return new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
}).format(value);
|
||||
}
|
||||
return formatCurrency(value);
|
||||
};
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
month: item.periodLabel,
|
||||
amount: item.amount,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Uso Mensal (6 meses)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-[280px] w-full">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
tickFormatter={formatCurrencyCompact}
|
||||
/>
|
||||
{limit > 0 && (
|
||||
<ReferenceLine
|
||||
y={limit}
|
||||
stroke="#ef4444"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: "Limite",
|
||||
position: "right",
|
||||
className: "text-xs fill-red-500",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0].payload;
|
||||
const value = data.amount as number;
|
||||
const usagePercent = limit > 0 ? (value / limit) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
{data.month}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Uso
|
||||
</span>
|
||||
<span className="text-xs font-medium tabular-nums">
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
{limit > 0 && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
% do Limite
|
||||
</span>
|
||||
<span className="text-xs font-medium tabular-nums">
|
||||
{usagePercent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill="#3b82f6"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={50}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
224
components/relatorios/cartoes/cards-overview.tsx
Normal file
224
components/relatorios/cartoes/cards-overview.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { CartoesReportData } from "@/lib/relatorios/cartoes-report";
|
||||
import {
|
||||
RiBankCard2Line,
|
||||
RiArrowUpLine,
|
||||
RiArrowDownLine,
|
||||
} from "@remixicon/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
type CardsOverviewProps = {
|
||||
data: CartoesReportData;
|
||||
};
|
||||
|
||||
const BRAND_ASSETS: Record<string, string> = {
|
||||
visa: "/bandeiras/visa.svg",
|
||||
mastercard: "/bandeiras/mastercard.svg",
|
||||
amex: "/bandeiras/amex.svg",
|
||||
american: "/bandeiras/amex.svg",
|
||||
elo: "/bandeiras/elo.svg",
|
||||
hipercard: "/bandeiras/hipercard.svg",
|
||||
hiper: "/bandeiras/hipercard.svg",
|
||||
};
|
||||
|
||||
const resolveBrandAsset = (brand: string | null) => {
|
||||
if (!brand) return null;
|
||||
const normalized = brand.trim().toLowerCase();
|
||||
const match = (
|
||||
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
|
||||
).find((entry) => normalized.includes(entry));
|
||||
return match ? BRAND_ASSETS[match] : null;
|
||||
};
|
||||
|
||||
const resolveLogoPath = (logo: string | null) => {
|
||||
if (!logo) return null;
|
||||
if (
|
||||
logo.startsWith("http://") ||
|
||||
logo.startsWith("https://") ||
|
||||
logo.startsWith("data:")
|
||||
) {
|
||||
return logo;
|
||||
}
|
||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
||||
};
|
||||
|
||||
export function CardsOverview({ data }: CardsOverviewProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const periodoParam = searchParams.get("periodo");
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const getUsageColor = (percent: number) => {
|
||||
if (percent < 50) return "bg-green-500";
|
||||
if (percent < 80) return "bg-yellow-500";
|
||||
return "bg-red-500";
|
||||
};
|
||||
|
||||
const buildUrl = (cardId: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (periodoParam) params.set("periodo", periodoParam);
|
||||
params.set("cartao", cardId);
|
||||
return `/relatorios/cartoes?${params.toString()}`;
|
||||
};
|
||||
|
||||
if (data.cards.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Resumo dos Cartões
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<RiBankCard2Line className="size-8 mb-2" />
|
||||
<p className="text-sm">Nenhum cartão ativo encontrado</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Resumo dos Cartões
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground">Limite Total</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{formatCurrency(data.totalLimit)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground">Uso Total</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{formatCurrency(data.totalUsage)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground">Utilização</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{data.totalUsagePercent.toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{data.cards.map((card) => {
|
||||
const logoPath = resolveLogoPath(card.logo);
|
||||
const brandAsset = resolveBrandAsset(card.brand);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={card.id}
|
||||
href={buildUrl(card.id)}
|
||||
className={cn(
|
||||
"flex flex-col py-2 border-b border-dashed last:border-0 transition-colors hover:bg-muted/50",
|
||||
data.selectedCard?.card.id === card.id && "bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{/* Logo container - size-10 like expenses-by-category */}
|
||||
<div className="flex size-10 shrink-0 items-center justify-center">
|
||||
{logoPath ? (
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`Logo ${card.name}`}
|
||||
width={28}
|
||||
height={28}
|
||||
className="rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and brand */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{card.name}
|
||||
</span>
|
||||
{brandAsset && (
|
||||
<Image
|
||||
src={brandAsset}
|
||||
alt={`Bandeira ${card.brand}`}
|
||||
width={24}
|
||||
height={16}
|
||||
className="h-2.5 w-auto shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatCurrency(card.currentUsage)} /{" "}
|
||||
{formatCurrency(card.limit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend and percentage */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{card.usagePercent.toFixed(0)}%
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{card.trend === "up" && (
|
||||
<RiArrowUpLine className="size-3 text-red-500" />
|
||||
)}
|
||||
{card.trend === "down" && (
|
||||
<RiArrowDownLine className="size-3 text-green-500" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
card.trend === "up" && "text-red-500",
|
||||
card.trend === "down" && "text-green-500",
|
||||
card.trend === "stable" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{card.changePercent > 0 ? "+" : ""}
|
||||
{card.changePercent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar - aligned with content */}
|
||||
<div className="ml-12 mt-1.5">
|
||||
<Progress
|
||||
value={Math.min(card.usagePercent, 100)}
|
||||
className={cn(
|
||||
"h-1.5",
|
||||
`[&>div]:${getUsageColor(card.usagePercent)}`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user