feat(dashboard): refina experiencia dos widgets

This commit is contained in:
Felipe Coutinho
2026-05-31 15:18:43 -03:00
parent 402f0072af
commit 35abe1b0bf
39 changed files with 887 additions and 592 deletions

View File

@@ -27,6 +27,10 @@ export default async function Page({ searchParams }: PageProps) {
const { dashboardData, preferences, quickActionOptions } = const { dashboardData, preferences, quickActionOptions } =
await fetchDashboardPageData(user.id, selectedPeriod); await fetchDashboardPageData(user.id, selectedPeriod);
const { dashboardWidgets } = preferences; const { dashboardWidgets } = preferences;
const adminPayerSlug =
quickActionOptions.payerOptions.find(
(option) => option.value === quickActionOptions.defaultPayerId,
)?.slug ?? null;
const logoMappings = await prefetchLogoMappings( const logoMappings = await prefetchLogoMappings(
user.id, user.id,
@@ -37,7 +41,11 @@ export default async function Page({ searchParams }: PageProps) {
<main className="flex flex-col gap-4"> <main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} /> <DashboardWelcome name={user.name} />
<MonthNavigation /> <MonthNavigation />
<DashboardMetricsCards metrics={dashboardData.metrics} /> <DashboardMetricsCards
metrics={dashboardData.metrics}
period={selectedPeriod}
adminPayerSlug={adminPayerSlug}
/>
<LogoPrefetchProvider mappings={logoMappings}> <LogoPrefetchProvider mappings={logoMappings}>
<DashboardGridEditable <DashboardGridEditable
data={dashboardData} data={dashboardData}

View File

@@ -6,6 +6,7 @@ export type DashboardBill = {
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean; isSettled: boolean;
accountId: string | null; accountId: string | null;
transactionType: string;
}; };
export type BillPaymentAccountOption = { export type BillPaymentAccountOption = {

View File

@@ -96,7 +96,7 @@ export function CategoryBreakdownChart({
}, [categories, chartConfig]); }, [categories, chartConfig]);
return ( return (
<div className="flex items-center gap-4"> <div className="flex flex-col items-center gap-4 sm:flex-row">
<ChartContainer config={chartConfig} className="h-[280px] flex-1"> <ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart> <PieChart>
<Pie <Pie
@@ -143,7 +143,7 @@ export function CategoryBreakdownChart({
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
<div className="min-w-[140px] flex flex-col gap-2"> <div className="grid w-full grid-cols-2 gap-2 sm:min-w-[140px] sm:w-auto sm:grid-cols-1">
{chartData.map((entry, index) => ( {chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2"> <div key={`legend-${index}`} className="flex items-center gap-2">
<div <div

View File

@@ -1,4 +1,3 @@
import { RiExternalLinkLine } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers"; import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
@@ -11,13 +10,14 @@ type CategoryBreakdownListItemConfig = {
shareLabel: string; shareLabel: string;
percentageDigits: number; percentageDigits: number;
positiveTrend: "up" | "down"; positiveTrend: "up" | "down";
includeBudgetAmount: boolean; showBudget: boolean;
}; };
type CategoryBreakdownListItemProps = { type CategoryBreakdownListItemProps = {
category: DashboardCategoryBreakdownItem; category: DashboardCategoryBreakdownItem;
periodParam: string; periodParam: string;
config: CategoryBreakdownListItemConfig; config: CategoryBreakdownListItemConfig;
position: number;
}; };
const formatPercentage = (value: number, digits: number) => const formatPercentage = (value: number, digits: number) =>
@@ -31,8 +31,9 @@ export function CategoryBreakdownListItem({
category, category,
periodParam, periodParam,
config, config,
position,
}: CategoryBreakdownListItemProps) { }: CategoryBreakdownListItemProps) {
const hasBudget = category.budgetAmount !== null; const hasBudget = config.showBudget && category.budgetAmount !== null;
const budgetExceeded = const budgetExceeded =
hasBudget && hasBudget &&
category.budgetUsedPercentage !== null && category.budgetUsedPercentage !== null &&
@@ -44,7 +45,10 @@ export function CategoryBreakdownListItem({
return ( return (
<div> <div>
<div className="flex items-center justify-between gap-3 transition-all duration-300 py-2"> <div className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5">
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{position}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge <CategoryIconBadge
icon={category.categoryIcon} icon={category.categoryIcon}
@@ -54,13 +58,9 @@ export function CategoryBreakdownListItem({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`} href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline" className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
> >
<span className="truncate">{category.categoryName}</span> <span className="truncate">{category.categoryName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
</div> </div>
<div className="flex flex-wrap items-center gap-x-1 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
@@ -71,15 +71,14 @@ export function CategoryBreakdownListItem({
)}{" "} )}{" "}
da {config.shareLabel} da {config.shareLabel}
</span> </span>
</div>
{hasBudget && category.budgetUsedPercentage !== null ? ( {hasBudget && category.budgetUsedPercentage !== null ? (
<> <div
<span aria-hidden>·</span> className={`mt-0.5 text-xs ${budgetExceeded ? "text-destructive" : "text-info"}`}
<span
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
> >
{budgetExceeded ? ( {budgetExceeded ? (
<> <>
Excedeu{" "} Limite excedido em{" "}
<span className="font-medium"> <span className="font-medium">
{formatCurrency(exceededAmount)} {formatCurrency(exceededAmount)}
</span> </span>
@@ -90,17 +89,11 @@ export function CategoryBreakdownListItem({
category.budgetUsedPercentage, category.budgetUsedPercentage,
config.percentageDigits, config.percentageDigits,
)}{" "} )}{" "}
do limite do limite utilizado
{config.includeBudgetAmount &&
category.budgetAmount !== null
? ` ${formatCurrency(category.budgetAmount)}`
: ""}
</> </>
)} )}
</span>
</>
) : null}
</div> </div>
) : null}
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ type CategoryBreakdownListConfig = {
shareLabel: string; shareLabel: string;
percentageDigits: number; percentageDigits: number;
positiveTrend: "up" | "down"; positiveTrend: "up" | "down";
includeBudgetAmount: boolean; showBudget: boolean;
}; };
type CategoryBreakdownListProps = { type CategoryBreakdownListProps = {
@@ -20,13 +20,14 @@ export function CategoryBreakdownList({
config, config,
}: CategoryBreakdownListProps) { }: CategoryBreakdownListProps) {
return ( return (
<div> <div className="flex flex-col">
{categories.map((category) => ( {categories.map((category, index) => (
<CategoryBreakdownListItem <CategoryBreakdownListItem
key={category.categoryId} key={category.categoryId}
category={category} category={category}
periodParam={periodParam} periodParam={periodParam}
config={config} config={config}
position={index + 1}
/> />
))} ))}
</div> </div>

View File

@@ -34,7 +34,7 @@ const VARIANT_CONFIG = {
shareLabel: "receita total", shareLabel: "receita total",
percentageDigits: 1, percentageDigits: 1,
positiveTrend: "up", positiveTrend: "up",
includeBudgetAmount: true, showBudget: false,
}, },
expense: { expense: {
emptyTitle: "Nenhuma despesa encontrada", emptyTitle: "Nenhuma despesa encontrada",
@@ -43,7 +43,7 @@ const VARIANT_CONFIG = {
shareLabel: "despesa total", shareLabel: "despesa total",
percentageDigits: 0, percentageDigits: 0,
positiveTrend: "down", positiveTrend: "down",
includeBudgetAmount: false, showBudget: true,
}, },
} as const; } as const;

View File

@@ -21,6 +21,7 @@ import {
RiCloseLine, RiCloseLine,
RiDragMove2Line, RiDragMove2Line,
RiEyeOffLine, RiEyeOffLine,
RiSettings4Line,
RiTodoLine, RiTodoLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
@@ -41,6 +42,12 @@ import {
import { NoteDialog } from "@/features/notes/components/note-dialog"; import { NoteDialog } from "@/features/notes/components/note-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog"; import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { ExpandableWidgetCard } from "@/shared/components/widgets/expandable-widget-card"; import { ExpandableWidgetCard } from "@/shared/components/widgets/expandable-widget-card";
type DashboardGridEditableProps = { type DashboardGridEditableProps = {
@@ -60,6 +67,9 @@ export function DashboardGridEditable({
}: DashboardGridEditableProps) { }: DashboardGridEditableProps) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isMobileIncomeOpen, setIsMobileIncomeOpen] = useState(false);
const [isMobileExpenseOpen, setIsMobileExpenseOpen] = useState(false);
const [isMobileNoteOpen, setIsMobileNoteOpen] = useState(false);
// Initialize widget order and hidden state // Initialize widget order and hidden state
const [widgetOrder, setWidgetOrder] = useState<string[]>( const [widgetOrder, setWidgetOrder] = useState<string[]>(
@@ -132,14 +142,6 @@ export function DashboardGridEditable({
: [...hiddenWidgets, widgetId]; : [...hiddenWidgets, widgetId];
setHiddenWidgets(newHidden); setHiddenWidgets(newHidden);
// Salvar automaticamente ao toggle
startTransition(async () => {
await updateWidgetPreferences({
order: widgetOrder,
hidden: newHidden,
});
});
}; };
const handleHideWidget = (widgetId: string) => { const handleHideWidget = (widgetId: string) => {
@@ -182,6 +184,8 @@ export function DashboardGridEditable({
setWidgetOrder(DEFAULT_WIDGET_ORDER); setWidgetOrder(DEFAULT_WIDGET_ORDER);
setHiddenWidgets([]); setHiddenWidgets([]);
setMyAccountsShowExcluded(true); setMyAccountsShowExcluded(true);
setOriginalOrder(DEFAULT_WIDGET_ORDER);
setOriginalHidden([]);
toast.success("Preferências restauradas!"); toast.success("Preferências restauradas!");
} else { } else {
toast.error(result.error ?? "Erro ao restaurar"); toast.error(result.error ?? "Erro ao restaurar");
@@ -195,7 +199,68 @@ export function DashboardGridEditable({
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
{!isEditing ? ( {!isEditing ? (
<div className="flex w-full min-w-0 flex-col gap-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2"> <div className="flex w-full min-w-0 flex-col gap-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2">
<div className="-mb-1 grid w-full grid-cols-3 gap-1 pb-1 sm:mb-0 sm:flex sm:w-auto sm:items-center sm:gap-2 sm:overflow-visible sm:pb-0"> <div className="sm:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="w-full gap-2">
<RiAddFill className="size-4 text-primary" />
Adicionar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem
onSelect={() => setIsMobileIncomeOpen(true)}
>
<RiAddFill className="text-success/80" />
Nova receita
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setIsMobileExpenseOpen(true)}
>
<RiAddFill className="text-destructive/80" />
Nova despesa
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsMobileNoteOpen(true)}>
<RiTodoLine className="text-info/80" />
Nova anotação
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TransactionDialog
mode="create"
open={isMobileIncomeOpen}
onOpenChange={setIsMobileIncomeOpen}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Receita"
/>
<TransactionDialog
mode="create"
open={isMobileExpenseOpen}
onOpenChange={setIsMobileExpenseOpen}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Despesa"
/>
<NoteDialog
mode="create"
open={isMobileNoteOpen}
onOpenChange={setIsMobileNoteOpen}
/>
</div>
<div className="hidden items-center gap-2 sm:flex">
<TransactionDialog <TransactionDialog
mode="create" mode="create"
payerOptions={quickActionOptions.payerOptions} payerOptions={quickActionOptions.payerOptions}
@@ -269,6 +334,12 @@ export function DashboardGridEditable({
<div className="flex w-full items-center justify-end gap-2 sm:w-auto"> <div className="flex w-full items-center justify-end gap-2 sm:w-auto">
{isEditing ? ( {isEditing ? (
<> <>
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
triggerLabel="Visibilidade"
/>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -290,21 +361,15 @@ export function DashboardGridEditable({
</Button> </Button>
</> </>
) : ( ) : (
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto"> <div className="w-full sm:w-auto">
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
triggerClassName="w-full sm:w-auto"
/>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleStartEditing} onClick={handleStartEditing}
className="w-full gap-2 sm:w-auto" className="w-full gap-2 sm:w-auto"
> >
<RiDragMove2Line className="size-4" /> <RiSettings4Line className="size-4" />
Reordenar Personalizar
</Button> </Button>
</div> </div>
)} )}

View File

@@ -1,9 +1,11 @@
import { import {
RiArrowLeftRightLine, RiArrowLeftRightLine,
RiArrowRightDownLine, RiArrowRightDownLine,
RiArrowRightLine,
RiArrowRightUpLine, RiArrowRightUpLine,
RiCalendar2Line, RiCalendar2Line,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link";
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button"; import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries"; import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
@@ -18,10 +20,13 @@ import {
} from "@/shared/components/ui/card"; } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator"; import { Separator } from "@/shared/components/ui/separator";
import { formatPercentage } from "@/shared/utils/percentage"; import { formatPercentage } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
type DashboardMetricsCardsProps = { type DashboardMetricsCardsProps = {
metrics: DashboardCardMetrics; metrics: DashboardCardMetrics;
period: string;
adminPayerSlug: string | null;
}; };
type Trend = "up" | "down" | "flat"; type Trend = "up" | "down" | "flat";
@@ -36,6 +41,7 @@ const CARDS = [
icon: RiArrowRightDownLine, icon: RiArrowRightDownLine,
invertTrend: false, invertTrend: false,
iconClass: "text-success", iconClass: "text-success",
transactionType: "receita",
helpTitle: "Como calculamos receitas", helpTitle: "Como calculamos receitas",
helpLines: [ helpLines: [
"Somamos os lançamentos do tipo Receita no período selecionado.", "Somamos os lançamentos do tipo Receita no período selecionado.",
@@ -53,6 +59,7 @@ const CARDS = [
icon: RiArrowRightUpLine, icon: RiArrowRightUpLine,
invertTrend: true, invertTrend: true,
iconClass: "text-destructive", iconClass: "text-destructive",
transactionType: "despesa",
helpTitle: "Como calculamos despesas", helpTitle: "Como calculamos despesas",
helpLines: [ helpLines: [
"Somamos os lançamentos do tipo Despesa no período selecionado.", "Somamos os lançamentos do tipo Despesa no período selecionado.",
@@ -70,6 +77,7 @@ const CARDS = [
icon: RiArrowLeftRightLine, icon: RiArrowLeftRightLine,
invertTrend: false, invertTrend: false,
iconClass: "text-warning", iconClass: "text-warning",
transactionType: null,
helpTitle: "Como calculamos o balanço", helpTitle: "Como calculamos o balanço",
helpLines: [ helpLines: [
"Partimos de receitas menos despesas do período.", "Partimos de receitas menos despesas do período.",
@@ -86,6 +94,7 @@ const CARDS = [
icon: RiCalendar2Line, icon: RiCalendar2Line,
invertTrend: false, invertTrend: false,
iconClass: "text-cyan-600", iconClass: "text-cyan-600",
transactionType: null,
helpTitle: "Como calculamos o previsto", helpTitle: "Como calculamos o previsto",
helpLines: [ helpLines: [
"Acumulamos o balanço mês a mês até o período atual.", "Acumulamos o balanço mês a mês até o período atual.",
@@ -123,7 +132,11 @@ const getPercentChange = (current: number, previous: number): string | null => {
}); });
}; };
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) { export function DashboardMetricsCards({
metrics,
period,
adminPayerSlug,
}: DashboardMetricsCardsProps) {
return ( return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"> <div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map( {CARDS.map(
@@ -134,6 +147,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
icon: Icon, icon: Icon,
invertTrend, invertTrend,
iconClass, iconClass,
transactionType,
helpTitle, helpTitle,
helpLines, helpLines,
}) => { }) => {
@@ -143,10 +157,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
metric.current, metric.current,
metric.previous, metric.previous,
); );
const transactionsHref = transactionType
? `/transactions?periodo=${formatPeriodForUrl(period)}&type=${transactionType}${adminPayerSlug ? `&payer=${adminPayerSlug}` : ""}`
: null;
return ( return (
<Card key={label} className="gap-2 overflow-hidden"> <Card key={label} className="gap-2 overflow-hidden py-6">
<CardHeader className="gap-1"> <CardHeader className="gap-1">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-1"> <CardTitle className="flex items-center gap-1">
<Icon className={cn("size-4", iconClass)} aria-hidden /> <Icon className={cn("size-4", iconClass)} aria-hidden />
{label} {label}
@@ -156,6 +174,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
helpLines={helpLines} helpLines={helpLines}
/> />
</CardTitle> </CardTitle>
{transactionsHref ? (
<Link
href={transactionsHref}
className="rounded-sm px-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px]"
aria-label={`Ver lançamentos de ${label.toLowerCase()}`}
>
<RiArrowRightLine className="size-4" aria-hidden />
</Link>
) : null}
</div>
<CardDescription className="mt-1 tracking-tight"> <CardDescription className="mt-1 tracking-tight">
{subtitle} {subtitle}
</CardDescription> </CardDescription>

View File

@@ -1,5 +1,5 @@
import { RiPencilLine } from "@remixicon/react"; import { RiPencilLine } from "@remixicon/react";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import Link from "next/link";
import { import {
clampGoalProgress, clampGoalProgress,
formatGoalProgressPercentage, formatGoalProgressPercentage,
@@ -9,24 +9,28 @@ import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Progress } from "@/shared/components/ui/progress"; import { Progress } from "@/shared/components/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils";
import { formatPeriodForUrl } from "@/shared/utils/period";
type GoalProgressItemProps = { type GoalProgressItemProps = {
item: GoalProgressItemData; item: GoalProgressItemData;
index: number;
onEdit: (item: GoalProgressItemData) => void; onEdit: (item: GoalProgressItemData) => void;
}; };
export function GoalProgressItem({ export function GoalProgressItem({ item, onEdit }: GoalProgressItemProps) {
item,
index,
onEdit,
}: GoalProgressItemProps) {
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100); const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
const percentageDelta = item.usedPercentage - 100;
const isExceeded = item.status === "exceeded"; const isExceeded = item.status === "exceeded";
const isCritical = item.status === "critical";
const exceededAmount = Math.max(item.spentAmount - item.budgetAmount, 0);
const usedPercentageLabel = formatGoalProgressPercentage(item.usedPercentage);
return ( return (
<div className="group transition-all duration-300 py-2"> <li className="group py-2 transition-all duration-300">
<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
@@ -35,46 +39,72 @@ export function GoalProgressItem({
size="md" size="md"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{item.categoryId ? (
<Link
href={`/categories/${item.categoryId}?periodo=${formatPeriodForUrl(item.period)}`}
className="block truncate text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
{item.categoryName}
</Link>
) : (
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{item.categoryName} {item.categoryName}
</p> </p>
)}
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "} <MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
de{" "} de{" "}
<MoneyValues className="font-medium" amount={item.budgetAmount} /> <MoneyValues className="font-medium" amount={item.budgetAmount} />
<PercentageChangeIndicator <span aria-hidden> · </span>
value={percentageDelta} <span
label={formatGoalProgressPercentage(percentageDelta, true)} className={cn(
positiveTrend="down" "font-medium",
className="ml-1.5 align-middle" isExceeded && "text-destructive",
/> isCritical && "text-warning",
)}
>
{isExceeded ? (
<>
<MoneyValues amount={exceededAmount} /> acima do limite
</>
) : (
`${usedPercentageLabel} utilizado`
)}
</span>
</p> </p>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <Tooltip>
<TooltipTrigger asChild>
<Button <Button
type="button" type="button"
variant="link" variant="ghost"
size="icon-sm" size="icon-sm"
className="transition-opacity text-primary hover:opacity-80" className="shrink-0 text-primary/70 opacity-70 transition-all hover:text-primary hover:opacity-100 focus-visible:text-primary focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onEdit(item)} onClick={() => onEdit(item)}
aria-label={`Atualizar orçamento de ${item.categoryName}`} aria-label={`Atualizar orçamento de ${item.categoryName}`}
> >
<RiPencilLine className="size-3.5" /> <RiPencilLine className="size-3.5" />
</Button> </Button>
</div> </TooltipTrigger>
<TooltipContent side="top">Atualizar orçamento</TooltipContent>
</Tooltip>
</div> </div>
<div className="ml-11 mt-1.5"> <div className="ml-11 mt-1.5">
<Progress <Progress
value={progressValue} value={progressValue}
className={ className={cn(
isExceeded isExceeded && "bg-destructive/20",
? "**:data-[slot=progress-indicator]:bg-destructive bg-destructive/20" isCritical && "bg-warning/20",
: undefined )}
} indicatorClassName={cn(
isExceeded && "bg-destructive",
isCritical && "bg-warning",
)}
aria-label={`${usedPercentageLabel} do orçamento utilizado em ${item.categoryName}`}
/> />
</div> </div>
</div> </li>
); );
} }

View File

@@ -21,13 +21,8 @@ export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) {
return ( return (
<ul className="flex flex-col"> <ul className="flex flex-col">
{items.map((item, index) => ( {items.map((item) => (
<GoalProgressListItem <GoalProgressListItem key={item.id} item={item} onEdit={onEdit} />
key={item.id}
item={item}
index={index}
onEdit={onEdit}
/>
))} ))}
</ul> </ul>
); );

View File

@@ -9,6 +9,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;
@@ -28,7 +29,7 @@ export function InstallmentExpenseListItem({
} = buildInstallmentExpenseDisplay(expense); } = buildInstallmentExpenseDisplay(expense);
return ( return (
<div className="flex items-center gap-3 transition-all duration-300 py-2"> <div className="flex items-center gap-2 transition-all duration-300 py-1.5">
<EstablishmentLogo name={expense.name} size={37} /> <EstablishmentLogo name={expense.name} size={37} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -66,22 +67,32 @@ export function InstallmentExpenseListItem({
/> />
</div> </div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="inline-flex min-w-0 items-center gap-1">
<span
className="inline-flex shrink-0 [&_svg]:size-3.5"
title={expense.paymentMethod}
>
{getPaymentMethodIcon(expense.paymentMethod)}
<span className="sr-only">{expense.paymentMethod}</span>
</span>
{endDate ? <span className="shrink-0">Até {endDate}</span> : null}
</span>
<span className="shrink-0">
{remainingInstallments === 0 ? ( {remainingInstallments === 0 ? (
<p className="text-xs text-muted-foreground"> "Quitado"
{endDate ? `Termina em ${endDate}` : null}
{" · Quitado"}
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <>
{endDate ? `Termina em ${endDate}` : null} {remainingLabel}:{" "}
{` · ${remainingLabel}: `}
<MoneyValues <MoneyValues
amount={remainingAmount} amount={remainingAmount}
className="inline-block font-semibold" className="inline-block font-semibold"
/>{" "} />{" "}
({remainingInstallments}x) ({remainingInstallments}x)
</p> </>
)} )}
</span>
</div>
<Progress value={progress} className="mt-1 h-2" /> <Progress value={progress} className="mt-1 h-2" />
</div> </div>

View File

@@ -14,17 +14,17 @@ export function InstallmentExpensesList({
return ( return (
<WidgetEmptyState <WidgetEmptyState
icon={<RiNumbersLine className="size-6 text-muted-foreground" />} icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa parcelada" title="Nenhuma despesa parcelada encontrada"
description="Lançamentos parcelados aparecerão aqui conforme forem registrados." description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
/> />
); );
} }
return ( return (
<ul className="flex flex-col"> <div className="flex flex-col">
{expenses.map((expense) => ( {expenses.map((expense) => (
<InstallmentExpenseListItem key={expense.id} expense={expense} /> <InstallmentExpenseListItem key={expense.id} expense={expense} />
))} ))}
</ul> </div>
); );
} }

View File

@@ -1,10 +1,11 @@
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react"; import { RiCheckboxCircleFill, RiGroupLine } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { import {
buildInvoiceDetailsHref, buildInvoiceDetailsHref,
buildInvoiceInitials, buildInvoiceInitials,
formatInvoicePaymentDate, formatInvoicePaymentDate,
formatInvoiceWidgetOverdueLabel,
formatInvoiceWidgetPaymentDate, formatInvoiceWidgetPaymentDate,
getInvoiceShareLabel, getInvoiceShareLabel,
parseInvoiceDueDate, parseInvoiceDueDate,
@@ -48,9 +49,13 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
const absolutePaymentInfo = formatInvoicePaymentDate(invoice.paidAt); const absolutePaymentInfo = formatInvoicePaymentDate(invoice.paidAt);
const breakdown = invoice.pagadorBreakdown ?? []; const breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0; const hasBreakdown = breakdown.length > 0;
const hasMultiplePayers = breakdown.length > 1;
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period); const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
const overdueLabel = formatInvoiceWidgetOverdueLabel(dueInfo.date);
const dueTooltipLabel = const dueTooltipLabel =
dueInfo.label !== absoluteDueInfo.label ? absoluteDueInfo.label : null; overdueLabel || dueInfo.label !== absoluteDueInfo.label
? absoluteDueInfo.label
: null;
const paymentTooltipLabel = const paymentTooltipLabel =
paymentInfo?.label && paymentInfo.label !== absolutePaymentInfo?.label paymentInfo?.label && paymentInfo.label !== absolutePaymentInfo?.label
? absolutePaymentInfo?.label ? absolutePaymentInfo?.label
@@ -63,15 +68,11 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline" className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
> >
<span className="truncate">{invoice.cardName}</span> <span className="truncate">{invoice.cardName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
); );
return ( return (
<div className="flex items-center justify-between transition-all duration-300 py-1.5"> <li className="flex items-center justify-between transition-all duration-300 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2 py-1"> <div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<InvoiceLogo <InvoiceLogo
cardName={invoice.cardName} cardName={invoice.cardName}
@@ -81,6 +82,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
/> />
<div className="min-w-0"> <div className="min-w-0">
<div className="flex max-w-full items-center gap-1">
{hasBreakdown ? ( {hasBreakdown ? (
<HoverCard openDelay={150}> <HoverCard openDelay={150}>
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger> <HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
@@ -133,18 +135,46 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
) : ( ) : (
linkNode linkNode
)} )}
{hasMultiplePayers ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 cursor-help text-muted-foreground">
<RiGroupLine className="size-3.5" aria-hidden />
<span className="sr-only">Ver distribuição por pessoa</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Ver distribuição por pessoa
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{!isPaid ? ( {!isPaid ? (
dueTooltipLabel ? ( dueTooltipLabel ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="cursor-help">{dueInfo.label}</span> <span
className={
isOverdue
? "cursor-help font-semibold text-destructive"
: "cursor-help"
}
>
{overdueLabel ?? dueInfo.label}
</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">{dueTooltipLabel}</TooltipContent> <TooltipContent side="top">{dueTooltipLabel}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<span>{dueInfo.label}</span> <span
className={
isOverdue ? "font-semibold text-destructive" : undefined
}
>
{overdueLabel ?? dueInfo.label}
</span>
) )
) : null} ) : null}
{isPaid && paymentInfo ? ( {isPaid && paymentInfo ? (
@@ -174,19 +204,19 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
className="font-medium" className="font-medium"
amount={Math.abs(invoice.totalAmount)} amount={Math.abs(invoice.totalAmount)}
/> />
{isPaid ? (
<span className="flex h-7 items-center gap-0.5 text-xs font-medium text-success">
<RiCheckboxCircleFill className="size-3.5" /> Pago
</span>
) : (
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="link" variant="link"
className="h-auto p-0 disabled:opacity-100" className="-mr-1.5 h-7 px-1.5 py-0"
disabled={isPaid}
onClick={() => onPay(invoice.id)} onClick={() => onPay(invoice.id)}
> >
{isPaid ? ( {isOverdue ? (
<span className="flex items-center gap-0.5 text-success">
<RiCheckboxCircleFill className="size-3.5" /> Pago
</span>
) : isOverdue ? (
<span className="overdue-blink"> <span className="overdue-blink">
<span className="overdue-blink-primary text-destructive"> <span className="overdue-blink-primary text-destructive">
Atrasado Atrasado
@@ -197,7 +227,8 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
<span>Pagar</span> <span>Pagar</span>
)} )}
</Button> </Button>
)}
</div> </div>
</div> </li>
); );
} }

View File

@@ -39,9 +39,7 @@ export function InvoicesWidgetView({
}: InvoicesWidgetViewProps) { }: InvoicesWidgetViewProps) {
return ( return (
<> <>
<div className="flex flex-col gap-4">
<InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} /> <InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} />
</div>
<InvoicePaymentDialog <InvoicePaymentDialog
invoice={selectedInvoice} invoice={selectedInvoice}

View File

@@ -1,4 +1,8 @@
import { RiFileList2Line, RiPencilLine } from "@remixicon/react"; import {
RiCalendarLine,
RiFileList2Line,
RiPencilLine,
} from "@remixicon/react";
import type { Note } from "@/features/notes/components/types"; import type { Note } from "@/features/notes/components/types";
import { import {
buildNoteDisplayTitle, buildNoteDisplayTitle,
@@ -7,6 +11,11 @@ import {
} from "@/features/notes/lib/formatters"; } from "@/features/notes/lib/formatters";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
type NoteListItemProps = { type NoteListItemProps = {
note: Note; note: Note;
@@ -21,43 +30,59 @@ export function NoteListItem({
}: NoteListItemProps) { }: NoteListItemProps) {
const displayTitle = buildNoteDisplayTitle(note.title); const displayTitle = buildNoteDisplayTitle(note.title);
const createdAtLabel = formatNoteCreatedAt(note.createdAt); const createdAtLabel = formatNoteCreatedAt(note.createdAt);
const isTask = note.type === "tarefa";
return ( return (
<div className="group flex items-center justify-between gap-2 transition-all duration-300 py-2"> <li className="group flex items-center justify-between gap-2 py-1.5 transition-all duration-300">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{displayTitle} {displayTitle}
</p> </p>
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex min-w-0 items-center gap-2">
{isTask ? (
<Badge variant="outline" className="h-5 px-1.5 text-xs"> <Badge variant="outline" className="h-5 px-1.5 text-xs">
{getNoteTasksSummary(note)} {getNoteTasksSummary(note)}
</Badge> </Badge>
) : null}
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<RiCalendarLine className="size-3.5 shrink-0" />
{createdAtLabel} {createdAtLabel}
</span>
</p> </p>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="link" variant="ghost"
size="icon-sm" size="icon-sm"
className="transition-opacity text-primary hover:opacity-80" className="text-primary/70 opacity-70 transition-all hover:text-primary hover:opacity-100 focus-visible:text-primary focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onOpenEdit(note)} onClick={() => onOpenEdit(note)}
aria-label={`Editar anotação ${displayTitle}`} aria-label={`Editar anotação ${displayTitle}`}
> >
<RiPencilLine className="size-4" /> <RiPencilLine className="size-4" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="top">Editar anotação</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="link" variant="ghost"
size="icon-sm" size="icon-sm"
className="transition-opacity text-primary hover:opacity-80" className="text-primary/70 opacity-70 transition-all hover:text-primary hover:opacity-100 focus-visible:text-primary focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onOpenDetails(note)} onClick={() => onOpenDetails(note)}
aria-label={`Ver detalhes da anotação ${displayTitle}`} aria-label={`Ver detalhes da anotação ${displayTitle}`}
> >
<RiFileList2Line className="size-4" /> <RiFileList2Line className="size-4" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="top">Ver detalhes</TooltipContent>
</Tooltip>
</div> </div>
</div> </li>
); );
} }

View File

@@ -27,7 +27,7 @@ export function NotesWidgetView({
}: NotesWidgetViewProps) { }: NotesWidgetViewProps) {
return ( return (
<> <>
<div className="flex flex-col gap-4 px-0"> <div className="flex flex-col px-0">
<NotesList <NotesList
notes={notes} notes={notes}
onOpenEdit={onOpenEdit} onOpenEdit={onOpenEdit}

View File

@@ -1,4 +1,3 @@
import { RiExternalLinkLine } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { import {
@@ -24,13 +23,18 @@ export type PaymentBreakdownListItemData = {
type PaymentBreakdownListItemProps = { type PaymentBreakdownListItemProps = {
item: PaymentBreakdownListItemData; item: PaymentBreakdownListItemData;
position: number;
}; };
export function PaymentBreakdownListItem({ export function PaymentBreakdownListItem({
item, item,
position,
}: PaymentBreakdownListItemProps) { }: PaymentBreakdownListItemProps) {
return ( return (
<div className="flex items-center gap-3 transition-all duration-300 py-1.5"> <div className="flex items-center gap-2 transition-all duration-300 py-1">
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{position}
</span>
<div <div
className="flex size-9.5 shrink-0 items-center justify-center rounded-full" className="flex size-9.5 shrink-0 items-center justify-center rounded-full"
style={{ style={{
@@ -49,22 +53,20 @@ export function PaymentBreakdownListItem({
className="inline-flex items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline" className="inline-flex items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
> >
<span className="truncate">{item.title}</span> <span className="truncate">{item.title}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
) : ( ) : (
<p className="text-sm font-medium text-foreground">{item.title}</p> <p className="text-sm font-medium text-foreground">{item.title}</p>
)} )}
<MoneyValues className="font-medium" amount={item.amount} /> <MoneyValues className="shrink-0 font-medium" amount={item.amount} />
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span> <span>
{formatPaymentBreakdownTransactionsLabel(item.transactions)} {formatPaymentBreakdownTransactionsLabel(item.transactions)}
</span> </span>
<span>{formatPaymentBreakdownPercentage(item.percentage)}</span> <span>
{formatPaymentBreakdownPercentage(item.percentage)} do total
</span>
</div> </div>
<div className="mt-1"> <div className="mt-1">

View File

@@ -31,10 +31,14 @@ export function PaymentBreakdownList({
} }
return ( return (
<div className="flex flex-col gap-4 px-0"> <div className="flex flex-col px-0">
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{items.map((item) => ( {items.map((item, index) => (
<PaymentBreakdownListItem key={item.id} item={item} /> <PaymentBreakdownListItem
key={item.id}
item={item}
position={index + 1}
/>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -43,7 +43,7 @@ export function PaymentOverviewWidgetView({
className="text-xs data-[state=active]:bg-transparent" className="text-xs data-[state=active]:bg-transparent"
> >
<RiMoneyDollarCircleLine className="mr-1 size-3.5" /> <RiMoneyDollarCircleLine className="mr-1 size-3.5" />
Formas Formas de pagamento
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>

View File

@@ -1,16 +1,18 @@
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import StatusDot from "@/shared/components/feedback/status-dot"; import StatusDot from "@/shared/components/feedback/status-dot";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Progress } from "@/shared/components/ui/progress"; import { Progress } from "@/shared/components/ui/progress";
import { formatPercentage } from "@/shared/utils/percentage";
type PaymentStatusCategorySectionProps = { type PaymentStatusCategorySectionProps = {
title: string; type: "income" | "expenses";
total: number; total: number;
confirmed: number; confirmed: number;
pending: number; pending: number;
}; };
export function PaymentStatusCategorySection({ export function PaymentStatusCategorySection({
title, type,
total, total,
confirmed, confirmed,
pending, pending,
@@ -19,27 +21,51 @@ export function PaymentStatusCategorySection({
const absConfirmed = Math.abs(confirmed); const absConfirmed = Math.abs(confirmed);
const confirmedPercentage = const confirmedPercentage =
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0; absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
const income = type === "income";
const title = income ? "A receber" : "A pagar";
const confirmedLabel = income ? "recebidos" : "pagos";
const pendingLabel = income ? "a receber" : "a pagar";
const percentageLabel = income ? "recebido" : "pago";
const TitleIcon = income ? RiArrowDownLine : RiArrowUpLine;
return ( return (
<div className="mt-4 space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{title}</span> <span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<TitleIcon className="size-4 text-primary" aria-hidden />
{title}
</span>
<span className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatPercentage(confirmedPercentage, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}{" "}
{percentageLabel}
</span>
<MoneyValues amount={total} className="font-medium" /> <MoneyValues amount={total} className="font-medium" />
</span>
</div> </div>
<Progress value={confirmedPercentage} className="h-2" /> <Progress
value={confirmedPercentage}
className="h-2"
indicatorClassName="bg-primary"
/>
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4"> <div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<StatusDot color="bg-primary" /> <StatusDot color="bg-primary" />
<MoneyValues amount={confirmed} className="font-medium" /> <MoneyValues amount={confirmed} className="font-medium" />
<span className="text-xs text-muted-foreground">confirmados</span> <span className="text-xs text-muted-foreground">
{confirmedLabel}
</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<StatusDot color="bg-warning/40" /> <StatusDot color="bg-warning/40" />
<MoneyValues amount={pending} className="font-medium" /> <MoneyValues amount={pending} className="font-medium" />
<span className="text-xs text-muted-foreground">pendentes</span> <span className="text-xs text-muted-foreground">{pendingLabel}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -28,7 +28,7 @@ export function PaymentStatusWidgetView({
return ( return (
<CardContent className="space-y-6 px-0"> <CardContent className="space-y-6 px-0">
<PaymentStatusCategorySection <PaymentStatusCategorySection
title="A Receber" type="income"
total={data.income.total} total={data.income.total}
confirmed={data.income.confirmed} confirmed={data.income.confirmed}
pending={data.income.pending} pending={data.income.pending}
@@ -37,7 +37,7 @@ export function PaymentStatusWidgetView({
<div className="border-t" /> <div className="border-t" />
<PaymentStatusCategorySection <PaymentStatusCategorySection
title="A Pagar" type="expenses"
total={data.expenses.total} total={data.expenses.total}
confirmed={data.expenses.confirmed} confirmed={data.expenses.confirmed}
pending={data.expenses.pending} pending={data.expenses.pending}

View File

@@ -1,6 +1,11 @@
"use client"; "use client";
import { RiLineChartLine } from "@remixicon/react"; import {
RiArrowRightLine,
RiCalendarLine,
RiHistoryLine,
RiLineChartLine,
} from "@remixicon/react";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers"; import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { CategoryIconBadge } from "@/shared/components/entity-avatar"; import { CategoryIconBadge } from "@/shared/components/entity-avatar";
@@ -50,12 +55,30 @@ export function CategoryTrendsWidget({
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{category.categoryName} {category.categoryName}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MoneyValues amount={category.previousAmount} /> vs{" "} <span
className="inline-flex items-center gap-1"
title="Mês anterior"
>
<RiHistoryLine className="size-3.5" aria-hidden />
<span className="sr-only">Mês anterior:</span>
<MoneyValues amount={category.previousAmount} />
</span>
<RiArrowRightLine className="size-3" aria-hidden />
<span
className="inline-flex items-center gap-1 text-foreground"
title="Mês atual"
>
<RiCalendarLine
className="size-3.5 text-primary"
aria-hidden
/>
<span className="sr-only">Mês atual:</span>
<MoneyValues <MoneyValues
amount={category.currentAmount} amount={category.currentAmount}
className="font-semibold" className="font-semibold"
/> />
</span>
</p> </p>
</div> </div>
<PercentageChangeIndicator <PercentageChangeIndicator

View File

@@ -6,6 +6,7 @@ import {
RiDeleteBinLine, RiDeleteBinLine,
} from "@remixicon/react"; } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,6 +20,11 @@ import { TransactionDialog } from "@/features/transactions/components/dialogs/tr
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { resolveLogoSrc } from "@/shared/lib/logo"; import { resolveLogoSrc } from "@/shared/lib/logo";
@@ -46,6 +52,24 @@ function getDateString(date: Date | string | null | undefined): string | null {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function findMatchingLogo(
sourceAppName: string | null,
logoMap: Record<string, string>,
): string | null {
if (!sourceAppName) return null;
const appName = sourceAppName.toLowerCase();
if (logoMap[appName]) return resolveLogoSrc(logoMap[appName]);
for (const [name, logo] of Object.entries(logoMap)) {
if (name.includes(appName) || appName.includes(name)) {
return resolveLogoSrc(logo);
}
}
return null;
}
export function InboxWidget({ export function InboxWidget({
snapshot, snapshot,
quickActionOptions, quickActionOptions,
@@ -149,13 +173,18 @@ export function InboxWidget({
if (snapshot.pendingCount === 0) { if (snapshot.pendingCount === 0) {
return ( return (
<WidgetEmptyState <WidgetEmptyState
icon={<RiCheckboxCircleFill color="green" className="size-6" />} icon={<RiCheckboxCircleFill className="size-6 text-success" />}
title="Tudo em dia" title="Tudo em dia"
description="Nenhum pré-lançamento aguardando revisão." description="Nenhum pré-lançamento aguardando revisão."
/> />
); );
} }
const remainingCount = Math.max(
snapshot.pendingCount - snapshot.recentItems.length,
0,
);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{snapshot.recentItems.map((item) => { {snapshot.recentItems.map((item) => {
@@ -168,17 +197,12 @@ export function InboxWidget({
parsedAmount !== null && Number.isFinite(parsedAmount) parsedAmount !== null && Number.isFinite(parsedAmount)
? parsedAmount ? parsedAmount
: null; : null;
const logoKey = item.sourceAppName?.toLowerCase() ?? ""; const logoSrc = findMatchingLogo(item.sourceAppName, snapshot.logoMap);
const rawLogo = snapshot.logoMap[logoKey] ?? null;
const logoSrc = resolveLogoSrc(rawLogo);
const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO; const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO;
return ( return (
<div <div key={item.id} className="flex items-center justify-between py-2">
key={item.id} <div className="flex min-w-0 flex-1 items-center gap-2">
className="flex items-center justify-between py-1.5"
>
<div className="flex flex-1 items-center gap-2">
<Image <Image
src={displayLogo} src={displayLogo}
alt={item.sourceAppName ?? ""} alt={item.sourceAppName ?? ""}
@@ -188,52 +212,74 @@ export function InboxWidget({
unoptimized unoptimized
/> />
<div> <div className="min-w-0">
<p className="text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{displayName.length > 30 {displayName}
? `${displayName.slice(0, 30)}...`
: displayName}
</p> </p>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
{item.sourceAppName && <span>{item.sourceAppName}</span>} {item.sourceAppName && (
<span className="truncate">{item.sourceAppName}</span>
)}
<span className="text-muted-foreground/60"> <span className="text-muted-foreground/60">
{relativeTime(item.createdAt)} {relativeTime(item.notificationTimestamp)}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-col items-end"> <div className="ml-2 flex shrink-0 items-center gap-1">
{amount !== null && ( {amount !== null && (
<MoneyValues className="font-medium" amount={amount} /> <MoneyValues className="font-medium" amount={amount} />
)} )}
{amount === null && (
<span className="max-w-20 text-right text-xs leading-tight text-muted-foreground">
Valor não identificado
</span>
)}
<div className="flex items-center"> <div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
className="size-6 text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
onClick={() => handleProcessRequest(item)} onClick={() => handleProcessRequest(item)}
aria-label="Processar notificação" aria-label="Lançar notificação"
title="Processar"
> >
<RiCheckLine className="size-3.5" /> <RiCheckLine className="size-3.5" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="top">Lançar</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
className="size-6 text-muted-foreground hover:text-destructive" className="text-muted-foreground hover:text-destructive"
onClick={() => handleDiscardRequest(item)} onClick={() => handleDiscardRequest(item)}
aria-label="Descartar notificação" aria-label="Descartar notificação"
title="Descartar"
> >
<RiDeleteBinLine className="size-3.5" /> <RiDeleteBinLine className="size-3.5" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="top">Descartar</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
{remainingCount > 0 && (
<Link
href="/inbox"
className="mt-2 inline-flex items-center justify-center text-xs font-medium text-muted-foreground transition-colors hover:text-primary"
>
+ {remainingCount} pendentes · Revisar todos
</Link>
)}
<TransactionDialog <TransactionDialog
mode="create" mode="create"
open={processOpen} open={processOpen}

View File

@@ -1,7 +1,14 @@
"use client"; "use client";
import { RiLineChartLine } from "@remixicon/react"; import { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; import {
Bar,
CartesianGrid,
ComposedChart,
Line,
ReferenceLine,
XAxis,
} from "recharts";
import type { IncomeExpenseBalanceData } from "@/features/dashboard/overview/income-expense-balance-queries"; import type { IncomeExpenseBalanceData } from "@/features/dashboard/overview/income-expense-balance-queries";
import { CardContent } from "@/shared/components/ui/card"; import { CardContent } from "@/shared/components/ui/card";
import { import {
@@ -11,6 +18,7 @@ import {
} from "@/shared/components/ui/chart"; } from "@/shared/components/ui/chart";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { formatCurrency } from "@/shared/utils/currency"; import { formatCurrency } from "@/shared/utils/currency";
import { formatCompactPeriodLabel } from "@/shared/utils/period";
type IncomeExpenseBalanceWidgetProps = { type IncomeExpenseBalanceWidgetProps = {
data: IncomeExpenseBalanceData; data: IncomeExpenseBalanceData;
@@ -19,15 +27,15 @@ type IncomeExpenseBalanceWidgetProps = {
const chartConfig = { const chartConfig = {
receita: { receita: {
label: "Receita", label: "Receita",
color: "var(--chart-1)", color: "var(--success)",
}, },
despesa: { despesa: {
label: "Despesa", label: "Despesa",
color: "var(--chart-2)", color: "var(--destructive)",
}, },
balanco: { balanco: {
label: "Balanço", label: "Balanço",
color: "var(--chart-3)", color: "var(--primary)",
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
@@ -35,7 +43,7 @@ export function IncomeExpenseBalanceWidget({
data, data,
}: IncomeExpenseBalanceWidgetProps) { }: IncomeExpenseBalanceWidgetProps) {
const chartData = data.months.map((month) => ({ const chartData = data.months.map((month) => ({
month: month.monthLabel, month: formatCompactPeriodLabel(month.month).toLowerCase(),
receita: month.income, receita: month.income,
despesa: month.expense, despesa: month.expense,
balanco: month.balance, balanco: month.balance,
@@ -59,16 +67,18 @@ export function IncomeExpenseBalanceWidget({
} }
return ( return (
<CardContent className="space-y-4 px-0"> <CardContent className="space-y-2 px-0">
<ChartContainer <ChartContainer
config={chartConfig} config={chartConfig}
className="h-[270px] w-full aspect-auto" className="h-[270px] w-full aspect-auto"
> >
<BarChart <ComposedChart
data={chartData} data={chartData}
margin={{ top: 20, right: 10, left: 10, bottom: 5 }} margin={{ top: 20, right: 10, left: 10, bottom: 5 }}
accessibilityLayer
> >
<CartesianGrid strokeDasharray="3 3" vertical={false} /> <CartesianGrid strokeDasharray="3 3" vertical={false} />
<ReferenceLine y={0} stroke="var(--border)" />
<XAxis <XAxis
dataKey="month" dataKey="month"
tickLine={false} tickLine={false}
@@ -81,8 +91,15 @@ export function IncomeExpenseBalanceWidget({
return null; return null;
} }
const month = payload[0]?.payload.month as string | undefined;
return ( return (
<div className="rounded-lg border bg-background p-2 shadow-sm"> <div className="rounded-lg border bg-background p-2 shadow-sm">
{month ? (
<p className="mb-2 text-xs font-medium text-muted-foreground">
{month}
</p>
) : null}
<div className="grid gap-2"> <div className="grid gap-2">
{payload.map((entry) => { {payload.map((entry) => {
const config = const config =
@@ -111,7 +128,7 @@ export function IncomeExpenseBalanceWidget({
</div> </div>
); );
}} }}
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }} cursor={{ fill: "var(--muted)", opacity: 0.3 }}
/> />
<Bar <Bar
dataKey="receita" dataKey="receita"
@@ -125,42 +142,26 @@ export function IncomeExpenseBalanceWidget({
radius={[4, 4, 0, 0]} radius={[4, 4, 0, 0]}
maxBarSize={60} maxBarSize={60}
/> />
<Bar <Line
dataKey="balanco" dataKey="balanco"
fill={chartConfig.balanco.color} type="monotone"
radius={[4, 4, 0, 0]} stroke={chartConfig.balanco.color}
maxBarSize={60} strokeWidth={2}
dot={{ fill: chartConfig.balanco.color, r: 3 }}
activeDot={{ r: 5 }}
/> />
</BarChart> </ComposedChart>
</ChartContainer> </ChartContainer>
<div className="flex items-center justify-center gap-6"> <div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2"> {Object.values(chartConfig).map((config) => (
<div key={config.label} className="flex items-center gap-1.5">
<div <div
className="size-2 rounded-full" className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.receita.color }} style={{ backgroundColor: config.color }}
/> />
<span className="text-sm text-muted-foreground"> <span>{config.label}</span>
{chartConfig.receita.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.despesa.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.despesa.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.balanco.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.balanco.label}
</span>
</div> </div>
))}
</div> </div>
</CardContent> </CardContent>
); );

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { import {
RiArrowRightLine,
RiBarChartBoxLine, RiBarChartBoxLine,
RiExternalLinkLine,
RiEyeLine, RiEyeLine,
RiEyeOffLine, RiEyeOffLine,
} from "@remixicon/react"; } from "@remixicon/react";
@@ -24,7 +24,9 @@ import {
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { isAccountInactive } from "@/shared/lib/accounts/constants"; import { isAccountInactive } from "@/shared/lib/accounts/constants";
import { resolveLogoSrc } from "@/shared/lib/logo"; import { resolveLogoSrc } from "@/shared/lib/logo";
import { buildInitials } from "@/shared/utils/initials";
import { formatPeriodForUrl } from "@/shared/utils/period"; import { formatPeriodForUrl } from "@/shared/utils/period";
import { cn } from "@/shared/utils/ui";
type MyAccountsWidgetProps = { type MyAccountsWidgetProps = {
accounts: DashboardAccount[]; accounts: DashboardAccount[];
@@ -54,9 +56,6 @@ export function MyAccountsWidget({
: activeAccounts.filter((account) => !account.excludeFromBalance); : activeAccounts.filter((account) => !account.excludeFromBalance);
const displayedAccounts = visibleAccounts.slice(0, 5); const displayedAccounts = visibleAccounts.slice(0, 5);
const remainingCount = visibleAccounts.length - displayedAccounts.length; const remainingCount = visibleAccounts.length - displayedAccounts.length;
const hiddenExcludedAccountsCount = showExcludedAccounts
? 0
: excludedAccountsCount;
const toggleButtonLabel = showExcludedAccounts const toggleButtonLabel = showExcludedAccounts
? "Ocultar contas não consideradas" ? "Ocultar contas não consideradas"
: "Mostrar contas não consideradas"; : "Mostrar contas não consideradas";
@@ -81,7 +80,7 @@ export function MyAccountsWidget({
<> <>
<div className="flex items-start justify-between gap-3 py-1"> <div className="flex items-start justify-between gap-3 py-1">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Saldo Total</p> <p className="text-sm text-muted-foreground">Saldo total</p>
<MoneyValues className="text-2xl font-medium" amount={totalBalance} /> <MoneyValues className="text-2xl font-medium" amount={totalBalance} />
</div> </div>
@@ -106,23 +105,21 @@ export function MyAccountsWidget({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left" className="max-w-xs"> <TooltipContent side="left" className="max-w-xs">
<p className="text-xs">{toggleButtonLabel}</p> <p className="text-xs">{toggleButtonLabel}</p>
{!showExcludedAccounts ? (
<p className="mt-1 text-xs text-background/70">
{excludedAccountsCount}{" "}
{excludedAccountsCount === 1
? "conta não considerada oculta"
: "contas não consideradas ocultas"}
</p>
) : null}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
</div> </div>
{hiddenExcludedAccountsCount > 0 ? (
<p className="pb-2 text-xs text-muted-foreground">
{hiddenExcludedAccountsCount}{" "}
{hiddenExcludedAccountsCount === 1
? "conta não considerada oculta"
: "contas não consideradas ocultas"}
</p>
) : null}
<div> <div>
{activeAccounts.length === 0 ? ( {activeAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState <WidgetEmptyState
icon={ icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" /> <RiBarChartBoxLine className="size-6 text-muted-foreground" />
@@ -130,27 +127,24 @@ export function MyAccountsWidget({
title="Você ainda não adicionou nenhuma conta" title="Você ainda não adicionou nenhuma conta"
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações." description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
/> />
</div>
) : displayedAccounts.length === 0 ? ( ) : displayedAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState <WidgetEmptyState
icon={<RiEyeOffLine className="size-6 text-muted-foreground" />} icon={<RiEyeOffLine className="size-6 text-muted-foreground" />}
title="As contas não consideradas estão ocultas" title="As contas não consideradas estão ocultas"
description="Use o botão no topo do widget para mostrá-las novamente." description="Use o botão no topo do widget para mostrá-las novamente."
/> />
</div>
) : ( ) : (
<ul className="flex flex-col"> <ul className="flex flex-col">
{displayedAccounts.map((account, index) => { {displayedAccounts.map((account, index) => {
const logoSrc = resolveLogoSrc(account.logo); const logoSrc = resolveLogoSrc(account.logo);
return ( return (
<div <li
key={account.id} key={account.id}
className="flex items-center justify-between transition-all duration-300 py-1.5 " className="flex items-center justify-between py-1.5 transition-all duration-300"
> >
<div className="flex min-w-0 flex-1 items-center gap-2 py-1"> <div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<div className="relative size-9.5 overflow-hidden"> <div className="relative flex size-9.5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/10">
{logoSrc ? ( {logoSrc ? (
<Image <Image
src={logoSrc} src={logoSrc}
@@ -160,7 +154,11 @@ export function MyAccountsWidget({
className="object-contain rounded-full" className="object-contain rounded-full"
priority={index === 0} priority={index === 0}
/> />
) : null} ) : (
<span className="text-xs font-medium text-primary">
{buildInitials(account.name)}
</span>
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -172,16 +170,14 @@ export function MyAccountsWidget({
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline" className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
> >
<span className="truncate">{account.name}</span> <span className="truncate">{account.name}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
{account.excludeFromBalance ? ( {account.excludeFromBalance ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex cursor-help ml-2"> <span className="inline-flex cursor-help">
<Badge className="font-normal" variant="info"> <Badge className="font-normal" variant="info">
Não considerada Não considerada
</Badge> </Badge>
@@ -190,26 +186,25 @@ export function MyAccountsWidget({
<TooltipContent side="top" className="max-w-xs"> <TooltipContent side="top" className="max-w-xs">
<p className="text-xs"> <p className="text-xs">
Esta conta aparece na lista, mas não entra no Esta conta aparece na lista, mas não entra no
cálculo do saldo total porque está marcada para cálculo do saldo total.
desconsiderar do saldo total.
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-0.5 text-right"> <div className="flex flex-col items-end gap-0.5 text-right">
<MoneyValues <MoneyValues
className="font-medium" className={cn(
"font-medium",
account.balance < 0 && "text-destructive",
)}
amount={account.balance} amount={account.balance}
/> />
</div> </div>
</div> </li>
); );
})} })}
</ul> </ul>
@@ -217,8 +212,14 @@ export function MyAccountsWidget({
</div> </div>
{remainingCount > 0 ? ( {remainingCount > 0 ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground"> <CardFooter className="border-border/60 border-t pt-4">
<Link
href="/accounts"
className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground transition-colors hover:text-primary"
>
+{remainingCount} contas não exibidas +{remainingCount} contas não exibidas
<RiArrowRightLine className="size-4" aria-hidden />
</Link>
</CardFooter> </CardFooter>
) : null} ) : null}
</> </>

View File

@@ -1,10 +1,6 @@
"use client"; "use client";
import { import { RiGroupLine, RiVerifiedBadgeFill } from "@remixicon/react";
RiExternalLinkLine,
RiGroupLine,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import type { DashboardPagador } from "@/features/dashboard/lib/payers-queries"; import type { DashboardPagador } from "@/features/dashboard/lib/payers-queries";
@@ -14,6 +10,11 @@ import {
AvatarFallback, AvatarFallback,
AvatarImage, AvatarImage,
} from "@/shared/components/ui/avatar"; } from "@/shared/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { getAvatarSrc } from "@/shared/lib/payers/utils"; import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { buildInitials } from "@/shared/utils/initials"; import { buildInitials } from "@/shared/utils/initials";
@@ -33,7 +34,7 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
/> />
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
{payers.map((payer) => { {payers.map((payer, index) => {
const initials = buildInitials(payer.name); const initials = buildInitials(payer.name);
const hasValidPercentageChange = const hasValidPercentageChange =
typeof payer.percentageChange === "number" && typeof payer.percentageChange === "number" &&
@@ -45,8 +46,11 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
return ( return (
<div <div
key={payer.id} key={payer.id}
className="flex items-center justify-between transition-all duration-300 py-1.5" className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"
> >
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1"> <div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<Avatar className="size-9.5 shrink-0"> <Avatar className="size-9.5 shrink-0">
<AvatarImage <AvatarImage
@@ -64,18 +68,24 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
> >
<span className="truncate font-medium">{payer.name}</span> <span className="truncate font-medium">{payer.name}</span>
{payer.isAdmin && ( {payer.isAdmin && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<RiVerifiedBadgeFill <RiVerifiedBadgeFill
className="size-4 shrink-0 text-blue-500" className="size-4 text-blue-500"
aria-hidden aria-hidden
/> />
<span className="sr-only">Pessoa principal</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Pessoa principal
</TooltipContent>
</Tooltip>
)} )}
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{payer.email ?? "Sem email cadastrado"} Despesas no período
</p> </p>
</div> </div>
</div> </div>
@@ -85,7 +95,12 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
className="font-medium" className="font-medium"
amount={payer.totalExpenses} amount={payer.totalExpenses}
/> />
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<PercentageChangeIndicator value={percentageChange} /> <PercentageChangeIndicator value={percentageChange} />
{percentageChange !== null ? (
<span>vs. mês ant.</span>
) : null}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react"; import { RiFileList2Line, RiStore3Line } from "@remixicon/react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries"; import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
@@ -8,7 +8,9 @@ import MoneyValues from "@/shared/components/money-values";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/components/ui/select"; } from "@/shared/components/ui/select";
@@ -129,18 +131,18 @@ export function PurchasesByCategoryWidget({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(categoriesByType).map(([type, categories]) => ( {Object.entries(categoriesByType).map(([type, categories]) => (
<div key={type}> <SelectGroup key={type}>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground"> <SelectLabel className="font-medium">
{CATEGORY_TYPE_LABEL[ {CATEGORY_TYPE_LABEL[
type as keyof typeof CATEGORY_TYPE_LABEL type as keyof typeof CATEGORY_TYPE_LABEL
] ?? type} ] ?? type}
</div> </SelectLabel>
{categories.map((category) => ( {categories.map((category) => (
<SelectItem key={category.id} value={category.id}> <SelectItem key={category.id} value={category.id}>
{category.name} {category.name}
</SelectItem> </SelectItem>
))} ))}
</div> </SelectGroup>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -148,12 +150,12 @@ export function PurchasesByCategoryWidget({
{currentTransactions.length === 0 ? ( {currentTransactions.length === 0 ? (
<WidgetEmptyState <WidgetEmptyState
icon={<RiArrowDownSFill className="size-6 text-muted-foreground" />} icon={<RiFileList2Line className="size-6 text-muted-foreground" />}
title="Nenhuma compra encontrada" title="Nenhum lançamento encontrado"
description={ description={
selectedCategory selectedCategory
? `Não há lançamentos na categoria "${selectedCategory.name}".` ? `Não há lançamentos na categoria "${selectedCategory.name}".`
: "Selecione uma categoria para visualizar as compras." : "Selecione uma categoria para visualizar os lançamentos."
} }
/> />
) : ( ) : (
@@ -162,9 +164,9 @@ export function PurchasesByCategoryWidget({
return ( return (
<div <div
key={transaction.id} key={transaction.id}
className="flex items-center justify-between gap-3 transition-all duration-300 py-2" className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-2">
<EstablishmentLogo name={transaction.name} size={37} /> <EstablishmentLogo name={transaction.name} size={37} />
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -3,6 +3,7 @@ import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurr
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { getPaymentMethodIcon } from "@/shared/utils/icons";
type RecurringExpensesWidgetProps = { type RecurringExpensesWidgetProps = {
data: RecurringExpensesData; data: RecurringExpensesData;
@@ -10,10 +11,10 @@ type RecurringExpensesWidgetProps = {
const formatOccurrences = (value: number | null) => { const formatOccurrences = (value: number | null) => {
if (!value) { if (!value) {
return "Recorrência contínua"; return "Repete mensalmente";
} }
return `${value} recorrências mensais`; return `Repete por ${value} ${value === 1 ? "mês" : "meses"}`;
}; };
export function RecurringExpensesWidget({ export function RecurringExpensesWidget({
@@ -23,7 +24,7 @@ export function RecurringExpensesWidget({
return ( return (
<WidgetEmptyState <WidgetEmptyState
icon={<RiRefreshLine className="size-6 text-muted-foreground" />} icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa recorrente" title="Nenhuma despesa recorrente encontrada"
description="Lançamentos recorrentes aparecerão aqui conforme forem registrados." description="Lançamentos recorrentes aparecerão aqui conforme forem registrados."
/> />
); );
@@ -31,7 +32,9 @@ export function RecurringExpensesWidget({
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{data.expenses.map((expense) => { {[...data.expenses]
.sort((a, b) => b.amount - a.amount)
.map((expense) => {
return ( return (
<div <div
key={expense.id} key={expense.id}
@@ -45,11 +48,15 @@ export function RecurringExpensesWidget({
{expense.name} {expense.name}
</p> </p>
<MoneyValues className="font-medium" amount={expense.amount} /> <MoneyValues
className="font-medium"
amount={expense.amount}
/>
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1 [&_svg]:size-3.5">
{getPaymentMethodIcon(expense.paymentMethod)}
{expense.paymentMethod} {expense.paymentMethod}
</span> </span>
<span>{formatOccurrences(expense.recurrenceCount)}</span> <span>{formatOccurrences(expense.recurrenceCount)}</span>

View File

@@ -15,13 +15,11 @@ import { TopExpensesWidget } from "./top-expenses-widget";
type SpendingOverviewWidgetProps = { type SpendingOverviewWidgetProps = {
topExpensesAll: TopExpensesData; topExpensesAll: TopExpensesData;
topExpensesCardOnly: TopExpensesData;
topEstablishmentsData: TopEstablishmentsData; topEstablishmentsData: TopEstablishmentsData;
}; };
export function SpendingOverviewWidget({ export function SpendingOverviewWidget({
topExpensesAll, topExpensesAll,
topExpensesCardOnly,
topEstablishmentsData, topEstablishmentsData,
}: SpendingOverviewWidgetProps) { }: SpendingOverviewWidgetProps) {
const [activeTab, setActiveTab] = useState<"expenses" | "establishments">( const [activeTab, setActiveTab] = useState<"expenses" | "establishments">(
@@ -54,10 +52,7 @@ export function SpendingOverviewWidget({
</TabsList> </TabsList>
<TabsContent value="expenses" className="mt-2"> <TabsContent value="expenses" className="mt-2">
<TopExpensesWidget <TopExpensesWidget data={topExpensesAll} />
allExpenses={topExpensesAll}
cardOnlyExpenses={topExpensesCardOnly}
/>
</TabsContent> </TabsContent>
<TabsContent value="establishments" className="mt-2"> <TabsContent value="establishments" className="mt-2">

View File

@@ -28,13 +28,16 @@ export function TopEstablishmentsWidget({
/> />
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
{data.establishments.map((establishment) => { {data.establishments.map((establishment, index) => {
return ( return (
<div <div
key={establishment.id} key={establishment.id}
className="flex items-center justify-between gap-3 transition-all duration-300 py-2" className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<EstablishmentLogo name={establishment.name} size={37} /> <EstablishmentLogo name={establishment.name} size={37} />
<div className="min-w-0"> <div className="min-w-0">
@@ -42,7 +45,8 @@ export function TopEstablishmentsWidget({
{establishment.name} {establishment.name}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatOccurrencesLabel(establishment.occurrences)} {formatOccurrencesLabel(establishment.occurrences)} ·
total acumulado
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,20 +1,18 @@
"use client"; "use client";
import { RiArrowUpDoubleLine } from "@remixicon/react"; import { RiArrowUpDoubleLine } from "@remixicon/react";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import type { import type {
TopExpense, TopExpense,
TopExpensesData, TopExpensesData,
} from "@/features/dashboard/expenses/top-expenses-queries"; } from "@/features/dashboard/expenses/top-expenses-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Switch } from "@/shared/components/ui/switch";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { formatTransactionDate } from "@/shared/utils/date"; import { formatTransactionDate } from "@/shared/utils/date";
type TopExpensesWidgetProps = { type TopExpensesWidgetProps = {
allExpenses: TopExpensesData; data: TopExpensesData;
cardOnlyExpenses: TopExpensesData;
}; };
const shouldIncludeExpense = (expense: TopExpense) => { const shouldIncludeExpense = (expense: TopExpense) => {
@@ -31,58 +29,15 @@ const shouldIncludeExpense = (expense: TopExpense) => {
return true; return true;
}; };
const isCardExpense = (expense: TopExpense) => export function TopExpensesWidget({ data }: TopExpensesWidgetProps) {
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false; const expenses = useMemo(
() => data.expenses.filter(shouldIncludeExpense),
export function TopExpensesWidget({ [data.expenses],
allExpenses, );
cardOnlyExpenses,
}: TopExpensesWidgetProps) {
const [cardOnly, setCardOnly] = useState(false);
const normalizedAllExpenses = useMemo(() => {
return allExpenses.expenses.filter(shouldIncludeExpense);
}, [allExpenses]);
const normalizedCardOnlyExpenses = useMemo(() => {
const merged = [...cardOnlyExpenses.expenses, ...normalizedAllExpenses];
const seen = new Set<string>();
return merged.filter((expense) => {
if (seen.has(expense.id)) {
return false;
}
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
return false;
}
seen.add(expense.id);
return true;
});
}, [cardOnlyExpenses, normalizedAllExpenses]);
const data = cardOnly
? { expenses: normalizedCardOnlyExpenses }
: { expenses: normalizedAllExpenses };
return ( return (
<div className="flex flex-col gap-4 px-0"> <div className="flex flex-col px-0">
<div className="flex items-center justify-between gap-3"> {expenses.length === 0 ? (
<label
htmlFor="card-only-toggle"
className="text-sm text-muted-foreground"
>
Apenas cartões
</label>
<Switch
id="card-only-toggle"
checked={cardOnly}
onCheckedChange={setCardOnly}
/>
</div>
{data.expenses.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState <WidgetEmptyState
icon={ icon={
<RiArrowUpDoubleLine className="size-6 text-muted-foreground" /> <RiArrowUpDoubleLine className="size-6 text-muted-foreground" />
@@ -90,16 +45,18 @@ export function TopExpensesWidget({
title="Nenhuma despesa encontrada" title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui." description="Quando houver despesas registradas, elas aparecerão aqui."
/> />
</div>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
{data.expenses.map((expense) => { {expenses.map((expense, index) => {
return ( return (
<div <div
key={expense.id} key={expense.id}
className="flex items-center justify-between gap-3 transition-all duration-300 py-2" className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<EstablishmentLogo name={expense.name} size={37} /> <EstablishmentLogo name={expense.name} size={37} />
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -21,6 +21,7 @@ type WidgetSettingsDialogProps = {
onToggleWidget: (widgetId: string) => void; onToggleWidget: (widgetId: string) => void;
onReset: () => void; onReset: () => void;
triggerClassName?: string; triggerClassName?: string;
triggerLabel?: string;
}; };
export function WidgetSettingsDialog({ export function WidgetSettingsDialog({
@@ -28,6 +29,7 @@ export function WidgetSettingsDialog({
onToggleWidget, onToggleWidget,
onReset, onReset,
triggerClassName, triggerClassName,
triggerLabel = "Widgets",
}: WidgetSettingsDialogProps) { }: WidgetSettingsDialogProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -40,12 +42,12 @@ export function WidgetSettingsDialog({
className={cn("gap-2", triggerClassName)} className={cn("gap-2", triggerClassName)}
> >
<RiSettings4Line className="size-4" /> <RiSettings4Line className="size-4" />
Widgets {triggerLabel}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Configurar Widgets</DialogTitle> <DialogTitle>Configurar widgets</DialogTitle>
<DialogDescription> <DialogDescription>
Escolha quais widgets deseja exibir no seu dashboard. Escolha quais widgets deseja exibir no seu dashboard.
</DialogDescription> </DialogDescription>
@@ -91,7 +93,7 @@ export function WidgetSettingsDialog({
className="gap-2" className="gap-2"
> >
<RiRefreshLine className="size-4" /> <RiRefreshLine className="size-4" />
Restaurar Padrão Restaurar padrão
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -5,7 +5,7 @@ import { capitalize } from "@/shared/utils/string";
type InstallmentExpenseDisplay = { type InstallmentExpenseDisplay = {
compactLabel: string | null; compactLabel: string | null;
isLast: boolean; isLast: boolean;
remainingLabel: "Próx." | "Aberto"; remainingLabel: "Próximas" | "Em aberto";
remainingInstallments: number; remainingInstallments: number;
remainingAmount: number; remainingAmount: number;
endDate: string | null; endDate: string | null;
@@ -17,7 +17,7 @@ const buildInstallmentCompactLabel = (
installmentCount: number | null, installmentCount: number | null,
) => { ) => {
if (currentInstallment && installmentCount) { if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`; return `Parcela ${currentInstallment} de ${installmentCount}`;
} }
return null; return null;
@@ -111,7 +111,7 @@ export const buildInstallmentExpenseDisplay = (
installmentCount, installmentCount,
), ),
isLast: isInstallmentLast(currentInstallment, installmentCount), isLast: isInstallmentLast(currentInstallment, installmentCount),
remainingLabel: isSettled === true ? "Próx." : "Aberto", remainingLabel: isSettled === true ? "Próximas" : "Em aberto",
remainingInstallments: calculateInstallmentRemainingCount( remainingInstallments: calculateInstallmentRemainingCount(
currentInstallment, currentInstallment,
installmentCount, installmentCount,

View File

@@ -65,7 +65,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
installmentExpensesData: currentPeriodOverview.installmentExpensesData, installmentExpensesData: currentPeriodOverview.installmentExpensesData,
topEstablishmentsData: currentPeriodOverview.topEstablishmentsData, topEstablishmentsData: currentPeriodOverview.topEstablishmentsData,
topExpensesAll: currentPeriodOverview.topExpensesAll, topExpensesAll: currentPeriodOverview.topExpensesAll,
topExpensesCardOnly: currentPeriodOverview.topExpensesCardOnly,
purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData, purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData,
incomeByCategoryData: categoryOverview.incomeByCategoryData, incomeByCategoryData: categoryOverview.incomeByCategoryData,
expensesByCategoryData: categoryOverview.expensesByCategoryData, expensesByCategoryData: categoryOverview.expensesByCategoryData,

View File

@@ -4,7 +4,11 @@ import {
INVOICE_PAYMENT_STATUS, INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus, type InvoicePaymentStatus,
} from "@/shared/lib/invoices"; } from "@/shared/lib/invoices";
import { getBusinessDateString } from "@/shared/utils/date"; import {
getBusinessDateString,
parseUtcDateString,
toDateOnlyString,
} from "@/shared/utils/date";
import { import {
buildDueDateInfoFromPeriodDay, buildDueDateInfoFromPeriodDay,
buildRelativeDueDateInfoFromPeriodDay, buildRelativeDueDateInfoFromPeriodDay,
@@ -80,6 +84,29 @@ export const formatInvoiceWidgetPaymentDate = (
}; };
}; };
export const formatInvoiceWidgetOverdueLabel = (
value: string | null,
): string | null => {
const dueDateValue = toDateOnlyString(value);
const todayValue = getBusinessDateString();
if (!dueDateValue || dueDateValue >= todayValue) {
return null;
}
const dueDate = parseUtcDateString(dueDateValue);
const today = parseUtcDateString(todayValue);
if (!dueDate || !today) {
return null;
}
const overdueDays = Math.round(
(today.getTime() - dueDate.getTime()) / (24 * 60 * 60 * 1000),
);
return overdueDays === 1
? "Atrasada · venceu ontem"
: `Atrasada · venceu há ${overdueDays} dias`;
};
export const getCurrentDateString = () => getBusinessDateString(); export const getCurrentDateString = () => getBusinessDateString();
const formatInvoiceSharePercentage = (value: number) => { const formatInvoiceSharePercentage = (value: number) => {

View File

@@ -1,6 +1,9 @@
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema"; import { financialAccounts, transactions } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants"; import {
INITIAL_BALANCE_NOTE,
isAccountInactive,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
@@ -101,7 +104,10 @@ export async function fetchDashboardAccounts(
.sort((a, b) => b.balance - a.balance); .sort((a, b) => b.balance - a.balance);
const totalBalance = accounts const totalBalance = accounts
.filter((account) => !account.excludeFromBalance) .filter(
(account) =>
!account.excludeFromBalance && !isAccountInactive(account.status),
)
.reduce((total, account) => total + account.balance, 0); .reduce((total, account) => total + account.balance, 0);
return { return {

View File

@@ -16,8 +16,6 @@ export function extractDashboardLogoNames(data: DashboardData): string[] {
for (const establishment of data.topEstablishmentsData.establishments) for (const establishment of data.topEstablishmentsData.establishments)
names.push(establishment.name); names.push(establishment.name);
for (const expense of data.topExpensesAll.expenses) names.push(expense.name); for (const expense of data.topExpensesAll.expenses) names.push(expense.name);
for (const expense of data.topExpensesCardOnly.expenses)
names.push(expense.name);
for (const transactions of Object.values( for (const transactions of Object.values(
data.purchasesByCategoryData.transactionsByCategory, data.purchasesByCategoryData.transactionsByCategory,
)) { )) {

View File

@@ -34,7 +34,6 @@ import {
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_BOLETO = "Boleto";
const PAYMENT_METHOD_CARD = "Cartão de Crédito";
const TRANSACTION_TYPE_EXPENSE = "Despesa"; const TRANSACTION_TYPE_EXPENSE = "Despesa";
const TRANSACTION_TYPE_INCOME = "Receita"; const TRANSACTION_TYPE_INCOME = "Receita";
const CONDITION_RECURRING = "Recorrente"; const CONDITION_RECURRING = "Recorrente";
@@ -79,7 +78,6 @@ type DashboardCurrentPeriodOverview = {
installmentExpensesData: InstallmentExpensesData; installmentExpensesData: InstallmentExpensesData;
topEstablishmentsData: TopEstablishmentsData; topEstablishmentsData: TopEstablishmentsData;
topExpensesAll: TopExpensesData; topExpensesAll: TopExpensesData;
topExpensesCardOnly: TopExpensesData;
purchasesByCategoryData: PurchasesByCategoryData; purchasesByCategoryData: PurchasesByCategoryData;
}; };
@@ -99,7 +97,6 @@ const emptyOverview = (): DashboardCurrentPeriodOverview => ({
installmentExpensesData: { expenses: [] }, installmentExpensesData: { expenses: [] },
topEstablishmentsData: { establishments: [] }, topEstablishmentsData: { establishments: [] },
topExpensesAll: { expenses: [] }, topExpensesAll: { expenses: [] },
topExpensesCardOnly: { expenses: [] },
purchasesByCategoryData: { purchasesByCategoryData: {
categories: [], categories: [],
transactionsByCategory: {}, transactionsByCategory: {},
@@ -190,6 +187,7 @@ const buildBillsSnapshot = (
: null, : null,
isSettled: Boolean(row.isSettled), isSettled: Boolean(row.isSettled),
accountId: row.accountId ?? null, accountId: row.accountId ?? null,
transactionType: row.transactionType,
})) }))
.sort((a, b) => { .sort((a, b) => {
if (a.isSettled !== b.isSettled) { if (a.isSettled !== b.isSettled) {
@@ -432,14 +430,12 @@ const mapTopExpense = (row: CurrentPeriodTransactionRow): TopExpense => ({
const buildTopExpensesData = ( const buildTopExpensesData = (
rows: CurrentPeriodTransactionRow[], rows: CurrentPeriodTransactionRow[],
cardOnly: boolean,
): TopExpensesData => ({ ): TopExpensesData => ({
expenses: rows expenses: rows
.filter( .filter(
(row) => (row) =>
row.transactionType === TRANSACTION_TYPE_EXPENSE && row.transactionType === TRANSACTION_TYPE_EXPENSE &&
shouldIncludeWithoutAutoGenerated(row.note) && shouldIncludeWithoutAutoGenerated(row.note),
(!cardOnly || row.paymentMethod === PAYMENT_METHOD_CARD),
) )
.sort((a, b) => toNumber(a.amount) - toNumber(b.amount)) .sort((a, b) => toNumber(a.amount) - toNumber(b.amount))
.slice(0, 10) .slice(0, 10)
@@ -617,8 +613,7 @@ export async function fetchDashboardCurrentPeriodOverview(
recurringExpensesData: buildRecurringExpensesData(rows), recurringExpensesData: buildRecurringExpensesData(rows),
installmentExpensesData: buildInstallmentExpensesData(rows), installmentExpensesData: buildInstallmentExpensesData(rows),
topEstablishmentsData: buildTopEstablishmentsData(rows), topEstablishmentsData: buildTopEstablishmentsData(rows),
topExpensesAll: buildTopExpensesData(rows, false), topExpensesAll: buildTopExpensesData(rows),
topExpensesCardOnly: buildTopExpensesData(rows, true),
purchasesByCategoryData: buildPurchasesByCategoryData(rows), purchasesByCategoryData: buildPurchasesByCategoryData(rows),
}; };
} }

View File

@@ -69,8 +69,8 @@ export type WidgetConfig = {
export const widgetsConfig: WidgetConfig[] = [ export const widgetsConfig: WidgetConfig[] = [
{ {
id: "my-accounts", id: "my-accounts",
title: "Minhas Contas", title: "Minhas contas",
subtitle: "Saldo consolidado disponível", subtitle: "Saldo atualizado das contas ativas",
icon: <RiBarChartBoxLine className="size-4" />, icon: <RiBarChartBoxLine className="size-4" />,
component: ({ component: ({
data, data,
@@ -114,7 +114,7 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "payment-status", id: "payment-status",
title: "Status de Pagamento", title: "Status de pagamento",
subtitle: "Valores confirmados e pendentes", subtitle: "Valores confirmados e pendentes",
icon: <RiWallet3Line className="size-4" />, icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
@@ -144,8 +144,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "income-expense-balance", id: "income-expense-balance",
title: "Receita, Despesa e Balanço", title: "Receita, despesa e balanço",
subtitle: "Últimos 6 meses", subtitle: "Últimos 6 meses até o período selecionado",
icon: <RiLineChartLine className="size-4" />, icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} /> <IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
@@ -153,8 +153,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "goals-progress", id: "goals-progress",
title: "Progresso de Orçamentos", title: "Progresso de orçamentos",
subtitle: "Orçamentos por categoria no período", subtitle: "Categorias mais próximas do limite",
icon: <RiExchangeLine className="size-4" />, icon: <RiExchangeLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<GoalsProgressWidget data={data.goalsProgressData} /> <GoalsProgressWidget data={data.goalsProgressData} />
@@ -171,7 +171,7 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "category-trends", id: "category-trends",
title: "Tendências de Categorias", title: "Tendências de categorias",
subtitle: "Top 10 maiores variações vs. mês anterior", subtitle: "Top 10 maiores variações vs. mês anterior",
icon: <RiLineChartLine className="size-4" />, icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
@@ -182,21 +182,20 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "spending-overview", id: "spending-overview",
title: "Panorama de Gastos", title: "Panorama de gastos",
subtitle: "Principais despesas e frequência por local", subtitle: "Principais despesas e frequência por local",
icon: <RiArrowUpDoubleLine className="size-4" />, icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<SpendingOverviewWidget <SpendingOverviewWidget
topExpensesAll={data.topExpensesAll} topExpensesAll={data.topExpensesAll}
topExpensesCardOnly={data.topExpensesCardOnly}
topEstablishmentsData={data.topEstablishmentsData} topEstablishmentsData={data.topEstablishmentsData}
/> />
), ),
}, },
{ {
id: "payment-overview", id: "payment-overview",
title: "Comportamento de Pagamento", title: "Distribuição de despesas",
subtitle: "Despesas por condição e forma de pagamento", subtitle: "Por condição e forma de pagamento",
icon: <RiWallet3Line className="size-4" />, icon: <RiWallet3Line className="size-4" />,
component: ({ data, period, adminPayerSlug }) => ( component: ({ data, period, adminPayerSlug }) => (
<PaymentOverviewWidget <PaymentOverviewWidget
@@ -209,8 +208,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "expenses-by-category", id: "expenses-by-category",
title: "Categorias por Despesas", title: "Despesas por categoria",
subtitle: "Distribuição de despesas por categoria", subtitle: "Maiores despesas por categoria",
icon: <RiPieChartLine className="size-4" />, icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => ( component: ({ data, period }) => (
<ExpensesByCategoryWidgetWithChart <ExpensesByCategoryWidgetWithChart
@@ -221,8 +220,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "income-by-category", id: "income-by-category",
title: "Categorias por Receitas", title: "Receitas por categoria",
subtitle: "Distribuição de receitas por categoria", subtitle: "Maiores receitas por categoria",
icon: <RiPieChartLine className="size-4" />, icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => ( component: ({ data, period }) => (
<IncomeByCategoryWidgetWithChart <IncomeByCategoryWidgetWithChart
@@ -233,8 +232,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "purchases-by-category", id: "purchases-by-category",
title: "Lançamentos por Categorias", title: "Lançamentos por categoria",
subtitle: "Distribuição de lançamentos por categoria", subtitle: "Lançamentos recentes da categoria selecionada",
icon: <RiStore3Line className="size-4" />, icon: <RiStore3Line className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} /> <PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
@@ -242,8 +241,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "recurring-expenses", id: "recurring-expenses",
title: "Lançamentos Recorrentes", title: "Despesas recorrentes",
subtitle: "Despesas recorrentes do período", subtitle: "Despesas recorrentes deste mês",
icon: <RiRefreshLine className="size-4" />, icon: <RiRefreshLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<RecurringExpensesWidget data={data.recurringExpensesData} /> <RecurringExpensesWidget data={data.recurringExpensesData} />
@@ -251,8 +250,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "installment-expenses", id: "installment-expenses",
title: "Lançamentos Parcelados", title: "Despesas parceladas",
subtitle: "Acompanhe as parcelas abertas", subtitle: "Parcelamentos mais próximos da quitação",
icon: <RiNumbersLine className="size-4" />, icon: <RiNumbersLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<InstallmentExpensesWidget data={data.installmentExpensesData} /> <InstallmentExpensesWidget data={data.installmentExpensesData} />
@@ -261,7 +260,7 @@ export const widgetsConfig: WidgetConfig[] = [
{ {
id: "pagadores", id: "pagadores",
title: "Pessoas", title: "Pessoas",
subtitle: "Despesas por pessoa no período", subtitle: "Maiores despesas por pessoa no período",
icon: <RiGroupLine className="size-4" />, icon: <RiGroupLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<PayersWidget payers={data.pagadoresSnapshot.payers} /> <PayersWidget payers={data.pagadoresSnapshot.payers} />
@@ -279,7 +278,7 @@ export const widgetsConfig: WidgetConfig[] = [
{ {
id: "notes", id: "notes",
title: "Anotações", title: "Anotações",
subtitle: "Últimas anotações ativas", subtitle: "Anotações ativas adicionadas recentemente",
icon: <RiTodoLine className="size-4" />, icon: <RiTodoLine className="size-4" />,
component: ({ data }) => <NotesWidget notes={data.notesData} />, component: ({ data }) => <NotesWidget notes={data.notesData} />,
action: ( action: (