mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor: compartilha utilitários e refina widgets e calendário
This commit is contained in:
@@ -136,6 +136,8 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
onCreate(day);
|
onCreate(day);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const overflowCount = day.events.length - previewEvents.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -143,7 +145,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
onClick={() => onSelect(day)}
|
onClick={() => onSelect(day)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent transition-colors duration-300",
|
"group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent",
|
||||||
!day.isCurrentMonth && "opacity-60",
|
!day.isCurrentMonth && "opacity-60",
|
||||||
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
|
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
|
||||||
)}
|
)}
|
||||||
@@ -153,7 +155,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold leading-none",
|
"text-sm font-semibold leading-none",
|
||||||
day.isToday
|
day.isToday
|
||||||
? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center"
|
? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center"
|
||||||
: "text-foreground/90",
|
: "text-foreground/90",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -162,7 +164,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreateClick}
|
onClick={handleCreateClick}
|
||||||
className="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
className="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground opacity-0 transition-all group-hover:opacity-100 hover:bg-primary/20 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||||
aria-label={`Criar lançamento em ${day.date}`}
|
aria-label={`Criar lançamento em ${day.date}`}
|
||||||
>
|
>
|
||||||
<RiAddLine className="size-3.5" />
|
<RiAddLine className="size-3.5" />
|
||||||
@@ -170,13 +172,14 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
{previewEvents.map((event) => (
|
{day.isCurrentMonth &&
|
||||||
<DayEventPreview key={event.id} event={event} />
|
previewEvents.map((event) => (
|
||||||
))}
|
<DayEventPreview key={event.id} event={event} />
|
||||||
|
))}
|
||||||
|
|
||||||
{hasOverflow ? (
|
{day.isCurrentMonth && hasOverflow ? (
|
||||||
<span className="text-xs font-medium text-primary/80">
|
<span className="text-xs font-medium text-primary/80">
|
||||||
+ ver mais
|
+{overflowCount} mais
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ const renderLancamento = (
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Badge variant={"outline"}>{event.transaction.condition}</Badge>
|
|
||||||
<Badge variant={"outline"}>{event.transaction.paymentMethod}</Badge>
|
|
||||||
<Badge variant={"outline"}>{event.transaction.categoriaName}</Badge>
|
<Badge variant={"outline"}>{event.transaction.categoriaName}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,9 +196,6 @@ export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={!day}>
|
<Button onClick={handleCreate} disabled={!day}>
|
||||||
Novo lançamento
|
Novo lançamento
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||||
import type { CategoryHistoryData } from "@/features/dashboard/categories/category-history-queries";
|
import type { CategoryHistoryData } from "@/features/dashboard/categories/category-history-queries";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -170,274 +169,272 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-auto">
|
<div className="space-y-2.5">
|
||||||
<CardContent className="space-y-2.5">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
{selectedCategoryDetails.length > 0 && (
|
||||||
{selectedCategoryDetails.length > 0 && (
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
<div className="flex items-start justify-between gap-4 mb-4">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
{selectedCategoryDetails.map((category) => {
|
||||||
{selectedCategoryDetails.map((category) => {
|
if (!category) return null;
|
||||||
if (!category) return null;
|
const IconComponent = category.icon
|
||||||
const IconComponent = category.icon
|
? getIconComponent(category.icon)
|
||||||
? getIconComponent(category.icon)
|
: null;
|
||||||
: null;
|
const colorIndex = selectedCategories.indexOf(category.id);
|
||||||
const colorIndex = selectedCategories.indexOf(category.id);
|
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
|
||||||
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
|
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||||
style={{ borderColor: color }}
|
style={{ borderColor: color }}
|
||||||
>
|
|
||||||
{IconComponent ? (
|
|
||||||
<span style={{ color }}>
|
|
||||||
<IconComponent className="size-4" />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="size-3 rounded-sm"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="text-foreground">{category.name}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
|
||||||
onClick={() => handleRemoveCategory(category.id)}
|
|
||||||
>
|
|
||||||
<RiCloseLine className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0 pt-1.5">
|
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
{selectedCategories.length}/5 selecionadas
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClearAll}
|
|
||||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Limpar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedCategories.length < 5 && availableCategories.length > 0 && (
|
|
||||||
<Popover open={open} onOpenChange={setOpen} modal>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="w-full justify-between hover:scale-none"
|
|
||||||
>
|
|
||||||
Selecionar categorias
|
|
||||||
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-(--radix-popover-trigger-width) p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Pesquisar categoria..." />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
|
||||||
|
|
||||||
{despesaCategories.length > 0 && (
|
|
||||||
<CommandGroup heading="Despesas">
|
|
||||||
{despesaCategories.map((category) => {
|
|
||||||
const IconComponent = category.icon
|
|
||||||
? getIconComponent(category.icon)
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={category.id}
|
|
||||||
value={category.name}
|
|
||||||
onSelect={() => handleAddCategory(category.id)}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{IconComponent ? (
|
|
||||||
<IconComponent className="size-4 text-destructive" />
|
|
||||||
) : (
|
|
||||||
<div className="size-3 rounded-sm bg-destructive" />
|
|
||||||
)}
|
|
||||||
<span>{category.name}</span>
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{receitaCategories.length > 0 && (
|
|
||||||
<CommandGroup heading="Receitas">
|
|
||||||
{receitaCategories.map((category) => {
|
|
||||||
const IconComponent = category.icon
|
|
||||||
? getIconComponent(category.icon)
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={category.id}
|
|
||||||
value={category.name}
|
|
||||||
onSelect={() => handleAddCategory(category.id)}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{IconComponent ? (
|
|
||||||
<IconComponent className="size-4 text-success" />
|
|
||||||
) : (
|
|
||||||
<div className="size-3 rounded-sm bg-success" />
|
|
||||||
)}
|
|
||||||
<span>{category.name}</span>
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEmpty ? (
|
|
||||||
<div className="h-[450px] flex items-center justify-center">
|
|
||||||
<WidgetEmptyState
|
|
||||||
icon={
|
|
||||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
|
||||||
}
|
|
||||||
title="Selecione categorias para visualizar"
|
|
||||||
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ChartContainer config={chartConfig} className="h-[450px] w-full">
|
|
||||||
<AreaChart
|
|
||||||
data={filteredChartData}
|
|
||||||
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
{filteredCategories.map((category) => (
|
|
||||||
<linearGradient
|
|
||||||
key={`gradient-${category.id}`}
|
|
||||||
id={`gradient-${category.id}`}
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
>
|
||||||
<stop
|
{IconComponent ? (
|
||||||
offset="5%"
|
<span style={{ color }}>
|
||||||
stopColor={category.color}
|
<IconComponent className="size-4" />
|
||||||
stopOpacity={0.4}
|
</span>
|
||||||
/>
|
) : (
|
||||||
<stop
|
<div
|
||||||
offset="95%"
|
className="size-3 rounded-sm"
|
||||||
stopColor={category.color}
|
style={{ backgroundColor: color }}
|
||||||
stopOpacity={0.05}
|
/>
|
||||||
/>
|
)}
|
||||||
</linearGradient>
|
<span className="text-foreground">{category.name}</span>
|
||||||
))}
|
<Button
|
||||||
</defs>
|
variant="ghost"
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
size="sm"
|
||||||
<XAxis
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||||
dataKey="month"
|
onClick={() => handleRemoveCategory(category.id)}
|
||||||
tickLine={false}
|
>
|
||||||
axisLine={false}
|
<RiCloseLine className="size-3" />
|
||||||
tickMargin={8}
|
</Button>
|
||||||
className="text-xs"
|
</div>
|
||||||
/>
|
);
|
||||||
<YAxis
|
})}
|
||||||
tickLine={false}
|
</div>
|
||||||
axisLine={false}
|
<div className="flex items-center gap-2 shrink-0 pt-1.5">
|
||||||
tickMargin={8}
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
className="text-xs"
|
{selectedCategories.length}/5 selecionadas
|
||||||
tickFormatter={(value) => formatCurrencyCompact(Number(value))}
|
</span>
|
||||||
/>
|
<Button
|
||||||
<ChartTooltip
|
variant="ghost"
|
||||||
content={({ active, payload }) => {
|
size="sm"
|
||||||
if (!active || !payload || payload.length === 0) {
|
onClick={handleClearAll}
|
||||||
return null;
|
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
}
|
>
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
// Sort payload by value (descending)
|
{selectedCategories.length < 5 && availableCategories.length > 0 && (
|
||||||
const sortedPayload = [...payload].sort(
|
<Popover open={open} onOpenChange={setOpen} modal>
|
||||||
(a, b) => (b.value as number) - (a.value as number),
|
<PopoverTrigger asChild>
|
||||||
);
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full justify-between hover:scale-none"
|
||||||
|
>
|
||||||
|
Selecionar categorias
|
||||||
|
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-(--radix-popover-trigger-width) p-0"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Pesquisar categoria..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
||||||
|
|
||||||
return (
|
{despesaCategories.length > 0 && (
|
||||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
<CommandGroup heading="Despesas">
|
||||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
{despesaCategories.map((category) => {
|
||||||
{payload[0].payload.month}
|
const IconComponent = category.icon
|
||||||
</div>
|
? getIconComponent(category.icon)
|
||||||
<div className="grid gap-1.5">
|
: null;
|
||||||
{sortedPayload
|
return (
|
||||||
.filter((entry) => (entry.value as number) > 0)
|
<CommandItem
|
||||||
.map((entry) => {
|
key={category.id}
|
||||||
const config =
|
value={category.name}
|
||||||
chartConfig[
|
onSelect={() => handleAddCategory(category.id)}
|
||||||
entry.dataKey as keyof typeof chartConfig
|
className="gap-2"
|
||||||
];
|
>
|
||||||
const value = entry.value as number;
|
{IconComponent ? (
|
||||||
|
<IconComponent className="size-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<div className="size-3 rounded-sm bg-destructive" />
|
||||||
|
)}
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{receitaCategories.length > 0 && (
|
||||||
<div
|
<CommandGroup heading="Receitas">
|
||||||
key={entry.dataKey}
|
{receitaCategories.map((category) => {
|
||||||
className="flex items-center justify-between gap-4"
|
const IconComponent = category.icon
|
||||||
>
|
? getIconComponent(category.icon)
|
||||||
<div className="flex items-center gap-2">
|
: null;
|
||||||
<div
|
return (
|
||||||
className="h-2.5 w-2.5 rounded-sm shrink-0"
|
<CommandItem
|
||||||
style={{ backgroundColor: config?.color }}
|
key={category.id}
|
||||||
/>
|
value={category.name}
|
||||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
onSelect={() => handleAddCategory(category.id)}
|
||||||
{config?.label}
|
className="gap-2"
|
||||||
</span>
|
>
|
||||||
</div>
|
{IconComponent ? (
|
||||||
<span className="text-xs font-medium tabular-nums">
|
<IconComponent className="size-4 text-success" />
|
||||||
{formatCurrency(value)}
|
) : (
|
||||||
|
<div className="size-3 rounded-sm bg-success" />
|
||||||
|
)}
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="h-[450px] flex items-center justify-center">
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={
|
||||||
|
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
title="Selecione categorias para visualizar"
|
||||||
|
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={chartConfig} className="h-[450px] w-full">
|
||||||
|
<AreaChart
|
||||||
|
data={filteredChartData}
|
||||||
|
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{filteredCategories.map((category) => (
|
||||||
|
<linearGradient
|
||||||
|
key={`gradient-${category.id}`}
|
||||||
|
id={`gradient-${category.id}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor={category.color}
|
||||||
|
stopOpacity={0.4}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor={category.color}
|
||||||
|
stopOpacity={0.05}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
<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={(value) => formatCurrencyCompact(Number(value))}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (!active || !payload || payload.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort payload by value (descending)
|
||||||
|
const sortedPayload = [...payload].sort(
|
||||||
|
(a, b) => (b.value as number) - (a.value as number),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||||
|
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
{payload[0].payload.month}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{sortedPayload
|
||||||
|
.filter((entry) => (entry.value as number) > 0)
|
||||||
|
.map((entry) => {
|
||||||
|
const config =
|
||||||
|
chartConfig[
|
||||||
|
entry.dataKey as keyof typeof chartConfig
|
||||||
|
];
|
||||||
|
const value = entry.value as number;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.dataKey}
|
||||||
|
className="flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rounded-sm shrink-0"
|
||||||
|
style={{ backgroundColor: config?.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||||
|
{config?.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
<span className="text-xs font-medium tabular-nums">
|
||||||
})}
|
{formatCurrency(value)}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}}
|
);
|
||||||
cursor={{
|
}}
|
||||||
stroke: "hsl(var(--muted-foreground))",
|
cursor={{
|
||||||
strokeWidth: 1,
|
stroke: "hsl(var(--muted-foreground))",
|
||||||
|
strokeWidth: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{filteredCategories.map((category) => (
|
||||||
|
<Area
|
||||||
|
key={category.id}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={category.name}
|
||||||
|
stroke={category.color}
|
||||||
|
strokeWidth={1}
|
||||||
|
fill={`url(#gradient-${category.id})`}
|
||||||
|
fillOpacity={1}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{
|
||||||
|
r: 5,
|
||||||
|
fill: category.color,
|
||||||
|
stroke: "hsl(var(--background))",
|
||||||
|
strokeWidth: 2,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{filteredCategories.map((category) => (
|
))}
|
||||||
<Area
|
</AreaChart>
|
||||||
key={category.id}
|
</ChartContainer>
|
||||||
type="monotone"
|
)}
|
||||||
dataKey={category.name}
|
</div>
|
||||||
stroke={category.color}
|
|
||||||
strokeWidth={1}
|
|
||||||
fill={`url(#gradient-${category.id})`}
|
|
||||||
fillOpacity={1}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{
|
|
||||||
r: 5,
|
|
||||||
fill: category.color,
|
|
||||||
stroke: "hsl(var(--background))",
|
|
||||||
strokeWidth: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ export function GoalProgressItem({
|
|||||||
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
|
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
|
||||||
const percentageDelta = item.usedPercentage - 100;
|
const percentageDelta = item.usedPercentage - 100;
|
||||||
|
|
||||||
|
const isExceeded = item.status === "exceeded";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="transition-all duration-300 py-2">
|
<div className="group transition-all duration-300 py-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||||
<CategoryIconBadge
|
<CategoryIconBadge
|
||||||
@@ -54,7 +56,7 @@ export function GoalProgressItem({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="opacity-30 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => onEdit(item)}
|
onClick={() => onEdit(item)}
|
||||||
aria-label={`Editar orçamento de ${item.categoryName}`}
|
aria-label={`Editar orçamento de ${item.categoryName}`}
|
||||||
>
|
>
|
||||||
@@ -63,7 +65,14 @@ export function GoalProgressItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-11 mt-1.5">
|
<div className="ml-11 mt-1.5">
|
||||||
<Progress value={progressValue} />
|
<Progress
|
||||||
|
value={progressValue}
|
||||||
|
className={
|
||||||
|
isExceeded
|
||||||
|
? "[&_[data-slot=progress-indicator]]:bg-destructive bg-destructive/20"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/shared/components/ui/tooltip";
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||||
|
|
||||||
type InstallmentExpenseListItemProps = {
|
type InstallmentExpenseListItemProps = {
|
||||||
expense: InstallmentExpense;
|
expense: InstallmentExpense;
|
||||||
@@ -27,6 +28,10 @@ export function InstallmentExpenseListItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 transition-all duration-300 py-2">
|
<div className="flex items-center gap-3 transition-all duration-300 py-2">
|
||||||
|
<div className="flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground">
|
||||||
|
{getPaymentMethodIcon(expense.paymentMethod)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
@@ -61,7 +66,7 @@ export function InstallmentExpenseListItem({
|
|||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{endDate ? `Termina em ${endDate}` : null}
|
{endDate ? `Termina em ${endDate}` : null}
|
||||||
{" | Restante "}
|
{" · Restante "}
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={remainingAmount}
|
amount={remainingAmount}
|
||||||
className="inline-block font-medium"
|
className="inline-block font-medium"
|
||||||
|
|||||||
@@ -15,32 +15,18 @@ import {
|
|||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from "@/shared/components/ui/avatar";
|
} from "@/shared/components/ui/avatar";
|
||||||
import { CardContent } from "@/shared/components/ui/card";
|
|
||||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||||
|
import { buildInitials } from "@/shared/utils/initials";
|
||||||
import { formatPercentage } from "@/shared/utils/percentage";
|
import { formatPercentage } from "@/shared/utils/percentage";
|
||||||
|
|
||||||
type PayersWidgetProps = {
|
type PayersWidgetProps = {
|
||||||
payers: DashboardPagador[];
|
payers: DashboardPagador[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildInitials = (value: string) => {
|
|
||||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return "??";
|
|
||||||
}
|
|
||||||
if (parts.length === 1) {
|
|
||||||
const firstPart = parts[0];
|
|
||||||
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "??";
|
|
||||||
}
|
|
||||||
const firstChar = parts[0]?.[0] ?? "";
|
|
||||||
const secondChar = parts[1]?.[0] ?? "";
|
|
||||||
return `${firstChar}${secondChar}`.toUpperCase() || "??";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PayersWidget({ payers }: PayersWidgetProps) {
|
export function PayersWidget({ payers }: PayersWidgetProps) {
|
||||||
return (
|
return (
|
||||||
<CardContent className="flex flex-col gap-4 px-0">
|
<div className="flex flex-col">
|
||||||
{payers.length === 0 ? (
|
{payers.length === 0 ? (
|
||||||
<WidgetEmptyState
|
<WidgetEmptyState
|
||||||
icon={<RiGroupLine className="size-6 text-muted-foreground" />}
|
icon={<RiGroupLine className="size-6 text-muted-foreground" />}
|
||||||
@@ -123,6 +109,6 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,7 @@ import { CardContent } from "@/shared/components/ui/card";
|
|||||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
import type { PayerCardUsageItem } from "@/shared/lib/payers/details";
|
import type { PayerCardUsageItem } from "@/shared/lib/payers/details";
|
||||||
|
import { buildInitials } from "@/shared/utils/initials";
|
||||||
const buildInitials = (value: string) => {
|
|
||||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length === 0) return "CC";
|
|
||||||
if (parts.length === 1) {
|
|
||||||
const firstPart = parts[0];
|
|
||||||
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
|
|
||||||
}
|
|
||||||
const firstChar = parts[0]?.[0] ?? "";
|
|
||||||
const secondChar = parts[1]?.[0] ?? "";
|
|
||||||
return `${firstChar}${secondChar}`.toUpperCase() || "CC";
|
|
||||||
};
|
|
||||||
|
|
||||||
type PagadorCardUsageCardProps = {
|
type PagadorCardUsageCardProps = {
|
||||||
items: PayerCardUsageItem[];
|
items: PayerCardUsageItem[];
|
||||||
|
|||||||
@@ -12,23 +12,12 @@ import {
|
|||||||
} from "@/shared/components/ui/card";
|
} from "@/shared/components/ui/card";
|
||||||
import { Progress } from "@/shared/components/ui/progress";
|
import { Progress } from "@/shared/components/ui/progress";
|
||||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||||
|
import { buildInitials } from "@/shared/utils/initials";
|
||||||
|
|
||||||
type EstablishmentsListProps = {
|
type EstablishmentsListProps = {
|
||||||
establishments: TopEstabelecimentosData["establishments"];
|
establishments: TopEstabelecimentosData["establishments"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildInitials = (value: string) => {
|
|
||||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length === 0) return "ES";
|
|
||||||
if (parts.length === 1) {
|
|
||||||
const firstPart = parts[0];
|
|
||||||
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "ES";
|
|
||||||
}
|
|
||||||
const firstChar = parts[0]?.[0] ?? "";
|
|
||||||
const secondChar = parts[1]?.[0] ?? "";
|
|
||||||
return `${firstChar}${secondChar}`.toUpperCase() || "ES";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EstablishmentsList({
|
export function EstablishmentsList({
|
||||||
establishments,
|
establishments,
|
||||||
}: EstablishmentsListProps) {
|
}: EstablishmentsListProps) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type WidgetCardProps = {
|
|||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
icon: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,11 +37,11 @@ export default function WidgetCard({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex w-full items-start justify-between">
|
<div className="flex w-full items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-1 tracking-tight lowercase">
|
<CardTitle className="flex items-center gap-1 tracking-tight">
|
||||||
<span className="size-4">{icon}</span>
|
{icon && <span className="size-4">{icon}</span>}
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-muted-foreground text-sm lowercase mt-1.5 tracking-tight">
|
<CardDescription className="text-muted-foreground text-sm mt-1.5 tracking-tight">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
15
src/shared/utils/initials.ts
Normal file
15
src/shared/utils/initials.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Builds a 2-character initials string from a name.
|
||||||
|
* Falls back to the provided `fallback` (default "??") when the name is empty.
|
||||||
|
*/
|
||||||
|
export function buildInitials(value: string, fallback = "??"): string {
|
||||||
|
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return fallback;
|
||||||
|
if (parts.length === 1) {
|
||||||
|
const firstPart = parts[0];
|
||||||
|
return firstPart ? firstPart.slice(0, 2).toUpperCase() : fallback;
|
||||||
|
}
|
||||||
|
const firstChar = parts[0]?.[0] ?? "";
|
||||||
|
const secondChar = parts[1]?.[0] ?? "";
|
||||||
|
return `${firstChar}${secondChar}`.toUpperCase() || fallback;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user