Dashboard
-- Opções que afetam a experiência no painel principal. -
-- Remove o recurso de linhas magnéticas do sistema. -
-
- {greeting}, {displayName}!
+
+
+
+ {greeting}, {displayName}
- {formattedDate}
+ {formattedDate}
-
+
);
}
diff --git a/components/dashboard/expenses-by-category-widget-with-chart.tsx b/components/dashboard/expenses-by-category-widget-with-chart.tsx
index 669a825..6cc09d7 100644
--- a/components/dashboard/expenses-by-category-widget-with-chart.tsx
+++ b/components/dashboard/expenses-by-category-widget-with-chart.tsx
@@ -1,328 +1,22 @@
"use client";
-import {
- RiArrowDownSFill,
- RiArrowUpSFill,
- RiExternalLinkLine,
- RiListUnordered,
- RiPieChart2Line,
- RiPieChartLine,
- RiWallet3Line,
-} from "@remixicon/react";
-import Link from "next/link";
-import { useMemo, useState } from "react";
-import { Pie, PieChart, Tooltip } from "recharts";
-import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
-import MoneyValues from "@/components/money-values";
-import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
-import { formatPeriodForUrl } from "@/lib/utils/period";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
type ExpensesByCategoryWidgetWithChartProps = {
data: ExpensesByCategoryData;
period: string;
};
-const formatPercentage = (value: number) => {
- return `${Math.abs(value).toFixed(0)}%`;
-};
-
-const formatCurrency = (value: number) =>
- new Intl.NumberFormat("pt-BR", {
- style: "currency",
- currency: "BRL",
- }).format(value);
-
export function ExpensesByCategoryWidgetWithChart({
data,
period,
}: ExpensesByCategoryWidgetWithChartProps) {
- const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
- const periodParam = formatPeriodForUrl(period);
-
- // Configuração do chart com cores do CSS
- const chartConfig = useMemo(() => {
- const config: ChartConfig = {};
- const colors = [
- "var(--chart-1)",
- "var(--chart-2)",
- "var(--chart-3)",
- "var(--chart-4)",
- "var(--chart-5)",
- "var(--chart-1)",
- "var(--chart-2)",
- ];
-
- if (data.categories.length <= 7) {
- data.categories.forEach((category, index) => {
- config[category.categoryId] = {
- label: category.categoryName,
- color: colors[index % colors.length],
- };
- });
- } else {
- // Top 7 + Outros
- const top7 = data.categories.slice(0, 7);
- top7.forEach((category, index) => {
- config[category.categoryId] = {
- label: category.categoryName,
- color: colors[index % colors.length],
- };
- });
- config.outros = {
- label: "Outros",
- color: "var(--chart-6)",
- };
- }
-
- return config;
- }, [data.categories]);
-
- // Preparar dados para o gráfico de pizza - Top 7 + Outros
- const chartData = useMemo(() => {
- if (data.categories.length <= 7) {
- return data.categories.map((category) => ({
- category: category.categoryId,
- name: category.categoryName,
- value: category.currentAmount,
- percentage: category.percentageOfTotal,
- fill: chartConfig[category.categoryId]?.color,
- }));
- }
-
- // Pegar top 7 categorias
- const top7 = data.categories.slice(0, 7);
- const others = data.categories.slice(7);
-
- // Somar o restante
- const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
- const othersPercentage = others.reduce(
- (sum, cat) => sum + cat.percentageOfTotal,
- 0,
- );
-
- const top7Data = top7.map((category) => ({
- category: category.categoryId,
- name: category.categoryName,
- value: category.currentAmount,
- percentage: category.percentageOfTotal,
- fill: chartConfig[category.categoryId]?.color,
- }));
-
- // Adicionar "Outros" se houver
- if (others.length > 0) {
- top7Data.push({
- category: "outros",
- name: "Outros",
- value: othersTotal,
- percentage: othersPercentage,
- fill: chartConfig.outros?.color,
- });
- }
-
- return top7Data;
- }, [data.categories, chartConfig]);
-
- if (data.categories.length === 0) {
- return (
- }
- title="Nenhuma despesa encontrada"
- description="Quando houver despesas registradas, elas aparecerão aqui."
- />
- );
- }
-
return (
- setActiveTab(v as "list" | "chart")}
- className="w-full"
- >
-
-
-
-
- Lista
-
-
-
- Gráfico
-
-
-
-
-
-
- {data.categories.map((category, index) => {
- const hasIncrease =
- category.percentageChange !== null &&
- category.percentageChange > 0;
- const hasDecrease =
- category.percentageChange !== null &&
- category.percentageChange < 0;
- const hasBudget = category.budgetAmount !== null;
- const budgetExceeded =
- hasBudget &&
- category.budgetUsedPercentage !== null &&
- category.budgetUsedPercentage > 100;
-
- const exceededAmount =
- budgetExceeded && category.budgetAmount
- ? category.currentAmount - category.budgetAmount
- : 0;
-
- return (
-
-
-
-
-
-
-
-
-
- {category.categoryName}
-
-
-
-
-
-
- {formatPercentage(category.percentageOfTotal)} da
- despesa total
-
-
-
-
-
-
-
- {category.percentageChange !== null && (
-
- {hasIncrease && }
- {hasDecrease && }
- {formatPercentage(category.percentageChange)}
-
- )}
-
-
-
- {hasBudget && category.budgetUsedPercentage !== null && (
-
-
-
- {budgetExceeded ? (
- <>
- {formatPercentage(category.budgetUsedPercentage)} do
- limite - excedeu em {formatCurrency(exceededAmount)}
- >
- ) : (
- <>
- {formatPercentage(category.budgetUsedPercentage)} do
- limite
- >
- )}
-
-
- )}
-
- );
- })}
-
-
-
-
-
-
-
- formatPercentage(entry.percentage)}
- outerRadius={75}
- dataKey="value"
- nameKey="category"
- />
- {
- if (active && payload && payload.length) {
- const data = payload[0].payload;
- return (
-
-
-
-
- {data.name}
-
-
- {formatCurrency(data.value)}
-
-
- {formatPercentage(data.percentage)} do total
-
-
-
-
- );
- }
- return null;
- }}
- />
-
-
-
-
- {chartData.map((entry, index) => (
-
-
-
- {entry.name}
-
-
- ))}
-
-
-
-
+
);
}
diff --git a/components/dashboard/goals-progress-widget.tsx b/components/dashboard/goals-progress-widget.tsx
index b6e56dd..1ddbe21 100644
--- a/components/dashboard/goals-progress-widget.tsx
+++ b/components/dashboard/goals-progress-widget.tsx
@@ -1,146 +1,32 @@
"use client";
-import { RiFundsLine, RiPencilLine } from "@remixicon/react";
-import { useCallback, useMemo, useState } from "react";
-import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
-import MoneyValues from "@/components/money-values";
-import { BudgetDialog } from "@/components/orcamentos/budget-dialog";
-import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
-import { Button } from "@/components/ui/button";
-import { Progress } from "@/components/ui/progress";
import type { GoalsProgressData } from "@/lib/dashboard/goals-progress";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { useGoalsProgressWidgetController } from "@/lib/dashboard/use-goals-progress-widget-controller";
+import { GoalsProgressWidgetView } from "./goals-progress/goals-progress-widget-view";
type GoalsProgressWidgetProps = {
data: GoalsProgressData;
};
-const clamp = (value: number, min: number, max: number) =>
- Math.min(max, Math.max(min, value));
-
-const formatPercentage = (value: number, withSign = false) =>
- `${new Intl.NumberFormat("pt-BR", {
- minimumFractionDigits: 0,
- maximumFractionDigits: 1,
- ...(withSign ? { signDisplay: "always" as const } : {}),
- }).format(value)}%`;
-
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
- const [editOpen, setEditOpen] = useState(false);
- const [selectedBudget, setSelectedBudget] = useState(null);
-
- const categories = useMemo(
- () =>
- data.categories.map((category) => ({
- id: category.id,
- name: category.name,
- icon: category.icon,
- })),
- [data.categories],
- );
-
- const defaultPeriod = data.items[0]?.period ?? "";
-
- const handleEdit = useCallback((item: GoalsProgressData["items"][number]) => {
- setSelectedBudget({
- id: item.id,
- amount: item.budgetAmount,
- spent: item.spentAmount,
- period: item.period,
- createdAt: item.createdAt,
- category: item.categoryId
- ? {
- id: item.categoryId,
- name: item.categoryName,
- icon: item.categoryIcon,
- }
- : null,
- });
- setEditOpen(true);
- }, []);
-
- const handleEditOpenChange = useCallback((open: boolean) => {
- setEditOpen(open);
- if (!open) {
- setSelectedBudget(null);
- }
- }, []);
-
- if (data.items.length === 0) {
- return (
- }
- title="Nenhum orçamento para o período"
- description="Cadastre orçamentos para acompanhar o progresso das metas."
- />
- );
- }
+ const {
+ selectedBudget,
+ editOpen,
+ categories,
+ defaultPeriod,
+ handleEdit,
+ handleEditOpenChange,
+ } = useGoalsProgressWidgetController(data);
return (
-
-
- {data.items.map((item, index) => {
- const statusColor =
- item.status === "exceeded" ? "text-destructive" : "";
- const progressValue = clamp(item.usedPercentage, 0, 100);
- const percentageDelta = item.usedPercentage - 100;
-
- return (
- -
-
-
-
-
-
- {item.categoryName}
-
-
- de{" "}
-
-
-
-
-
-
-
- {formatPercentage(percentageDelta, true)}
-
- handleEdit(item)}
- aria-label={`Editar orçamento de ${item.categoryName}`}
- >
-
-
-
-
-
-
-
-
- );
- })}
-
-
-
-
+
);
}
diff --git a/components/dashboard/goals-progress/goal-progress-item.tsx b/components/dashboard/goals-progress/goal-progress-item.tsx
new file mode 100644
index 0000000..eb3b203
--- /dev/null
+++ b/components/dashboard/goals-progress/goal-progress-item.tsx
@@ -0,0 +1,70 @@
+import { RiPencilLine } from "@remixicon/react";
+import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
+import MoneyValues from "@/components/shared/money-values";
+import { Button } from "@/components/ui/button";
+import { Progress } from "@/components/ui/progress";
+import type { GoalProgressItem as GoalProgressItemData } from "@/lib/dashboard/goals-progress";
+import {
+ clampGoalProgress,
+ formatGoalProgressPercentage,
+ getGoalProgressStatusColorClass,
+} from "@/lib/dashboard/goals-progress-helpers";
+
+type GoalProgressItemProps = {
+ item: GoalProgressItemData;
+ index: number;
+ onEdit: (item: GoalProgressItemData) => void;
+};
+
+export function GoalProgressItem({
+ item,
+ index,
+ onEdit,
+}: GoalProgressItemProps) {
+ const statusColor = getGoalProgressStatusColorClass(item.status);
+ const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
+ const percentageDelta = item.usedPercentage - 100;
+
+ return (
+
+
+
+
+
+
+ {item.categoryName}
+
+
+ de{" "}
+
+
+
+
+
+
+
+ {formatGoalProgressPercentage(percentageDelta, true)}
+
+ onEdit(item)}
+ aria-label={`Editar orçamento de ${item.categoryName}`}
+ >
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/dashboard/goals-progress/goals-progress-list.tsx b/components/dashboard/goals-progress/goals-progress-list.tsx
new file mode 100644
index 0000000..66188b5
--- /dev/null
+++ b/components/dashboard/goals-progress/goals-progress-list.tsx
@@ -0,0 +1,34 @@
+import { RiFundsLine } from "@remixicon/react";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
+import type { GoalProgressItem } from "@/lib/dashboard/goals-progress";
+import { GoalProgressItem as GoalProgressListItem } from "./goal-progress-item";
+
+type GoalsProgressListProps = {
+ items: GoalProgressItem[];
+ onEdit: (item: GoalProgressItem) => void;
+};
+
+export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) {
+ if (items.length === 0) {
+ return (
+ }
+ title="Nenhum orçamento para o período"
+ description="Cadastre orçamentos para acompanhar o progresso das metas."
+ />
+ );
+ }
+
+ return (
+
+ {items.map((item, index) => (
+
+ ))}
+
+ );
+}
diff --git a/components/dashboard/goals-progress/goals-progress-widget-dialogs.tsx b/components/dashboard/goals-progress/goals-progress-widget-dialogs.tsx
new file mode 100644
index 0000000..12c2917
--- /dev/null
+++ b/components/dashboard/goals-progress/goals-progress-widget-dialogs.tsx
@@ -0,0 +1,29 @@
+import { BudgetDialog } from "@/components/orcamentos/budget-dialog";
+import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
+
+type GoalsProgressWidgetDialogsProps = {
+ selectedBudget: Budget | null;
+ editOpen: boolean;
+ categories: BudgetCategory[];
+ defaultPeriod: string;
+ onEditOpenChange: (open: boolean) => void;
+};
+
+export function GoalsProgressWidgetDialogs({
+ selectedBudget,
+ editOpen,
+ categories,
+ defaultPeriod,
+ onEditOpenChange,
+}: GoalsProgressWidgetDialogsProps) {
+ return (
+
+ );
+}
diff --git a/components/dashboard/goals-progress/goals-progress-widget-view.tsx b/components/dashboard/goals-progress/goals-progress-widget-view.tsx
new file mode 100644
index 0000000..1a711c5
--- /dev/null
+++ b/components/dashboard/goals-progress/goals-progress-widget-view.tsx
@@ -0,0 +1,41 @@
+import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
+import type {
+ GoalProgressItem,
+ GoalsProgressData,
+} from "@/lib/dashboard/goals-progress";
+import { GoalsProgressList } from "./goals-progress-list";
+import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";
+
+type GoalsProgressWidgetViewProps = {
+ data: GoalsProgressData;
+ selectedBudget: Budget | null;
+ editOpen: boolean;
+ categories: BudgetCategory[];
+ defaultPeriod: string;
+ onEdit: (item: GoalProgressItem) => void;
+ onEditOpenChange: (open: boolean) => void;
+};
+
+export function GoalsProgressWidgetView({
+ data,
+ selectedBudget,
+ editOpen,
+ categories,
+ defaultPeriod,
+ onEdit,
+ onEditOpenChange,
+}: GoalsProgressWidgetViewProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/components/dashboard/income-by-category-widget-with-chart.tsx b/components/dashboard/income-by-category-widget-with-chart.tsx
index 2411018..d797c52 100644
--- a/components/dashboard/income-by-category-widget-with-chart.tsx
+++ b/components/dashboard/income-by-category-widget-with-chart.tsx
@@ -1,331 +1,18 @@
"use client";
-import {
- RiArrowDownSFill,
- RiArrowUpSFill,
- RiExternalLinkLine,
- RiListUnordered,
- RiPieChart2Line,
- RiPieChartLine,
- RiWallet3Line,
-} from "@remixicon/react";
-import Link from "next/link";
-import { useMemo, useState } from "react";
-import { Pie, PieChart, Tooltip } from "recharts";
-import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
-import MoneyValues from "@/components/money-values";
-import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
-import { formatPeriodForUrl } from "@/lib/utils/period";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
type IncomeByCategoryWidgetWithChartProps = {
data: IncomeByCategoryData;
period: string;
};
-const formatPercentage = (value: number) => {
- return `${Math.abs(value).toFixed(1)}%`;
-};
-
-const formatCurrency = (value: number) =>
- new Intl.NumberFormat("pt-BR", {
- style: "currency",
- currency: "BRL",
- }).format(value);
-
export function IncomeByCategoryWidgetWithChart({
data,
period,
}: IncomeByCategoryWidgetWithChartProps) {
- const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
- const periodParam = formatPeriodForUrl(period);
-
- // Configuração do chart com cores do CSS
- const chartConfig = useMemo(() => {
- const config: ChartConfig = {};
- const colors = [
- "var(--chart-1)",
- "var(--chart-2)",
- "var(--chart-3)",
- "var(--chart-4)",
- "var(--chart-5)",
- "var(--chart-1)",
- "var(--chart-2)",
- ];
-
- if (data.categories.length <= 7) {
- data.categories.forEach((category, index) => {
- config[category.categoryId] = {
- label: category.categoryName,
- color: colors[index % colors.length],
- };
- });
- } else {
- // Top 7 + Outros
- const top7 = data.categories.slice(0, 7);
- top7.forEach((category, index) => {
- config[category.categoryId] = {
- label: category.categoryName,
- color: colors[index % colors.length],
- };
- });
- config.outros = {
- label: "Outros",
- color: "var(--chart-6)",
- };
- }
-
- return config;
- }, [data.categories]);
-
- // Preparar dados para o gráfico de pizza - Top 7 + Outros
- const chartData = useMemo(() => {
- if (data.categories.length <= 7) {
- return data.categories.map((category) => ({
- category: category.categoryId,
- name: category.categoryName,
- value: category.currentAmount,
- percentage: category.percentageOfTotal,
- fill: chartConfig[category.categoryId]?.color,
- }));
- }
-
- // Pegar top 7 categorias
- const top7 = data.categories.slice(0, 7);
- const others = data.categories.slice(7);
-
- // Somar o restante
- const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
- const othersPercentage = others.reduce(
- (sum, cat) => sum + cat.percentageOfTotal,
- 0,
- );
-
- const top7Data = top7.map((category) => ({
- category: category.categoryId,
- name: category.categoryName,
- value: category.currentAmount,
- percentage: category.percentageOfTotal,
- fill: chartConfig[category.categoryId]?.color,
- }));
-
- // Adicionar "Outros" se houver
- if (others.length > 0) {
- top7Data.push({
- category: "outros",
- name: "Outros",
- value: othersTotal,
- percentage: othersPercentage,
- fill: chartConfig.outros?.color,
- });
- }
-
- return top7Data;
- }, [data.categories, chartConfig]);
-
- if (data.categories.length === 0) {
- return (
- }
- title="Nenhuma receita encontrada"
- description="Quando houver receitas registradas, elas aparecerão aqui."
- />
- );
- }
-
return (
- setActiveTab(v as "list" | "chart")}
- className="w-full"
- >
-
-
-
-
- Lista
-
-
-
- Gráfico
-
-
-
-
-
-
- {data.categories.map((category, index) => {
- const hasIncrease =
- category.percentageChange !== null &&
- category.percentageChange > 0;
- const hasDecrease =
- category.percentageChange !== null &&
- category.percentageChange < 0;
- const hasBudget = category.budgetAmount !== null;
- const budgetExceeded =
- hasBudget &&
- category.budgetUsedPercentage !== null &&
- category.budgetUsedPercentage > 100;
-
- const exceededAmount =
- budgetExceeded && category.budgetAmount
- ? category.currentAmount - category.budgetAmount
- : 0;
-
- return (
-
-
-
-
-
-
-
-
-
- {category.categoryName}
-
-
-
-
-
-
- {formatPercentage(category.percentageOfTotal)} da
- receita total
-
-
-
-
-
-
-
- {category.percentageChange !== null && (
-
- {hasIncrease && }
- {hasDecrease && }
- {formatPercentage(category.percentageChange)}
-
- )}
-
-
-
- {hasBudget &&
- category.budgetUsedPercentage !== null &&
- category.budgetAmount !== null && (
-
-
-
- {budgetExceeded ? (
- <>
- {formatPercentage(category.budgetUsedPercentage)} do
- limite {formatCurrency(category.budgetAmount)} -
- excedeu em {formatCurrency(exceededAmount)}
- >
- ) : (
- <>
- {formatPercentage(category.budgetUsedPercentage)} do
- limite {formatCurrency(category.budgetAmount)}
- >
- )}
-
-
- )}
-
- );
- })}
-
-
-
-
-
-
-
- formatPercentage(entry.percentage)}
- outerRadius={75}
- dataKey="value"
- nameKey="category"
- />
- {
- if (active && payload && payload.length) {
- const data = payload[0].payload;
- return (
-
-
-
-
- {data.name}
-
-
- {formatCurrency(data.value)}
-
-
- {formatPercentage(data.percentage)} do total
-
-
-
-
- );
- }
- return null;
- }}
- />
-
-
-
-
- {chartData.map((entry, index) => (
-
-
-
- {entry.name}
-
-
- ))}
-
-
-
-
+
);
}
diff --git a/components/dashboard/income-expense-balance-widget.tsx b/components/dashboard/income-expense-balance-widget.tsx
index 9548225..275cfb5 100644
--- a/components/dashboard/income-expense-balance-widget.tsx
+++ b/components/dashboard/income-expense-balance-widget.tsx
@@ -2,14 +2,15 @@
import { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { CardContent } from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart";
-import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { IncomeExpenseBalanceData } from "@/lib/dashboard/income-expense-balance";
+import { formatCurrency } from "@/lib/utils/currency";
type IncomeExpenseBalanceWidgetProps = {
data: IncomeExpenseBalanceData;
@@ -80,15 +81,6 @@ export function IncomeExpenseBalanceWidget({
return null;
}
- const formatCurrency = (value: number) => {
- return new Intl.NumberFormat("pt-BR", {
- style: "currency",
- currency: "BRL",
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- }).format(value);
- };
-
return (
@@ -103,7 +95,7 @@ export function IncomeExpenseBalanceWidget({
className="flex items-center gap-2"
>
@@ -144,7 +136,7 @@ export function IncomeExpenseBalanceWidget({
@@ -153,7 +145,7 @@ export function IncomeExpenseBalanceWidget({
@@ -162,7 +154,7 @@ export function IncomeExpenseBalanceWidget({
diff --git a/components/dashboard/installment-analysis/installment-analysis-page.tsx b/components/dashboard/installment-analysis/installment-analysis-page.tsx
index 0cb6d3d..514a2ec 100644
--- a/components/dashboard/installment-analysis/installment-analysis-page.tsx
+++ b/components/dashboard/installment-analysis/installment-analysis-page.tsx
@@ -6,7 +6,7 @@ import {
RiCheckboxLine,
} from "@remixicon/react";
import { useMemo, useState } from "react";
-import MoneyValues from "@/components/money-values";
+import MoneyValues from "@/components/shared/money-values";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { InstallmentGroupCard } from "./installment-group-card";
diff --git a/components/dashboard/installment-analysis/installment-group-card.tsx b/components/dashboard/installment-analysis/installment-group-card.tsx
index f17ae7a..429a1cc 100644
--- a/components/dashboard/installment-analysis/installment-group-card.tsx
+++ b/components/dashboard/installment-analysis/installment-group-card.tsx
@@ -8,7 +8,7 @@ import {
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
-import MoneyValues from "@/components/money-values";
+import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
diff --git a/components/dashboard/installment-expenses-widget.tsx b/components/dashboard/installment-expenses-widget.tsx
index fc46ea9..c5d4be3 100644
--- a/components/dashboard/installment-expenses-widget.tsx
+++ b/components/dashboard/installment-expenses-widget.tsx
@@ -1,191 +1,12 @@
-import { RiNumbersLine } from "@remixicon/react";
-import Image from "next/image";
-import MoneyValues from "@/components/money-values";
-import { CardContent } from "@/components/ui/card";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
-import {
- calculateLastInstallmentDate,
- formatLastInstallmentDate,
-} from "@/lib/installments/utils";
-import { Progress } from "../ui/progress";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { InstallmentExpensesWidgetView } from "./installment-expenses/installment-expenses-widget-view";
type InstallmentExpensesWidgetProps = {
data: InstallmentExpensesData;
};
-const buildCompactInstallmentLabel = (
- currentInstallment: number | null,
- installmentCount: number | null,
-) => {
- if (currentInstallment && installmentCount) {
- return `${currentInstallment} de ${installmentCount}`;
- }
- return null;
-};
-
-const isLastInstallment = (
- currentInstallment: number | null,
- installmentCount: number | null,
-) => {
- if (!currentInstallment || !installmentCount) return false;
- return currentInstallment === installmentCount && installmentCount > 1;
-};
-
-const calculateRemainingInstallments = (
- currentInstallment: number | null,
- installmentCount: number | null,
-) => {
- if (!currentInstallment || !installmentCount) return 0;
- return Math.max(0, installmentCount - currentInstallment);
-};
-
-const calculateRemainingAmount = (
- amount: number,
- currentInstallment: number | null,
- installmentCount: number | null,
-) => {
- const remaining = calculateRemainingInstallments(
- currentInstallment,
- installmentCount,
- );
- return amount * remaining;
-};
-
-const formatEndDate = (
- period: string,
- currentInstallment: number | null,
- installmentCount: number | null,
-) => {
- if (!currentInstallment || !installmentCount) return null;
-
- const lastDate = calculateLastInstallmentDate(
- period,
- currentInstallment,
- installmentCount,
- );
-
- return formatLastInstallmentDate(lastDate);
-};
-
-const buildProgress = (
- currentInstallment: number | null,
- installmentCount: number | null,
-) => {
- if (!currentInstallment || !installmentCount || installmentCount <= 0) {
- return 0;
- }
-
- return Math.min(
- 100,
- Math.max(0, (currentInstallment / installmentCount) * 100),
- );
-};
-
export function InstallmentExpensesWidget({
data,
}: InstallmentExpensesWidgetProps) {
- if (data.expenses.length === 0) {
- return (
- }
- title="Nenhuma despesa parcelada"
- description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
- />
- );
- }
-
- return (
-
-
- {data.expenses.map((expense) => {
- const compactLabel = buildCompactInstallmentLabel(
- expense.currentInstallment,
- expense.installmentCount,
- );
- const isLast = isLastInstallment(
- expense.currentInstallment,
- expense.installmentCount,
- );
- const remainingInstallments = calculateRemainingInstallments(
- expense.currentInstallment,
- expense.installmentCount,
- );
- const remainingAmount = calculateRemainingAmount(
- expense.amount,
- expense.currentInstallment,
- expense.installmentCount,
- );
- const endDate = formatEndDate(
- expense.period,
- expense.currentInstallment,
- expense.installmentCount,
- );
- const progress = buildProgress(
- expense.currentInstallment,
- expense.installmentCount,
- );
-
- return (
- -
-
-
-
-
- {expense.name}
-
- {compactLabel && (
-
- {compactLabel}
- {isLast && (
-
-
-
-
- Última parcela
-
-
-
- Última parcela!
-
-
- )}
-
- )}
-
-
-
-
-
- {endDate && `Termina em ${endDate}`}
- {" | Restante "}
- {" "}
- ({remainingInstallments})
-
-
-
-
-
- );
- })}
-
-
- );
+ return ;
}
diff --git a/components/dashboard/installment-expenses/installment-expense-list-item.tsx b/components/dashboard/installment-expenses/installment-expense-list-item.tsx
new file mode 100644
index 0000000..c931eea
--- /dev/null
+++ b/components/dashboard/installment-expenses/installment-expense-list-item.tsx
@@ -0,0 +1,76 @@
+import Image from "next/image";
+import MoneyValues from "@/components/shared/money-values";
+import { Progress } from "@/components/ui/progress";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
+import { buildInstallmentExpenseDisplay } from "@/lib/dashboard/installment-expenses-helpers";
+
+type InstallmentExpenseListItemProps = {
+ expense: InstallmentExpense;
+};
+
+export function InstallmentExpenseListItem({
+ expense,
+}: InstallmentExpenseListItemProps) {
+ const {
+ compactLabel,
+ isLast,
+ remainingInstallments,
+ remainingAmount,
+ endDate,
+ progress,
+ } = buildInstallmentExpenseDisplay(expense);
+
+ return (
+
+
+
+
+
+ {expense.name}
+
+ {compactLabel ? (
+
+ {compactLabel}
+ {isLast ? (
+
+
+
+
+ Última parcela
+
+
+ Última parcela!
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+ {endDate ? `Termina em ${endDate}` : null}
+ {" | Restante "}
+ {" "}
+ ({remainingInstallments})
+
+
+
+
+
+ );
+}
diff --git a/components/dashboard/installment-expenses/installment-expenses-list.tsx b/components/dashboard/installment-expenses/installment-expenses-list.tsx
new file mode 100644
index 0000000..8dbd28b
--- /dev/null
+++ b/components/dashboard/installment-expenses/installment-expenses-list.tsx
@@ -0,0 +1,30 @@
+import { RiNumbersLine } from "@remixicon/react";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
+import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
+import { InstallmentExpenseListItem } from "./installment-expense-list-item";
+
+type InstallmentExpensesListProps = {
+ expenses: InstallmentExpense[];
+};
+
+export function InstallmentExpensesList({
+ expenses,
+}: InstallmentExpensesListProps) {
+ if (expenses.length === 0) {
+ return (
+ }
+ title="Nenhuma despesa parcelada"
+ description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
+ />
+ );
+ }
+
+ return (
+
+ {expenses.map((expense) => (
+
+ ))}
+
+ );
+}
diff --git a/components/dashboard/installment-expenses/installment-expenses-widget-view.tsx b/components/dashboard/installment-expenses/installment-expenses-widget-view.tsx
new file mode 100644
index 0000000..0bd5eb3
--- /dev/null
+++ b/components/dashboard/installment-expenses/installment-expenses-widget-view.tsx
@@ -0,0 +1,16 @@
+import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
+import { InstallmentExpensesList } from "./installment-expenses-list";
+
+type InstallmentExpensesWidgetViewProps = {
+ data: InstallmentExpensesData;
+};
+
+export function InstallmentExpensesWidgetView({
+ data,
+}: InstallmentExpensesWidgetViewProps) {
+ return (
+
+
+
+ );
+}
diff --git a/components/dashboard/invoices-widget.tsx b/components/dashboard/invoices-widget.tsx
index 22cee07..07bf676 100644
--- a/components/dashboard/invoices-widget.tsx
+++ b/components/dashboard/invoices-widget.tsx
@@ -1,584 +1,35 @@
"use client";
-import {
- RiBillLine,
- RiCheckboxCircleFill,
- RiCheckboxCircleLine,
- RiExternalLinkLine,
- RiLoader4Line,
- RiMoneyDollarCircleLine,
-} from "@remixicon/react";
-import Image from "next/image";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useEffect, useMemo, useState, useTransition } from "react";
-import { toast } from "sonner";
-import { updateInvoicePaymentStatusAction } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
-import MoneyValues from "@/components/money-values";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Button } from "@/components/ui/button";
-import { CardContent } from "@/components/ui/card";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogFooter as ModalFooter,
-} from "@/components/ui/dialog";
+
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
-import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas";
-import { getAvatarSrc } from "@/lib/pagadores/utils";
-import { formatPeriodForUrl } from "@/lib/utils/period";
-import { Badge } from "../ui/badge";
-import {
- HoverCard,
- HoverCardContent,
- HoverCardTrigger,
-} from "../ui/hover-card";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { useInvoicesWidgetController } from "@/lib/dashboard/use-invoices-widget-controller";
+import { InvoicesWidgetView } from "./invoices/invoices-widget-view";
type InvoicesWidgetProps = {
invoices: DashboardInvoice[];
};
-type ModalState = "idle" | "processing" | "success";
-
-const DUE_DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
- day: "2-digit",
- month: "short",
- year: "numeric",
- timeZone: "UTC",
-});
-
-const resolveLogoPath = (logo: string | null) => {
- if (!logo) {
- return null;
- }
- if (/^(https?:\/\/|data:)/.test(logo)) {
- return logo;
- }
- return logo.startsWith("/") ? logo : `/logos/${logo}`;
-};
-
-const buildInitials = (value: string) => {
- const parts = value.trim().split(/\s+/).filter(Boolean);
- if (parts.length === 0) {
- return "CC";
- }
- if (parts.length === 1) {
- const firstPart = parts[0];
- return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
- }
- const firstChar = parts[0]?.[0] ?? "";
- const secondChar = parts[1]?.[0] ?? "";
- return `${firstChar}${secondChar}`.toUpperCase() || "CC";
-};
-
-const parseDueDate = (period: string, dueDay: string) => {
- const [yearStr, monthStr] = period.split("-");
- const dayNumber = Number.parseInt(dueDay, 10);
- const year = Number.parseInt(yearStr ?? "", 10);
- const month = Number.parseInt(monthStr ?? "", 10);
-
- if (
- Number.isNaN(dayNumber) ||
- Number.isNaN(year) ||
- Number.isNaN(month) ||
- period.length !== 7
- ) {
- return {
- label: `Vence dia ${dueDay}`,
- date: null,
- };
- }
-
- const date = new Date(Date.UTC(year, month - 1, dayNumber));
- return {
- label: `Vence em ${DUE_DATE_FORMATTER.format(date)}`,
- date,
- };
-};
-
-const formatPaymentDate = (value: string | null) => {
- if (!value) {
- return null;
- }
-
- const [yearStr, monthStr, dayStr] = value.split("-");
- const year = Number.parseInt(yearStr ?? "", 10);
- const month = Number.parseInt(monthStr ?? "", 10);
- const day = Number.parseInt(dayStr ?? "", 10);
-
- if (
- Number.isNaN(year) ||
- Number.isNaN(month) ||
- Number.isNaN(day) ||
- yearStr?.length !== 4 ||
- monthStr?.length !== 2 ||
- dayStr?.length !== 2
- ) {
- return null;
- }
-
- const date = new Date(Date.UTC(year, month - 1, day));
- return {
- label: `Pago em ${DUE_DATE_FORMATTER.format(date)}`,
- };
-};
-
-const getTodayDateString = () => {
- const now = new Date();
- const year = now.getFullYear();
- const month = String(now.getMonth() + 1).padStart(2, "0");
- const day = String(now.getDate()).padStart(2, "0");
- return `${year}-${month}-${day}`;
-};
-
-const formatSharePercentage = (value: number) => {
- if (!Number.isFinite(value) || value <= 0) {
- return "0%";
- }
- const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
- return `${value.toLocaleString("pt-BR", {
- minimumFractionDigits: digits,
- maximumFractionDigits: digits,
- })}%`;
-};
-
-const getShareLabel = (amount: number, total: number) => {
- if (total <= 0) {
- return "0% do total";
- }
- const percentage = (amount / total) * 100;
- return `${formatSharePercentage(percentage)} do total`;
-};
-
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
- const router = useRouter();
- const [isPending, startTransition] = useTransition();
- const [items, setItems] = useState(invoices);
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [selectedId, setSelectedId] = useState(null);
- const [modalState, setModalState] = useState("idle");
-
- useEffect(() => {
- setItems(invoices);
- }, [invoices]);
-
- const selectedInvoice = useMemo(
- () => items.find((invoice) => invoice.id === selectedId) ?? null,
- [items, selectedId],
- );
-
- const selectedLogo = useMemo(
- () => (selectedInvoice ? resolveLogoPath(selectedInvoice.logo) : null),
- [selectedInvoice],
- );
-
- const selectedPaymentInfo = useMemo(
- () => (selectedInvoice ? formatPaymentDate(selectedInvoice.paidAt) : null),
- [selectedInvoice],
- );
-
- const handleOpenModal = (invoiceId: string) => {
- setSelectedId(invoiceId);
- setModalState("idle");
- setIsModalOpen(true);
- };
-
- const handleCloseModal = () => {
- setIsModalOpen(false);
- setModalState("idle");
- setSelectedId(null);
- };
-
- const handleConfirmPayment = () => {
- if (!selectedInvoice) {
- return;
- }
-
- setModalState("processing");
-
- startTransition(async () => {
- const result = await updateInvoicePaymentStatusAction({
- cartaoId: selectedInvoice.cardId,
- period: selectedInvoice.period,
- status: INVOICE_PAYMENT_STATUS.PAID,
- });
-
- if (result.success) {
- toast.success(result.message);
- setItems((previous) =>
- previous.map((invoice) =>
- invoice.id === selectedInvoice.id
- ? {
- ...invoice,
- paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
- paidAt: getTodayDateString(),
- }
- : invoice,
- ),
- );
- setModalState("success");
- router.refresh();
- return;
- }
-
- toast.error(result.error);
- setModalState("idle");
- });
- };
-
- const getStatusBadgeVariant = (status: string): "success" | "info" => {
- const normalizedStatus = status.toLowerCase();
- if (normalizedStatus === "em aberto") {
- return "info";
- }
- return "success";
- };
+ const {
+ items,
+ selectedInvoice,
+ isModalOpen,
+ modalState,
+ isPending,
+ openPaymentDialog,
+ closePaymentDialog,
+ confirmPayment,
+ } = useInvoicesWidgetController(invoices);
return (
- <>
-
- {items.length === 0 ? (
- }
- title="Nenhuma fatura para o período selecionado"
- description="Quando houver cartões com compras registradas, eles aparecerão aqui."
- />
- ) : (
-
- {items.map((invoice) => {
- const logo = resolveLogoPath(invoice.logo);
- const initials = buildInitials(invoice.cardName);
- const dueInfo = parseDueDate(invoice.period, invoice.dueDay);
- const isPaid =
- invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
- const isOverdue =
- !isPaid && dueInfo.date !== null && dueInfo.date < new Date();
- const paymentInfo = formatPaymentDate(invoice.paidAt);
-
- return (
- -
-
-
- {logo ? (
-
- ) : (
-
- {initials}
-
- )}
-
-
-
- {(() => {
- const breakdown = invoice.pagadorBreakdown ?? [];
- const hasBreakdown = breakdown.length > 0;
- const linkNode = (
-
- {invoice.cardName}
-
-
- );
-
- if (!hasBreakdown) {
- return linkNode;
- }
-
- const totalForShare = Math.abs(invoice.totalAmount);
-
- return (
-
-
- {linkNode}
-
-
-
- Distribuição por pagador
-
-
- {breakdown.map((share, index) => (
- -
-
-
-
- {buildInitials(share.pagadorName)}
-
-
-
-
- {share.pagadorName}
-
-
- {getShareLabel(
- share.amount,
- totalForShare,
- )}
-
-
-
-
-
-
- ))}
-
-
-
- );
- })()}
-
- {!isPaid ? {dueInfo.label} : null}
- {isPaid && paymentInfo ? (
-
- {paymentInfo.label}
-
- ) : null}
-
-
-
-
-
-
-
- handleOpenModal(invoice.id)}
- variant={"link"}
- className="p-0 h-auto disabled:opacity-100"
- >
- {isPaid ? (
-
- Pago
-
- ) : isOverdue ? (
-
-
- Atrasado
-
-
- Pagar
-
-
- ) : (
- Pagar
- )}
-
-
-
-
- );
- })}
-
- )}
-
-
-
- >
+
);
}
diff --git a/components/dashboard/invoices/invoice-list-item.tsx b/components/dashboard/invoices/invoice-list-item.tsx
new file mode 100644
index 0000000..0c4e11d
--- /dev/null
+++ b/components/dashboard/invoices/invoice-list-item.tsx
@@ -0,0 +1,148 @@
+import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
+import Link from "next/link";
+import MoneyValues from "@/components/shared/money-values";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import type { DashboardInvoice } from "@/lib/dashboard/invoices";
+import {
+ buildInvoiceDetailsHref,
+ buildInvoiceInitials,
+ formatInvoicePaymentDate,
+ getInvoiceShareLabel,
+ parseInvoiceDueDate,
+} from "@/lib/dashboard/invoices-helpers";
+import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
+import { getAvatarSrc } from "@/lib/pagadores/utils";
+import { isDateOnlyPast } from "@/lib/utils/date";
+import { InvoiceLogo } from "./invoice-logo";
+
+type InvoiceListItemProps = {
+ invoice: DashboardInvoice;
+ onPay: (invoiceId: string) => void;
+};
+
+export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
+ const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
+ const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
+ const isOverdue =
+ !isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
+ const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
+ const breakdown = invoice.pagadorBreakdown ?? [];
+ const hasBreakdown = breakdown.length > 0;
+ const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
+
+ const linkNode = (
+
+ {invoice.cardName}
+
+
+ );
+
+ return (
+
+
+
+
+
+ {hasBreakdown ? (
+
+ {linkNode}
+
+
+ Distribuição por pagador
+
+
+ {breakdown.map((share, index) => (
+ -
+
+
+
+ {buildInvoiceInitials(share.pagadorName)}
+
+
+
+
+ {share.pagadorName}
+
+
+ {getInvoiceShareLabel(
+ share.amount,
+ Math.abs(invoice.totalAmount),
+ )}
+
+
+
+
+
+
+ ))}
+
+
+
+ ) : (
+ linkNode
+ )}
+
+
+ {!isPaid ? {dueInfo.label} : null}
+ {isPaid && paymentInfo ? (
+ {paymentInfo.label}
+ ) : null}
+
+
+
+
+
+
+ onPay(invoice.id)}
+ >
+ {isPaid ? (
+
+ Pago
+
+ ) : isOverdue ? (
+
+
+ Atrasado
+
+ Pagar
+
+ ) : (
+ Pagar
+ )}
+
+
+
+ );
+}
diff --git a/components/dashboard/invoices/invoice-logo.tsx b/components/dashboard/invoices/invoice-logo.tsx
new file mode 100644
index 0000000..c270a51
--- /dev/null
+++ b/components/dashboard/invoices/invoice-logo.tsx
@@ -0,0 +1,59 @@
+import Image from "next/image";
+import {
+ buildInvoiceInitials,
+ type InvoiceLogoTone,
+} from "@/lib/dashboard/invoices-helpers";
+import { resolveLogoSrc } from "@/lib/logo";
+import { cn } from "@/lib/utils/ui";
+
+type InvoiceLogoProps = {
+ cardName: string;
+ logo: string | null;
+ size: number;
+ containerClassName?: string;
+ imageClassName?: string;
+ fallbackClassName?: string;
+ tone?: InvoiceLogoTone;
+};
+
+export function InvoiceLogo({
+ cardName,
+ logo,
+ size,
+ containerClassName,
+ imageClassName,
+ fallbackClassName,
+ tone = "muted",
+}: InvoiceLogoProps) {
+ const resolvedLogo = resolveLogoSrc(logo);
+
+ return (
+
+ {resolvedLogo ? (
+
+ ) : (
+
+ {buildInvoiceInitials(cardName)}
+
+ )}
+
+ );
+}
diff --git a/components/dashboard/invoices/invoice-payment-dialog.tsx b/components/dashboard/invoices/invoice-payment-dialog.tsx
new file mode 100644
index 0000000..8e732c1
--- /dev/null
+++ b/components/dashboard/invoices/invoice-payment-dialog.tsx
@@ -0,0 +1,203 @@
+import {
+ RiCheckboxCircleLine,
+ RiLoader4Line,
+ RiMoneyDollarCircleLine,
+} from "@remixicon/react";
+import MoneyValues from "@/components/shared/money-values";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import type { DashboardInvoice } from "@/lib/dashboard/invoices";
+import {
+ formatInvoicePaymentDate,
+ getInvoiceStatusBadgeVariant,
+ type InvoiceDialogState,
+ parseInvoiceDueDate,
+} from "@/lib/dashboard/invoices-helpers";
+import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas";
+import { InvoiceLogo } from "./invoice-logo";
+
+type InvoicePaymentDialogProps = {
+ invoice: DashboardInvoice | null;
+ open: boolean;
+ modalState: InvoiceDialogState;
+ isPending: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+};
+
+export function InvoicePaymentDialog({
+ invoice,
+ open,
+ modalState,
+ isPending,
+ onClose,
+ onConfirm,
+}: InvoicePaymentDialogProps) {
+ const isProcessing = modalState === "processing" || isPending;
+ const paymentInfo = invoice ? formatInvoicePaymentDate(invoice.paidAt) : null;
+
+ return (
+
+ );
+}
diff --git a/components/dashboard/invoices/invoices-list.tsx b/components/dashboard/invoices/invoices-list.tsx
new file mode 100644
index 0000000..cb9106d
--- /dev/null
+++ b/components/dashboard/invoices/invoices-list.tsx
@@ -0,0 +1,29 @@
+import { RiBillLine } from "@remixicon/react";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
+import type { DashboardInvoice } from "@/lib/dashboard/invoices";
+import { InvoiceListItem } from "./invoice-list-item";
+
+type InvoicesListProps = {
+ invoices: DashboardInvoice[];
+ onPay: (invoiceId: string) => void;
+};
+
+export function InvoicesList({ invoices, onPay }: InvoicesListProps) {
+ if (invoices.length === 0) {
+ return (
+ }
+ title="Nenhuma fatura para o período selecionado"
+ description="Quando houver cartões com compras registradas, eles aparecerão aqui."
+ />
+ );
+ }
+
+ return (
+
+ {invoices.map((invoice) => (
+
+ ))}
+
+ );
+}
diff --git a/components/dashboard/invoices/invoices-widget-view.tsx b/components/dashboard/invoices/invoices-widget-view.tsx
new file mode 100644
index 0000000..6623be6
--- /dev/null
+++ b/components/dashboard/invoices/invoices-widget-view.tsx
@@ -0,0 +1,43 @@
+import type { DashboardInvoice } from "@/lib/dashboard/invoices";
+import type { InvoiceDialogState } from "@/lib/dashboard/invoices-helpers";
+import { InvoicePaymentDialog } from "./invoice-payment-dialog";
+import { InvoicesList } from "./invoices-list";
+
+type InvoicesWidgetViewProps = {
+ invoices: DashboardInvoice[];
+ selectedInvoice: DashboardInvoice | null;
+ isModalOpen: boolean;
+ modalState: InvoiceDialogState;
+ isPending: boolean;
+ onOpenPaymentDialog: (invoiceId: string) => void;
+ onClosePaymentDialog: () => void;
+ onConfirmPayment: () => void;
+};
+
+export function InvoicesWidgetView({
+ invoices,
+ selectedInvoice,
+ isModalOpen,
+ modalState,
+ isPending,
+ onOpenPaymentDialog,
+ onClosePaymentDialog,
+ onConfirmPayment,
+}: InvoicesWidgetViewProps) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/components/dashboard/my-accounts-widget.tsx b/components/dashboard/my-accounts-widget.tsx
index 6ab3c4d..0b57ea4 100644
--- a/components/dashboard/my-accounts-widget.tsx
+++ b/components/dashboard/my-accounts-widget.tsx
@@ -1,62 +1,38 @@
import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
-import {
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
-} from "@/components/ui/card";
+import { CardFooter } from "@/components/ui/card";
import type { DashboardAccount } from "@/lib/dashboard/accounts";
+import { resolveLogoSrc } from "@/lib/logo";
import { formatPeriodForUrl } from "@/lib/utils/period";
-import MoneyValues from "../money-values";
-import { WidgetEmptyState } from "../widget-empty-state";
+import MoneyValues from "@/components/shared/money-values";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
type MyAccountsWidgetProps = {
accounts: DashboardAccount[];
totalBalance: number;
- maxVisible?: number;
period: string;
};
-const resolveLogoSrc = (logo: string | null) => {
- if (!logo) {
- return null;
- }
-
- const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
- return `/logos/${fileName}`;
-};
-
-const buildInitials = (name: string) => {
- const parts = name.trim().split(/\s+/).filter(Boolean);
- if (parts.length === 0) return "CC";
- if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
- return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
-};
-
export function MyAccountsWidget({
accounts,
totalBalance,
- maxVisible = 5,
period,
}: MyAccountsWidgetProps) {
const visibleAccounts = accounts.filter(
(account) => !account.excludeFromBalance,
);
- const displayedAccounts = visibleAccounts.slice(0, maxVisible);
+ const displayedAccounts = visibleAccounts.slice(0, 5);
const remainingCount = visibleAccounts.length - displayedAccounts.length;
return (
<>
-
- Saldo Total
-
-
-
-
+
+ Saldo Total
+
+
-
+
{displayedAccounts.length === 0 ? (
{displayedAccounts.map((account) => {
const logoSrc = resolveLogoSrc(account.logo);
- const initials = buildInitials(account.name);
return (
- {logoSrc ? (
-
-
-
- ) : (
-
- {initials}
-
- )}
+
+
+
)}
-
+
{visibleAccounts.length > displayedAccounts.length ? (
diff --git a/components/dashboard/notes-widget.tsx b/components/dashboard/notes-widget.tsx
index f982e81..accf53e 100644
--- a/components/dashboard/notes-widget.tsx
+++ b/components/dashboard/notes-widget.tsx
@@ -1,154 +1,37 @@
"use client";
-import { RiFileList2Line, RiPencilLine, RiTodoLine } from "@remixicon/react";
-import { useCallback, useMemo, useState } from "react";
-import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog";
-import { NoteDialog } from "@/components/anotacoes/note-dialog";
-import type { Note } from "@/components/anotacoes/types";
-import { Button } from "@/components/ui/button";
-import { CardContent } from "@/components/ui/card";
import type { DashboardNote } from "@/lib/dashboard/notes";
-import { Badge } from "../ui/badge";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { useNotesWidgetController } from "@/lib/dashboard/use-notes-widget-controller";
+import { NotesWidgetView } from "./notes/notes-widget-view";
type NotesWidgetProps = {
notes: DashboardNote[];
};
-const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
- day: "2-digit",
- month: "short",
- year: "numeric",
- timeZone: "UTC",
-});
-
-const buildDisplayTitle = (value: string) => {
- const trimmed = value.trim();
- return trimmed.length ? trimmed : "Anotação sem título";
-};
-
-const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
- id: note.id,
- title: note.title,
- description: note.description,
- type: note.type,
- tasks: note.tasks,
- arquivada: note.arquivada,
- createdAt: note.createdAt,
-});
-
-const getTasksSummary = (note: DashboardNote) => {
- if (note.type !== "tarefa") {
- return "Nota";
- }
-
- const tasks = note.tasks ?? [];
- const completed = tasks.filter((task) => task.completed).length;
- return `${completed}/${tasks.length} concluídas`;
-};
-
export function NotesWidget({ notes }: NotesWidgetProps) {
- const [noteToEdit, setNoteToEdit] = useState(null);
- const [isEditOpen, setIsEditOpen] = useState(false);
- const [noteDetails, setNoteDetails] = useState(null);
- const [isDetailsOpen, setIsDetailsOpen] = useState(false);
-
- const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]);
-
- const handleOpenEdit = useCallback((note: Note) => {
- setNoteToEdit(note);
- setIsEditOpen(true);
- }, []);
-
- const handleOpenDetails = useCallback((note: Note) => {
- setNoteDetails(note);
- setIsDetailsOpen(true);
- }, []);
-
- const handleEditOpenChange = useCallback((open: boolean) => {
- setIsEditOpen(open);
- if (!open) {
- setNoteToEdit(null);
- }
- }, []);
-
- const handleDetailsOpenChange = useCallback((open: boolean) => {
- setIsDetailsOpen(open);
- if (!open) {
- setNoteDetails(null);
- }
- }, []);
+ const {
+ mappedNotes,
+ noteToEdit,
+ isEditOpen,
+ noteDetails,
+ isDetailsOpen,
+ openEdit,
+ openDetails,
+ handleEditOpenChange,
+ handleDetailsOpenChange,
+ } = useNotesWidgetController(notes);
return (
- <>
-
- {mappedNotes.length === 0 ? (
- }
- title="Nenhuma anotação ativa"
- description="Crie anotações para acompanhar lembretes e tarefas financeiras."
- />
- ) : (
-
- {mappedNotes.map((note) => (
- -
-
-
- {buildDisplayTitle(note.title)}
-
-
-
- {getTasksSummary(note)}
-
-
- {DATE_FORMATTER.format(new Date(note.createdAt))}
-
-
-
-
-
- handleOpenEdit(note)}
- aria-label={`Editar anotação ${buildDisplayTitle(note.title)}`}
- >
-
-
- handleOpenDetails(note)}
- aria-label={`Ver detalhes da anotação ${buildDisplayTitle(
- note.title,
- )}`}
- >
-
-
-
-
- ))}
-
- )}
-
-
-
-
-
- >
+
);
}
diff --git a/components/dashboard/notes/note-list-item.tsx b/components/dashboard/notes/note-list-item.tsx
new file mode 100644
index 0000000..85aad79
--- /dev/null
+++ b/components/dashboard/notes/note-list-item.tsx
@@ -0,0 +1,65 @@
+import { RiFileList2Line, RiPencilLine } from "@remixicon/react";
+import type { Note } from "@/components/anotacoes/types";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ buildNoteDisplayTitle,
+ formatNoteCreatedAt,
+ getNoteTasksSummary,
+} from "@/lib/notes/formatters";
+
+type NoteListItemProps = {
+ note: Note;
+ onOpenEdit: (note: Note) => void;
+ onOpenDetails: (note: Note) => void;
+};
+
+export function NoteListItem({
+ note,
+ onOpenEdit,
+ onOpenDetails,
+}: NoteListItemProps) {
+ const displayTitle = buildNoteDisplayTitle(note.title);
+ const createdAtLabel = formatNoteCreatedAt(note.createdAt);
+
+ return (
+
+
+
+ {displayTitle}
+
+
+
+ {getNoteTasksSummary(note)}
+
+ {createdAtLabel ? (
+
+ {createdAtLabel}
+
+ ) : null}
+
+
+
+
+ onOpenEdit(note)}
+ aria-label={`Editar anotação ${displayTitle}`}
+ >
+
+
+ onOpenDetails(note)}
+ aria-label={`Ver detalhes da anotação ${displayTitle}`}
+ >
+
+
+
+
+ );
+}
diff --git a/components/dashboard/notes/notes-list.tsx b/components/dashboard/notes/notes-list.tsx
new file mode 100644
index 0000000..f72e832
--- /dev/null
+++ b/components/dashboard/notes/notes-list.tsx
@@ -0,0 +1,39 @@
+import { RiTodoLine } from "@remixicon/react";
+import type { Note } from "@/components/anotacoes/types";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
+import { NoteListItem } from "./note-list-item";
+
+type NotesListProps = {
+ notes: Note[];
+ onOpenEdit: (note: Note) => void;
+ onOpenDetails: (note: Note) => void;
+};
+
+export function NotesList({
+ notes,
+ onOpenEdit,
+ onOpenDetails,
+}: NotesListProps) {
+ if (notes.length === 0) {
+ return (
+ }
+ title="Nenhuma anotação ativa"
+ description="Crie anotações para acompanhar lembretes e tarefas financeiras."
+ />
+ );
+ }
+
+ return (
+
+ {notes.map((note) => (
+
+ ))}
+
+ );
+}
diff --git a/components/dashboard/notes/notes-widget-dialogs.tsx b/components/dashboard/notes/notes-widget-dialogs.tsx
new file mode 100644
index 0000000..e0c7686
--- /dev/null
+++ b/components/dashboard/notes/notes-widget-dialogs.tsx
@@ -0,0 +1,38 @@
+import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog";
+import { NoteDialog } from "@/components/anotacoes/note-dialog";
+import type { Note } from "@/components/anotacoes/types";
+
+type NotesWidgetDialogsProps = {
+ noteToEdit: Note | null;
+ isEditOpen: boolean;
+ noteDetails: Note | null;
+ isDetailsOpen: boolean;
+ onEditOpenChange: (open: boolean) => void;
+ onDetailsOpenChange: (open: boolean) => void;
+};
+
+export function NotesWidgetDialogs({
+ noteToEdit,
+ isEditOpen,
+ noteDetails,
+ isDetailsOpen,
+ onEditOpenChange,
+ onDetailsOpenChange,
+}: NotesWidgetDialogsProps) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/components/dashboard/notes/notes-widget-view.tsx b/components/dashboard/notes/notes-widget-view.tsx
new file mode 100644
index 0000000..07c67f3
--- /dev/null
+++ b/components/dashboard/notes/notes-widget-view.tsx
@@ -0,0 +1,48 @@
+import type { Note } from "@/components/anotacoes/types";
+import { NotesList } from "./notes-list";
+import { NotesWidgetDialogs } from "./notes-widget-dialogs";
+
+type NotesWidgetViewProps = {
+ notes: Note[];
+ noteToEdit: Note | null;
+ isEditOpen: boolean;
+ noteDetails: Note | null;
+ isDetailsOpen: boolean;
+ onOpenEdit: (note: Note) => void;
+ onOpenDetails: (note: Note) => void;
+ onEditOpenChange: (open: boolean) => void;
+ onDetailsOpenChange: (open: boolean) => void;
+};
+
+export function NotesWidgetView({
+ notes,
+ noteToEdit,
+ isEditOpen,
+ noteDetails,
+ isDetailsOpen,
+ onOpenEdit,
+ onOpenDetails,
+ onEditOpenChange,
+ onDetailsOpenChange,
+}: NotesWidgetViewProps) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/components/dashboard/pagadores-widget.tsx b/components/dashboard/payers-widget.tsx
similarity index 92%
rename from components/dashboard/pagadores-widget.tsx
rename to components/dashboard/payers-widget.tsx
index afe22fa..30154ac 100644
--- a/components/dashboard/pagadores-widget.tsx
+++ b/components/dashboard/payers-widget.tsx
@@ -8,21 +8,18 @@ import {
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Link from "next/link";
-import MoneyValues from "@/components/money-values";
+import MoneyValues from "@/components/shared/money-values";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CardContent } from "@/components/ui/card";
import type { DashboardPagador } from "@/lib/dashboard/pagadores";
import { getAvatarSrc } from "@/lib/pagadores/utils";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { formatPercentage } from "@/lib/utils/percentage";
-type PagadoresWidgetProps = {
+type PayersWidgetProps = {
pagadores: DashboardPagador[];
};
-const formatPercentage = (value: number) => {
- return `${Math.abs(value).toFixed(0)}%`;
-};
-
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
@@ -37,7 +34,7 @@ const buildInitials = (value: string) => {
return `${firstChar}${secondChar}`.toUpperCase() || "??";
};
-export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
+export function PayersWidget({ pagadores }: PayersWidgetProps) {
return (
{pagadores.length === 0 ? (
diff --git a/components/dashboard/payment-conditions-widget.tsx b/components/dashboard/payment-conditions-widget.tsx
deleted file mode 100644
index 558180f..0000000
--- a/components/dashboard/payment-conditions-widget.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import {
- RiCheckLine,
- RiLoader2Fill,
- RiRefreshLine,
- RiSlideshowLine,
-} from "@remixicon/react";
-import type { ReactNode } from "react";
-import MoneyValues from "@/components/money-values";
-import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
-import { Progress } from "../ui/progress";
-import { WidgetEmptyState } from "../widget-empty-state";
-
-type PaymentConditionsWidgetProps = {
- data: PaymentConditionsData;
-};
-
-const CONDITION_ICON_CLASSES =
- "flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
-
-const CONDITION_ICONS: Record = {
- "À vista": ,
- Parcelado: ,
- Recorrente: ,
-};
-
-const formatPercentage = (value: number) =>
- new Intl.NumberFormat("pt-BR", {
- minimumFractionDigits: 0,
- maximumFractionDigits: 1,
- }).format(value);
-
-export function PaymentConditionsWidget({
- data,
-}: PaymentConditionsWidgetProps) {
- if (data.conditions.length === 0) {
- return (
- }
- title="Nenhuma despesa encontrada"
- description="As distribuições por condição aparecerão conforme novos lançamentos."
- />
- );
- }
-
- return (
-
-
- {data.conditions.map((condition) => {
- const Icon =
- CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"];
- const percentageLabel = formatPercentage(condition.percentage);
-
- return (
- -
- {Icon}
-
-
-
-
- {condition.condition}
-
-
-
-
-
-
- {condition.transactions}{" "}
- {condition.transactions === 1
- ? "lançamento"
- : "lançamentos"}
-
- {percentageLabel}%
-
-
-
-
-
-
-
- );
- })}
-
-
- );
-}
diff --git a/components/dashboard/payment-methods-widget.tsx b/components/dashboard/payment-methods-widget.tsx
deleted file mode 100644
index b894d05..0000000
--- a/components/dashboard/payment-methods-widget.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
-import MoneyValues from "@/components/money-values";
-import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
-import { getPaymentMethodIcon } from "@/lib/utils/icons";
-import { Progress } from "../ui/progress";
-import { WidgetEmptyState } from "../widget-empty-state";
-
-type PaymentMethodsWidgetProps = {
- data: PaymentMethodsData;
-};
-
-const ICON_WRAPPER_CLASS =
- "flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
-
-const formatPercentage = (value: number) =>
- new Intl.NumberFormat("pt-BR", {
- minimumFractionDigits: 0,
- maximumFractionDigits: 1,
- }).format(value);
-
-const resolveIcon = (paymentMethod: string | null | undefined) => {
- if (!paymentMethod) {
- return ;
- }
-
- const icon = getPaymentMethodIcon(paymentMethod);
- if (icon) {
- return icon;
- }
-
- return ;
-};
-
-export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
- if (data.methods.length === 0) {
- return (
-
- }
- title="Nenhuma despesa encontrada"
- description="Cadastre despesas para visualizar a distribuição por forma de pagamento."
- />
- );
- }
-
- return (
-
-
- {data.methods.map((method) => {
- const icon = resolveIcon(method.paymentMethod);
- const percentageLabel = formatPercentage(method.percentage);
-
- return (
- -
- {icon}
-
-
-
-
- {method.paymentMethod}
-
-
-
-
-
-
- {method.transactions}{" "}
- {method.transactions === 1 ? "lançamento" : "lançamentos"}
-
- {percentageLabel}%
-
-
-
-
-
-
-
- );
- })}
-
-
- );
-}
diff --git a/components/dashboard/payment-overview-widget.tsx b/components/dashboard/payment-overview-widget.tsx
index e9bd20c..7e69e46 100644
--- a/components/dashboard/payment-overview-widget.tsx
+++ b/components/dashboard/payment-overview-widget.tsx
@@ -1,12 +1,9 @@
"use client";
-import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
-import { useState } from "react";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
-import { PaymentConditionsWidget } from "./payment-conditions-widget";
-import { PaymentMethodsWidget } from "./payment-methods-widget";
+import { usePaymentOverviewWidgetController } from "@/lib/dashboard/use-payment-overview-widget-controller";
+import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-widget-view";
type PaymentOverviewWidgetProps = {
paymentConditionsData: PaymentConditionsData;
@@ -17,34 +14,14 @@ export function PaymentOverviewWidget({
paymentConditionsData,
paymentMethodsData,
}: PaymentOverviewWidgetProps) {
- const [activeTab, setActiveTab] = useState<"conditions" | "methods">(
- "conditions",
- );
+ const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
return (
- setActiveTab(value as "conditions" | "methods")}
- className="w-full"
- >
-
-
-
- Condições
-
-
-
- Formas
-
-
-
-
-
-
-
-
-
-
-
+
);
}
diff --git a/components/dashboard/payment-overview/payment-breakdown-list-item.tsx b/components/dashboard/payment-overview/payment-breakdown-list-item.tsx
new file mode 100644
index 0000000..b50cde9
--- /dev/null
+++ b/components/dashboard/payment-overview/payment-breakdown-list-item.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from "react";
+import MoneyValues from "@/components/shared/money-values";
+import { Progress } from "@/components/ui/progress";
+import {
+ formatPaymentBreakdownPercentage,
+ formatPaymentBreakdownTransactionsLabel,
+} from "@/lib/dashboard/payment-breakdown-formatters";
+
+const ICON_WRAPPER_CLASS =
+ "flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
+
+export type PaymentBreakdownListItemData = {
+ id: string;
+ title: string;
+ icon: ReactNode;
+ amount: number;
+ transactions: number;
+ percentage: number;
+};
+
+type PaymentBreakdownListItemProps = {
+ item: PaymentBreakdownListItemData;
+};
+
+export function PaymentBreakdownListItem({
+ item,
+}: PaymentBreakdownListItemProps) {
+ return (
+
+ {item.icon}
+
+
+
+ {item.title}
+
+
+
+
+
+ {formatPaymentBreakdownTransactionsLabel(item.transactions)}
+
+ {formatPaymentBreakdownPercentage(item.percentage)}
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/dashboard/payment-overview/payment-breakdown-list.tsx b/components/dashboard/payment-overview/payment-breakdown-list.tsx
new file mode 100644
index 0000000..edd4dd2
--- /dev/null
+++ b/components/dashboard/payment-overview/payment-breakdown-list.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from "react";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
+import {
+ PaymentBreakdownListItem,
+ type PaymentBreakdownListItemData,
+} from "./payment-breakdown-list-item";
+
+export type { PaymentBreakdownListItemData } from "./payment-breakdown-list-item";
+
+type PaymentBreakdownListProps = {
+ items: PaymentBreakdownListItemData[];
+ emptyIcon: ReactNode;
+ emptyTitle: string;
+ emptyDescription: string;
+};
+
+export function PaymentBreakdownList({
+ items,
+ emptyIcon,
+ emptyTitle,
+ emptyDescription,
+}: PaymentBreakdownListProps) {
+ if (items.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
diff --git a/components/dashboard/payment-overview/payment-conditions-widget.tsx b/components/dashboard/payment-overview/payment-conditions-widget.tsx
new file mode 100644
index 0000000..efb3c63
--- /dev/null
+++ b/components/dashboard/payment-overview/payment-conditions-widget.tsx
@@ -0,0 +1,38 @@
+import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
+import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
+import { getConditionIcon } from "@/lib/utils/icons";
+import {
+ PaymentBreakdownList,
+ type PaymentBreakdownListItemData,
+} from "./payment-breakdown-list";
+
+type PaymentConditionsWidgetProps = {
+ data: PaymentConditionsData;
+};
+
+const resolveConditionIcon = (condition: string) =>
+ getConditionIcon(condition) ?? ;
+
+export function PaymentConditionsWidget({
+ data,
+}: PaymentConditionsWidgetProps) {
+ const items: PaymentBreakdownListItemData[] = data.conditions.map(
+ (condition) => ({
+ id: condition.condition,
+ title: condition.condition,
+ icon: resolveConditionIcon(condition.condition),
+ amount: condition.amount,
+ transactions: condition.transactions,
+ percentage: condition.percentage,
+ }),
+ );
+
+ return (
+ }
+ emptyTitle="Nenhuma despesa encontrada"
+ emptyDescription="As distribuições por condição aparecerão conforme novos lançamentos."
+ />
+ );
+}
diff --git a/components/dashboard/payment-overview/payment-methods-widget.tsx b/components/dashboard/payment-overview/payment-methods-widget.tsx
new file mode 100644
index 0000000..83769bd
--- /dev/null
+++ b/components/dashboard/payment-overview/payment-methods-widget.tsx
@@ -0,0 +1,38 @@
+import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
+import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
+import { getPaymentMethodIcon } from "@/lib/utils/icons";
+import {
+ PaymentBreakdownList,
+ type PaymentBreakdownListItemData,
+} from "./payment-breakdown-list";
+
+type PaymentMethodsWidgetProps = {
+ data: PaymentMethodsData;
+};
+
+const resolvePaymentMethodIcon = (paymentMethod: string) =>
+ getPaymentMethodIcon(paymentMethod) ?? (
+
+ );
+
+export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
+ const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({
+ id: method.paymentMethod,
+ title: method.paymentMethod,
+ icon: resolvePaymentMethodIcon(method.paymentMethod),
+ amount: method.amount,
+ transactions: method.transactions,
+ percentage: method.percentage,
+ }));
+
+ return (
+
+ }
+ emptyTitle="Nenhuma despesa encontrada"
+ emptyDescription="Cadastre despesas para visualizar a distribuição por forma de pagamento."
+ />
+ );
+}
diff --git a/components/dashboard/payment-overview/payment-overview-widget-view.tsx b/components/dashboard/payment-overview/payment-overview-widget-view.tsx
new file mode 100644
index 0000000..59328e5
--- /dev/null
+++ b/components/dashboard/payment-overview/payment-overview-widget-view.tsx
@@ -0,0 +1,44 @@
+import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { PaymentOverviewTab } from "@/lib/dashboard/payment-overview-tabs";
+import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
+import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
+import { PaymentConditionsWidget } from "./payment-conditions-widget";
+import { PaymentMethodsWidget } from "./payment-methods-widget";
+
+type PaymentOverviewWidgetViewProps = {
+ activeTab: PaymentOverviewTab;
+ paymentConditionsData: PaymentConditionsData;
+ paymentMethodsData: PaymentMethodsData;
+ onTabChange: (value: string) => void;
+};
+
+export function PaymentOverviewWidgetView({
+ activeTab,
+ paymentConditionsData,
+ paymentMethodsData,
+ onTabChange,
+}: PaymentOverviewWidgetViewProps) {
+ return (
+
+
+
+
+ Condições
+
+
+
+ Formas
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/dashboard/payment-status-widget.tsx b/components/dashboard/payment-status-widget.tsx
index 6a9fba5..622a031 100644
--- a/components/dashboard/payment-status-widget.tsx
+++ b/components/dashboard/payment-status-widget.tsx
@@ -1,103 +1,12 @@
"use client";
-import {
- RiCheckboxCircleLine,
- RiHourglass2Line,
- RiWallet3Line,
-} from "@remixicon/react";
-import MoneyValues from "@/components/money-values";
-import { CardContent } from "@/components/ui/card";
-import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status";
-import { Progress } from "../ui/progress";
+import { PaymentStatusWidgetView } from "./payment-status/payment-status-widget-view";
type PaymentStatusWidgetProps = {
data: PaymentStatusData;
};
-type CategorySectionProps = {
- title: string;
- total: number;
- confirmed: number;
- pending: number;
-};
-
-function CategorySection({
- title,
- total,
- confirmed,
- pending,
-}: CategorySectionProps) {
- // Usa valores absolutos para calcular percentual corretamente
- const absTotal = Math.abs(total);
- const absConfirmed = Math.abs(confirmed);
- const confirmedPercentage =
- absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
-
- return (
-
-
- {title}
-
-
-
- {/* Barra de progresso */}
-
-
- {/* Status de confirmados e pendentes */}
-
-
-
-
- confirmados
-
-
-
-
-
- pendentes
-
-
-
- );
-}
-
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
- const isEmpty = data.income.total === 0 && data.expenses.total === 0;
-
- if (isEmpty) {
- return (
-
- }
- title="Nenhum valor a receber ou pagar no período"
- description="Registre lançamentos para visualizar os valores confirmados e pendentes."
- />
-
- );
- }
-
- return (
-
-
-
- {/* Linha divisória pontilhada */}
-
-
-
-
- );
+ return ;
}
diff --git a/components/dashboard/payment-status/payment-status-category-section.tsx b/components/dashboard/payment-status/payment-status-category-section.tsx
new file mode 100644
index 0000000..0225b17
--- /dev/null
+++ b/components/dashboard/payment-status/payment-status-category-section.tsx
@@ -0,0 +1,50 @@
+import MoneyValues from "@/components/shared/money-values";
+import StatusDot from "@/components/shared/status-dot";
+import { Progress } from "@/components/ui/progress";
+
+type PaymentStatusCategorySectionProps = {
+ title: string;
+ total: number;
+ confirmed: number;
+ pending: number;
+};
+
+export function PaymentStatusCategorySection({
+ title,
+ total,
+ confirmed,
+ pending,
+}: PaymentStatusCategorySectionProps) {
+ const absTotal = Math.abs(total);
+ const absConfirmed = Math.abs(confirmed);
+ const confirmedPercentage =
+ absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
+
+ return (
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+ confirmados
+
+
+
+
+
+ pendentes
+
+
+
+ );
+}
diff --git a/components/dashboard/payment-status/payment-status-widget-view.tsx b/components/dashboard/payment-status/payment-status-widget-view.tsx
new file mode 100644
index 0000000..9692ae2
--- /dev/null
+++ b/components/dashboard/payment-status/payment-status-widget-view.tsx
@@ -0,0 +1,47 @@
+import { RiWallet3Line } from "@remixicon/react";
+import { CardContent } from "@/components/ui/card";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
+import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status";
+import { PaymentStatusCategorySection } from "./payment-status-category-section";
+
+type PaymentStatusWidgetViewProps = {
+ data: PaymentStatusData;
+};
+
+export function PaymentStatusWidgetView({
+ data,
+}: PaymentStatusWidgetViewProps) {
+ const isEmpty = data.income.total === 0 && data.expenses.total === 0;
+
+ if (isEmpty) {
+ return (
+
+ }
+ title="Nenhum valor a receber ou pagar no período"
+ description="Registre lançamentos para visualizar os valores confirmados e pendentes."
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/dashboard/purchases-by-category-widget.tsx b/components/dashboard/purchases-by-category-widget.tsx
index fabaaf0..edf8ede 100644
--- a/components/dashboard/purchases-by-category-widget.tsx
+++ b/components/dashboard/purchases-by-category-widget.tsx
@@ -1,9 +1,10 @@
"use client";
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
-import MoneyValues from "@/components/money-values";
+import MoneyValues from "@/components/shared/money-values";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import {
Select,
SelectContent,
@@ -13,7 +14,6 @@ import {
} from "@/components/ui/select";
import { CATEGORY_TYPE_LABEL } from "@/lib/categorias/constants";
import type { PurchasesByCategoryData } from "@/lib/dashboard/purchases-by-category";
-import { WidgetEmptyState } from "../widget-empty-state";
type PurchasesByCategoryWidgetProps = {
data: PurchasesByCategoryData;
@@ -38,21 +38,11 @@ const STORAGE_KEY = "purchases-by-category-selected";
export function PurchasesByCategoryWidget({
data,
}: PurchasesByCategoryWidgetProps) {
- // Inicializa com a categoria salva ou a primeira disponível
- const [selectedCategoryId, setSelectedCategoryId] = useState(() => {
- if (typeof window === "undefined") {
- const firstCategory = data.categories[0];
- return firstCategory ? firstCategory.id : "";
- }
-
- const saved = sessionStorage.getItem(STORAGE_KEY);
- if (saved && data.categories.some((cat) => cat.id === saved)) {
- return saved;
- }
-
- const firstCategory = data.categories[0];
- return firstCategory ? firstCategory.id : "";
- });
+ const firstCategoryId = data.categories[0]?.id ?? "";
+ const hasRestoredSelectionRef = useRef(false);
+ const hasPersistedSelectionRef = useRef(false);
+ const [selectedCategoryId, setSelectedCategoryId] =
+ useState(firstCategoryId);
// Agrupa categorias por tipo
const categoriesByType = useMemo(() => {
@@ -72,27 +62,52 @@ export function PurchasesByCategoryWidget({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.categories]);
- // Salva a categoria selecionada quando mudar
+ // Restaura a categoria salva apenas depois da montagem para manter SSR e cliente consistentes.
useEffect(() => {
+ if (hasRestoredSelectionRef.current) {
+ return;
+ }
+
+ hasRestoredSelectionRef.current = true;
+
+ const saved = sessionStorage.getItem(STORAGE_KEY);
+ if (saved && data.categories.some((cat) => cat.id === saved)) {
+ setSelectedCategoryId(saved);
+ return;
+ }
+
+ setSelectedCategoryId(firstCategoryId);
+ }, [data.categories, firstCategoryId]);
+
+ // Salva a categoria selecionada quando mudar, sem sobrescrever o valor salvo na primeira montagem.
+ useEffect(() => {
+ if (!hasPersistedSelectionRef.current) {
+ hasPersistedSelectionRef.current = true;
+ return;
+ }
+
if (selectedCategoryId) {
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
+ return;
}
+
+ sessionStorage.removeItem(STORAGE_KEY);
}, [selectedCategoryId]);
// Atualiza a categoria selecionada se ela não existir mais na lista
useEffect(() => {
+ if (!selectedCategoryId && firstCategoryId) {
+ setSelectedCategoryId(firstCategoryId);
+ return;
+ }
+
if (
selectedCategoryId &&
!data.categories.some((cat) => cat.id === selectedCategoryId)
) {
- const firstCategory = data.categories[0];
- if (firstCategory) {
- setSelectedCategoryId(firstCategory.id);
- } else {
- setSelectedCategoryId("");
- }
+ setSelectedCategoryId(firstCategoryId);
}
- }, [data.categories, selectedCategoryId]);
+ }, [data.categories, firstCategoryId, selectedCategoryId]);
const currentTransactions = useMemo(() => {
if (!selectedCategoryId) {
diff --git a/components/dashboard/recurring-expenses-widget.tsx b/components/dashboard/recurring-expenses-widget.tsx
index 2ac6f7c..34f281c 100644
--- a/components/dashboard/recurring-expenses-widget.tsx
+++ b/components/dashboard/recurring-expenses-widget.tsx
@@ -1,9 +1,8 @@
import { RiRefreshLine } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
-import MoneyValues from "@/components/money-values";
-import { CardContent } from "@/components/ui/card";
+import MoneyValues from "@/components/shared/money-values";
import type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
type RecurringExpensesWidgetProps = {
data: RecurringExpensesData;
@@ -31,7 +30,7 @@ export function RecurringExpensesWidget({
}
return (
-
+
{data.expenses.map((expense) => {
return (
@@ -61,6 +60,6 @@ export function RecurringExpensesWidget({
);
})}
-
+
);
}
diff --git a/components/dashboard/top-establishments-widget.tsx b/components/dashboard/top-establishments-widget.tsx
index d2a3241..7f5bdae 100644
--- a/components/dashboard/top-establishments-widget.tsx
+++ b/components/dashboard/top-establishments-widget.tsx
@@ -1,8 +1,8 @@
import { RiStore2Line } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
-import MoneyValues from "@/components/money-values";
+import MoneyValues from "@/components/shared/money-values";
import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
type TopEstablishmentsWidgetProps = {
data: TopEstablishmentsData;
diff --git a/components/dashboard/top-expenses-widget.tsx b/components/dashboard/top-expenses-widget.tsx
index 85078de..3f5acb4 100644
--- a/components/dashboard/top-expenses-widget.tsx
+++ b/components/dashboard/top-expenses-widget.tsx
@@ -3,13 +3,13 @@
import { RiArrowUpDoubleLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
-import MoneyValues from "@/components/money-values";
+import MoneyValues from "@/components/shared/money-values";
import { Switch } from "@/components/ui/switch";
import type {
TopExpense,
TopExpensesData,
} from "@/lib/dashboard/expenses/top-expenses";
-import { WidgetEmptyState } from "../widget-empty-state";
+import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
type TopExpensesWidgetProps = {
allExpenses: TopExpensesData;
diff --git a/components/dashboard/welcome-widget.ts b/components/dashboard/welcome-widget.ts
new file mode 100644
index 0000000..2dd3a77
--- /dev/null
+++ b/components/dashboard/welcome-widget.ts
@@ -0,0 +1,9 @@
+import {
+ formatBusinessCurrentDate,
+ getBusinessGreeting,
+} from "@/lib/utils/date";
+
+export const formatCurrentDate = (date = new Date()) =>
+ formatBusinessCurrentDate(date);
+
+export const getGreeting = (date = new Date()) => getBusinessGreeting(date);
diff --git a/components/magnet-lines.tsx b/components/magnet-lines.tsx
deleted file mode 100644
index 1b3be80..0000000
--- a/components/magnet-lines.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-"use client";
-
-import type React from "react";
-import { type CSSProperties, useEffect, useRef } from "react";
-
-interface MagnetLinesProps {
- rows?: number;
- columns?: number;
- containerSize?: string;
- lineColor?: string;
- lineWidth?: string;
- lineHeight?: string;
- baseAngle?: number;
- className?: string;
- style?: CSSProperties;
- disabled?: boolean;
-}
-
-const MagnetLines: React.FC = ({
- rows = 9,
- columns = 9,
- containerSize = "80vmin",
- lineColor = "#efefef",
- lineWidth = "1vmin",
- lineHeight = "6vmin",
- baseAngle = -10,
- className = "",
- style = {},
- disabled = false,
-}) => {
- const containerRef = useRef(null);
-
- useEffect(() => {
- if (disabled) return;
- const container = containerRef.current;
- if (!container) return;
-
- const items = container.querySelectorAll("span");
-
- const onPointerMove = (pointer: { x: number; y: number }) => {
- items.forEach((item) => {
- const rect = item.getBoundingClientRect();
- const centerX = rect.x + rect.width / 2;
- const centerY = rect.y + rect.height / 2;
-
- const b = pointer.x - centerX;
- const a = pointer.y - centerY;
- const c = Math.sqrt(a * a + b * b) || 1;
- const r =
- ((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1);
-
- item.style.setProperty("--rotate", `${r}deg`);
- });
- };
-
- const handlePointerMove = (e: PointerEvent) => {
- onPointerMove({ x: e.x, y: e.y });
- };
-
- window.addEventListener("pointermove", handlePointerMove);
-
- if (items.length) {
- const middleIndex = Math.floor(items.length / 2);
- const rect = items[middleIndex].getBoundingClientRect();
- onPointerMove({ x: rect.x, y: rect.y });
- }
-
- return () => {
- window.removeEventListener("pointermove", handlePointerMove);
- };
- }, [disabled]);
-
- // Se magnetlines estiver desabilitado, não renderiza nada
- if (disabled) {
- return null;
- }
-
- const total = rows * columns;
- const spans = Array.from({ length: total }, (_, i) => (
-
- ));
-
- return (
-
- {spans}
-
- );
-};
-
-export default MagnetLines;
diff --git a/components/shared/skeletons/section-cards-skeleton.tsx b/components/shared/skeletons/section-cards-skeleton.tsx
deleted file mode 100644
index 076106e..0000000
--- a/components/shared/skeletons/section-cards-skeleton.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Card, CardFooter, CardHeader } from "@/components/ui/card";
-import { Skeleton } from "@/components/ui/skeleton";
-
-/**
- * Skeleton fiel aos cards de métricas do dashboard (SectionCards)
- * Mantém o mesmo layout de 4 colunas responsivo
- */
-export function SectionCardsSkeleton() {
- return (
-
- {Array.from({ length: 4 }).map((_, index) => (
-
-
-
- {/* Título com ícone */}
-
-
-
-
-
- {/* Valor principal */}
-
-
- {/* Badge de tendência */}
-
-
-
-
-
-
-
-
-
- ))}
-
- );
-}
diff --git a/db/schema.ts b/db/schema.ts
index e7aea38..f4ae871 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -127,7 +127,6 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", {
.notNull()
.unique()
.references(() => user.id, { onDelete: "cascade" }),
- disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
extratoNoteAsColumn: boolean("extrato_note_as_column")
.notNull()
.default(false),
diff --git a/drizzle/0018_rainy_epoch.sql b/drizzle/0018_rainy_epoch.sql
new file mode 100644
index 0000000..8023229
--- /dev/null
+++ b/drizzle/0018_rainy_epoch.sql
@@ -0,0 +1 @@
+ALTER TABLE "preferencias_usuario" DROP COLUMN "disable_magnetlines";
\ No newline at end of file
diff --git a/drizzle/meta/0018_snapshot.json b/drizzle/meta/0018_snapshot.json
new file mode 100644
index 0000000..66ec7d6
--- /dev/null
+++ b/drizzle/meta/0018_snapshot.json
@@ -0,0 +1,2416 @@
+{
+ "id": "853b7f42-7b0e-43a6-8665-918ec5ec6608",
+ "prevId": "ad4a0401-4eb8-47ab-a18d-a85643544d73",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "accountId": {
+ "name": "accountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerId": {
+ "name": "providerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "accessToken": {
+ "name": "accessToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refreshToken": {
+ "name": "refreshToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idToken": {
+ "name": "idToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accessTokenExpiresAt": {
+ "name": "accessTokenExpiresAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refreshTokenExpiresAt": {
+ "name": "refreshTokenExpiresAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anotacoes": {
+ "name": "anotacoes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "titulo": {
+ "name": "titulo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "descricao": {
+ "name": "descricao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tipo": {
+ "name": "tipo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'nota'"
+ },
+ "tasks": {
+ "name": "tasks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "arquivada": {
+ "name": "arquivada",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "anotacoes_user_id_user_id_fk": {
+ "name": "anotacoes_user_id_user_id_fk",
+ "tableFrom": "anotacoes",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.antecipacoes_parcelas": {
+ "name": "antecipacoes_parcelas",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "series_id": {
+ "name": "series_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "periodo_antecipacao": {
+ "name": "periodo_antecipacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data_antecipacao": {
+ "name": "data_antecipacao",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parcelas_antecipadas": {
+ "name": "parcelas_antecipadas",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "valor_total": {
+ "name": "valor_total",
+ "type": "numeric(12, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "qtde_parcelas": {
+ "name": "qtde_parcelas",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "desconto": {
+ "name": "desconto",
+ "type": "numeric(12, 2)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "lancamento_id": {
+ "name": "lancamento_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pagador_id": {
+ "name": "pagador_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "categoria_id": {
+ "name": "categoria_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "anotacao": {
+ "name": "anotacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "antecipacoes_parcelas_series_id_idx": {
+ "name": "antecipacoes_parcelas_series_id_idx",
+ "columns": [
+ {
+ "expression": "series_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "antecipacoes_parcelas_user_id_idx": {
+ "name": "antecipacoes_parcelas_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk": {
+ "name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
+ "tableFrom": "antecipacoes_parcelas",
+ "tableTo": "lancamentos",
+ "columnsFrom": [
+ "lancamento_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "antecipacoes_parcelas_pagador_id_pagadores_id_fk": {
+ "name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
+ "tableFrom": "antecipacoes_parcelas",
+ "tableTo": "pagadores",
+ "columnsFrom": [
+ "pagador_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "antecipacoes_parcelas_categoria_id_categorias_id_fk": {
+ "name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
+ "tableFrom": "antecipacoes_parcelas",
+ "tableTo": "categorias",
+ "columnsFrom": [
+ "categoria_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "antecipacoes_parcelas_user_id_user_id_fk": {
+ "name": "antecipacoes_parcelas_user_id_user_id_fk",
+ "tableFrom": "antecipacoes_parcelas",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cartoes": {
+ "name": "cartoes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "nome": {
+ "name": "nome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dt_fechamento": {
+ "name": "dt_fechamento",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dt_vencimento": {
+ "name": "dt_vencimento",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "anotacao": {
+ "name": "anotacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limite": {
+ "name": "limite",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bandeira": {
+ "name": "bandeira",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "conta_id": {
+ "name": "conta_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "cartoes_user_id_status_idx": {
+ "name": "cartoes_user_id_status_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "cartoes_user_id_user_id_fk": {
+ "name": "cartoes_user_id_user_id_fk",
+ "tableFrom": "cartoes",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "cartoes_conta_id_contas_id_fk": {
+ "name": "cartoes_conta_id_contas_id_fk",
+ "tableFrom": "cartoes",
+ "tableTo": "contas",
+ "columnsFrom": [
+ "conta_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.categorias": {
+ "name": "categorias",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "nome": {
+ "name": "nome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tipo": {
+ "name": "tipo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icone": {
+ "name": "icone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "categorias_user_id_type_idx": {
+ "name": "categorias_user_id_type_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tipo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "categorias_user_id_user_id_fk": {
+ "name": "categorias_user_id_user_id_fk",
+ "tableFrom": "categorias",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.compartilhamentos_pagador": {
+ "name": "compartilhamentos_pagador",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "pagador_id": {
+ "name": "pagador_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "shared_with_user_id": {
+ "name": "shared_with_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'read'"
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "compartilhamentos_pagador_unique": {
+ "name": "compartilhamentos_pagador_unique",
+ "columns": [
+ {
+ "expression": "pagador_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "shared_with_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "compartilhamentos_pagador_pagador_id_pagadores_id_fk": {
+ "name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
+ "tableFrom": "compartilhamentos_pagador",
+ "tableTo": "pagadores",
+ "columnsFrom": [
+ "pagador_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "compartilhamentos_pagador_shared_with_user_id_user_id_fk": {
+ "name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
+ "tableFrom": "compartilhamentos_pagador",
+ "tableTo": "user",
+ "columnsFrom": [
+ "shared_with_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "compartilhamentos_pagador_created_by_user_id_user_id_fk": {
+ "name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
+ "tableFrom": "compartilhamentos_pagador",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contas": {
+ "name": "contas",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "nome": {
+ "name": "nome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tipo_conta": {
+ "name": "tipo_conta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "anotacao": {
+ "name": "anotacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "saldo_inicial": {
+ "name": "saldo_inicial",
+ "type": "numeric(12, 2)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "excluir_do_saldo": {
+ "name": "excluir_do_saldo",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "excluir_saldo_inicial_receitas": {
+ "name": "excluir_saldo_inicial_receitas",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contas_user_id_status_idx": {
+ "name": "contas_user_id_status_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "contas_user_id_user_id_fk": {
+ "name": "contas_user_id_user_id_fk",
+ "tableFrom": "contas",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.faturas": {
+ "name": "faturas",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "status_pagamento": {
+ "name": "status_pagamento",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "periodo": {
+ "name": "periodo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cartao_id": {
+ "name": "cartao_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "faturas_user_id_period_idx": {
+ "name": "faturas_user_id_period_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "periodo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "faturas_cartao_id_period_idx": {
+ "name": "faturas_cartao_id_period_idx",
+ "columns": [
+ {
+ "expression": "cartao_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "periodo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "faturas_user_id_user_id_fk": {
+ "name": "faturas_user_id_user_id_fk",
+ "tableFrom": "faturas",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "faturas_cartao_id_cartoes_id_fk": {
+ "name": "faturas_cartao_id_cartoes_id_fk",
+ "tableFrom": "faturas",
+ "tableTo": "cartoes",
+ "columnsFrom": [
+ "cartao_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.insights_salvos": {
+ "name": "insights_salvos",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "period": {
+ "name": "period",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "insights_salvos_user_period_idx": {
+ "name": "insights_salvos_user_period_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "period",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "insights_salvos_user_id_user_id_fk": {
+ "name": "insights_salvos_user_id_user_id_fk",
+ "tableFrom": "insights_salvos",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.lancamentos": {
+ "name": "lancamentos",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "condicao": {
+ "name": "condicao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nome": {
+ "name": "nome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "forma_pagamento": {
+ "name": "forma_pagamento",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "anotacao": {
+ "name": "anotacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valor": {
+ "name": "valor",
+ "type": "numeric(12, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data_compra": {
+ "name": "data_compra",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tipo_transacao": {
+ "name": "tipo_transacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "qtde_parcela": {
+ "name": "qtde_parcela",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "periodo": {
+ "name": "periodo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parcela_atual": {
+ "name": "parcela_atual",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "qtde_recorrencia": {
+ "name": "qtde_recorrencia",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data_vencimento": {
+ "name": "data_vencimento",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dt_pagamento_boleto": {
+ "name": "dt_pagamento_boleto",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "realizado": {
+ "name": "realizado",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "dividido": {
+ "name": "dividido",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "antecipado": {
+ "name": "antecipado",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "antecipacao_id": {
+ "name": "antecipacao_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cartao_id": {
+ "name": "cartao_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "conta_id": {
+ "name": "conta_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "categoria_id": {
+ "name": "categoria_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pagador_id": {
+ "name": "pagador_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "series_id": {
+ "name": "series_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transfer_id": {
+ "name": "transfer_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "lancamentos_user_id_period_idx": {
+ "name": "lancamentos_user_id_period_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "periodo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "lancamentos_user_id_period_type_idx": {
+ "name": "lancamentos_user_id_period_type_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "periodo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tipo_transacao",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "lancamentos_pagador_id_period_idx": {
+ "name": "lancamentos_pagador_id_period_idx",
+ "columns": [
+ {
+ "expression": "pagador_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "periodo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "lancamentos_user_id_purchase_date_idx": {
+ "name": "lancamentos_user_id_purchase_date_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "data_compra",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "lancamentos_series_id_idx": {
+ "name": "lancamentos_series_id_idx",
+ "columns": [
+ {
+ "expression": "series_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "lancamentos_transfer_id_idx": {
+ "name": "lancamentos_transfer_id_idx",
+ "columns": [
+ {
+ "expression": "transfer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "lancamentos_user_id_condition_idx": {
+ "name": "lancamentos_user_id_condition_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "condicao",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "lancamentos_cartao_id_period_idx": {
+ "name": "lancamentos_cartao_id_period_idx",
+ "columns": [
+ {
+ "expression": "cartao_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "periodo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk": {
+ "name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
+ "tableFrom": "lancamentos",
+ "tableTo": "antecipacoes_parcelas",
+ "columnsFrom": [
+ "antecipacao_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "lancamentos_user_id_user_id_fk": {
+ "name": "lancamentos_user_id_user_id_fk",
+ "tableFrom": "lancamentos",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "lancamentos_cartao_id_cartoes_id_fk": {
+ "name": "lancamentos_cartao_id_cartoes_id_fk",
+ "tableFrom": "lancamentos",
+ "tableTo": "cartoes",
+ "columnsFrom": [
+ "cartao_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ },
+ "lancamentos_conta_id_contas_id_fk": {
+ "name": "lancamentos_conta_id_contas_id_fk",
+ "tableFrom": "lancamentos",
+ "tableTo": "contas",
+ "columnsFrom": [
+ "conta_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ },
+ "lancamentos_categoria_id_categorias_id_fk": {
+ "name": "lancamentos_categoria_id_categorias_id_fk",
+ "tableFrom": "lancamentos",
+ "tableTo": "categorias",
+ "columnsFrom": [
+ "categoria_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ },
+ "lancamentos_pagador_id_pagadores_id_fk": {
+ "name": "lancamentos_pagador_id_pagadores_id_fk",
+ "tableFrom": "lancamentos",
+ "tableTo": "pagadores",
+ "columnsFrom": [
+ "pagador_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.orcamentos": {
+ "name": "orcamentos",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "valor": {
+ "name": "valor",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "periodo": {
+ "name": "periodo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "categoria_id": {
+ "name": "categoria_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "orcamentos_user_id_period_idx": {
+ "name": "orcamentos_user_id_period_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "periodo",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "orcamentos_user_id_user_id_fk": {
+ "name": "orcamentos_user_id_user_id_fk",
+ "tableFrom": "orcamentos",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "orcamentos_categoria_id_categorias_id_fk": {
+ "name": "orcamentos_categoria_id_categorias_id_fk",
+ "tableFrom": "orcamentos",
+ "tableTo": "categorias",
+ "columnsFrom": [
+ "categoria_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pagadores": {
+ "name": "pagadores",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "nome": {
+ "name": "nome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "anotacao": {
+ "name": "anotacao",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_auto_send": {
+ "name": "is_auto_send",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "share_code": {
+ "name": "share_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "substr(encode(gen_random_bytes(24), 'base64'), 1, 24)"
+ },
+ "last_mail": {
+ "name": "last_mail",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "pagadores_share_code_key": {
+ "name": "pagadores_share_code_key",
+ "columns": [
+ {
+ "expression": "share_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pagadores_user_id_status_idx": {
+ "name": "pagadores_user_id_status_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pagadores_user_id_role_idx": {
+ "name": "pagadores_user_id_role_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pagadores_user_id_user_id_fk": {
+ "name": "pagadores_user_id_user_id_fk",
+ "tableFrom": "pagadores",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.passkey": {
+ "name": "passkey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "publicKey": {
+ "name": "publicKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credentialID": {
+ "name": "credentialID",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deviceType": {
+ "name": "deviceType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "backedUp": {
+ "name": "backedUp",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "aaguid": {
+ "name": "aaguid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "passkey_userId_user_id_fk": {
+ "name": "passkey_userId_user_id_fk",
+ "tableFrom": "passkey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pre_lancamentos": {
+ "name": "pre_lancamentos",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_app": {
+ "name": "source_app",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_app_name": {
+ "name": "source_app_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_title": {
+ "name": "original_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_text": {
+ "name": "original_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notification_timestamp": {
+ "name": "notification_timestamp",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parsed_name": {
+ "name": "parsed_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parsed_amount": {
+ "name": "parsed_amount",
+ "type": "numeric(12, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "lancamento_id": {
+ "name": "lancamento_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "discarded_at": {
+ "name": "discarded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "pre_lancamentos_user_id_status_idx": {
+ "name": "pre_lancamentos_user_id_status_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pre_lancamentos_user_id_created_at_idx": {
+ "name": "pre_lancamentos_user_id_created_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pre_lancamentos_user_id_user_id_fk": {
+ "name": "pre_lancamentos_user_id_user_id_fk",
+ "tableFrom": "pre_lancamentos",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pre_lancamentos_lancamento_id_lancamentos_id_fk": {
+ "name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
+ "tableFrom": "pre_lancamentos",
+ "tableTo": "lancamentos",
+ "columnsFrom": [
+ "lancamento_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.preferencias_usuario": {
+ "name": "preferencias_usuario",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extrato_note_as_column": {
+ "name": "extrato_note_as_column",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "system_font": {
+ "name": "system_font",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ai-sans'"
+ },
+ "money_font": {
+ "name": "money_font",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ai-sans'"
+ },
+ "lancamentos_column_order": {
+ "name": "lancamentos_column_order",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dashboard_widgets": {
+ "name": "dashboard_widgets",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "preferencias_usuario_user_id_user_id_fk": {
+ "name": "preferencias_usuario_user_id_user_id_fk",
+ "tableFrom": "preferencias_usuario",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "preferencias_usuario_user_id_unique": {
+ "name": "preferencias_usuario_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ipAddress": {
+ "name": "ipAddress",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userAgent": {
+ "name": "userAgent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tokens_api": {
+ "name": "tokens_api",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_prefix": {
+ "name": "token_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_used_ip": {
+ "name": "last_used_ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tokens_api_user_id_idx": {
+ "name": "tokens_api_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tokens_api_token_hash_idx": {
+ "name": "tokens_api_token_hash_idx",
+ "columns": [
+ {
+ "expression": "token_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tokens_api_user_id_user_id_fk": {
+ "name": "tokens_api_user_id_user_id_fk",
+ "tableFrom": "tokens_api",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 23712e6..49c3512 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -1,132 +1,139 @@
{
- "version": "7",
- "dialect": "postgresql",
- "entries": [
- {
- "idx": 0,
- "version": "7",
- "when": 1762993507299,
- "tag": "0000_flashy_manta",
- "breakpoints": true
- },
- {
- "idx": 1,
- "version": "7",
- "when": 1765199006435,
- "tag": "0001_young_mister_fear",
- "breakpoints": true
- },
- {
- "idx": 2,
- "version": "7",
- "when": 1765200545692,
- "tag": "0002_slimy_flatman",
- "breakpoints": true
- },
- {
- "idx": 3,
- "version": "7",
- "when": 1767102605526,
- "tag": "0003_green_korg",
- "breakpoints": true
- },
- {
- "idx": 4,
- "version": "7",
- "when": 1767104066872,
- "tag": "0004_acoustic_mach_iv",
- "breakpoints": true
- },
- {
- "idx": 5,
- "version": "7",
- "when": 1767106121811,
- "tag": "0005_adorable_bruce_banner",
- "breakpoints": true
- },
- {
- "idx": 6,
- "version": "7",
- "when": 1767107487318,
- "tag": "0006_youthful_mister_fear",
- "breakpoints": true
- },
- {
- "idx": 7,
- "version": "7",
- "when": 1767118780033,
- "tag": "0007_sturdy_kate_bishop",
- "breakpoints": true
- },
- {
- "idx": 8,
- "version": "7",
- "when": 1767125796314,
- "tag": "0008_fat_stick",
- "breakpoints": true
- },
- {
- "idx": 9,
- "version": "7",
- "when": 1768925100873,
- "tag": "0009_add_dashboard_widgets",
- "breakpoints": true
- },
- {
- "idx": 10,
- "version": "7",
- "when": 1769369834242,
- "tag": "0010_lame_psynapse",
- "breakpoints": true
- },
- {
- "idx": 11,
- "version": "7",
- "when": 1769447087678,
- "tag": "0011_remove_unused_inbox_columns",
- "breakpoints": true
- },
- {
- "idx": 12,
- "version": "7",
- "when": 1769533200000,
- "tag": "0012_rename_tables_to_portuguese",
- "breakpoints": true
- },
- {
- "idx": 13,
- "version": "7",
- "when": 1769523352777,
- "tag": "0013_fancy_rick_jones",
- "breakpoints": true
- },
- {
- "idx": 14,
- "version": "7",
- "when": 1769619226903,
- "tag": "0014_yielding_jack_flag",
- "breakpoints": true
- },
- {
- "idx": 15,
- "version": "7",
- "when": 1770332054481,
- "tag": "0015_concerned_kat_farrell",
- "breakpoints": true
- },
- {
- "idx": 16,
- "version": "7",
- "when": 1771166328908,
- "tag": "0016_complete_randall",
- "breakpoints": true
- },
- {
- "idx": 17,
- "version": "7",
- "when": 1772400510326,
- "tag": "0017_previous_warstar",
- "breakpoints": true
- }
- ]
-}
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1762993507299,
+ "tag": "0000_flashy_manta",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1765199006435,
+ "tag": "0001_young_mister_fear",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1765200545692,
+ "tag": "0002_slimy_flatman",
+ "breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1767102605526,
+ "tag": "0003_green_korg",
+ "breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "7",
+ "when": 1767104066872,
+ "tag": "0004_acoustic_mach_iv",
+ "breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "7",
+ "when": 1767106121811,
+ "tag": "0005_adorable_bruce_banner",
+ "breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "7",
+ "when": 1767107487318,
+ "tag": "0006_youthful_mister_fear",
+ "breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "7",
+ "when": 1767118780033,
+ "tag": "0007_sturdy_kate_bishop",
+ "breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "7",
+ "when": 1767125796314,
+ "tag": "0008_fat_stick",
+ "breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "7",
+ "when": 1768925100873,
+ "tag": "0009_add_dashboard_widgets",
+ "breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "7",
+ "when": 1769369834242,
+ "tag": "0010_lame_psynapse",
+ "breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "7",
+ "when": 1769447087678,
+ "tag": "0011_remove_unused_inbox_columns",
+ "breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "7",
+ "when": 1769533200000,
+ "tag": "0012_rename_tables_to_portuguese",
+ "breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "7",
+ "when": 1769523352777,
+ "tag": "0013_fancy_rick_jones",
+ "breakpoints": true
+ },
+ {
+ "idx": 14,
+ "version": "7",
+ "when": 1769619226903,
+ "tag": "0014_yielding_jack_flag",
+ "breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "7",
+ "when": 1770332054481,
+ "tag": "0015_concerned_kat_farrell",
+ "breakpoints": true
+ },
+ {
+ "idx": 16,
+ "version": "7",
+ "when": 1771166328908,
+ "tag": "0016_complete_randall",
+ "breakpoints": true
+ },
+ {
+ "idx": 17,
+ "version": "7",
+ "when": 1772400510326,
+ "tag": "0017_previous_warstar",
+ "breakpoints": true
+ },
+ {
+ "idx": 18,
+ "version": "7",
+ "when": 1773020417482,
+ "tag": "0018_rainy_epoch",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/lib/dashboard/accounts.ts b/lib/dashboard/accounts.ts
index 492d6b4..c71515b 100644
--- a/lib/dashboard/accounts.ts
+++ b/lib/dashboard/accounts.ts
@@ -1,9 +1,9 @@
import { and, eq, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
type RawDashboardAccount = {
id: string;
diff --git a/lib/dashboard/bills-helpers.ts b/lib/dashboard/bills-helpers.ts
new file mode 100644
index 0000000..87281cd
--- /dev/null
+++ b/lib/dashboard/bills-helpers.ts
@@ -0,0 +1,53 @@
+import type { DashboardBill } from "@/lib/dashboard/bills";
+import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller";
+import { getBusinessDateString, isDateOnlyPast } from "@/lib/utils/date";
+import {
+ buildFinancialStatusLabel,
+ formatFinancialDateLabel,
+} from "@/lib/utils/financial-dates";
+
+export type BillDialogState = PaymentDialogState;
+export type BillStatusDateItem = Pick<
+ DashboardBill,
+ "dueDate" | "boletoPaymentDate" | "isSettled"
+>;
+
+export const formatBillDateLabel = (value: string | null, prefix?: string) => {
+ return formatFinancialDateLabel(value, prefix);
+};
+
+export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
+ return buildFinancialStatusLabel({
+ isSettled: bill.isSettled,
+ dueDate: bill.dueDate,
+ paidAt: bill.boletoPaymentDate,
+ });
+};
+
+export const getCurrentBillDateString = () => getBusinessDateString();
+
+export const isBillOverdue = (bill: DashboardBill) => {
+ if (bill.isSettled || !bill.dueDate) {
+ return false;
+ }
+
+ return isDateOnlyPast(bill.dueDate);
+};
+
+export const getBillStatusBadgeVariant = (
+ statusLabel: string,
+): "success" | "info" => {
+ if (statusLabel.toLowerCase() === "pendente") {
+ return "info";
+ }
+ return "success";
+};
+
+export const markBillAsSettled = (
+ bill: DashboardBill,
+ boletoPaymentDate: string,
+): DashboardBill => ({
+ ...bill,
+ isSettled: true,
+ boletoPaymentDate,
+});
diff --git a/lib/dashboard/boletos.ts b/lib/dashboard/bills.ts
similarity index 65%
rename from lib/dashboard/boletos.ts
rename to lib/dashboard/bills.ts
index 1d28c97..c6e1d42 100644
--- a/lib/dashboard/boletos.ts
+++ b/lib/dashboard/bills.ts
@@ -2,13 +2,14 @@
import { and, asc, eq } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { toDateOnlyString } from "@/lib/utils/date";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto";
-type RawDashboardBoleto = {
+type RawDashboardBill = {
id: string;
name: string;
amount: string | number | null;
@@ -17,7 +18,7 @@ type RawDashboardBoleto = {
isSettled: boolean | null;
};
-export type DashboardBoleto = {
+export type DashboardBill = {
id: string;
name: string;
amount: number;
@@ -26,35 +27,19 @@ export type DashboardBoleto = {
isSettled: boolean;
};
-export type DashboardBoletosSnapshot = {
- boletos: DashboardBoleto[];
+export type DashboardBillsSnapshot = {
+ bills: DashboardBill[];
totalPendingAmount: number;
pendingCount: number;
};
-const toISODate = (value: Date | string | null) => {
- if (!value) {
- return null;
- }
-
- if (value instanceof Date) {
- return value.toISOString().slice(0, 10);
- }
-
- if (typeof value === "string") {
- return value;
- }
-
- return null;
-};
-
-export async function fetchDashboardBoletos(
+export async function fetchDashboardBills(
userId: string,
period: string,
-): Promise {
+): Promise {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
- return { boletos: [], totalPendingAmount: 0, pendingCount: 0 };
+ return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
}
const rows = await db
@@ -81,14 +66,14 @@ export async function fetchDashboardBoletos(
asc(lancamentos.name),
);
- const boletos = rows.map((row: RawDashboardBoleto): DashboardBoleto => {
+ const bills = rows.map((row: RawDashboardBill): DashboardBill => {
const amount = Math.abs(toNumber(row.amount));
return {
id: row.id,
name: row.name,
amount,
- dueDate: toISODate(row.dueDate),
- boletoPaymentDate: toISODate(row.boletoPaymentDate),
+ dueDate: toDateOnlyString(row.dueDate),
+ boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
isSettled: Boolean(row.isSettled),
};
});
@@ -96,15 +81,15 @@ export async function fetchDashboardBoletos(
let totalPendingAmount = 0;
let pendingCount = 0;
- for (const boleto of boletos) {
- if (!boleto.isSettled) {
- totalPendingAmount += boleto.amount;
+ for (const bill of bills) {
+ if (!bill.isSettled) {
+ totalPendingAmount += bill.amount;
pendingCount += 1;
}
}
return {
- boletos,
+ bills,
totalPendingAmount,
pendingCount,
};
diff --git a/lib/dashboard/categories/category-breakdown.ts b/lib/dashboard/categories/category-breakdown.ts
new file mode 100644
index 0000000..92c221d
--- /dev/null
+++ b/lib/dashboard/categories/category-breakdown.ts
@@ -0,0 +1,121 @@
+import { calculatePercentageChange } from "@/lib/utils/math";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
+
+export type DashboardCategoryBreakdownItem = {
+ categoryId: string;
+ categoryName: string;
+ categoryIcon: string | null;
+ currentAmount: number;
+ previousAmount: number;
+ percentageChange: number | null;
+ percentageOfTotal: number;
+ budgetAmount: number | null;
+ budgetUsedPercentage: number | null;
+};
+
+export type DashboardCategoryBreakdownData = {
+ categories: DashboardCategoryBreakdownItem[];
+ currentTotal: number;
+ previousTotal: number;
+};
+
+type CategoryBreakdownRow = {
+ categoryId: string;
+ categoryName: string;
+ categoryIcon: string | null;
+ period: string | null;
+ total: unknown;
+};
+
+type CategoryBudgetRow = {
+ categoriaId: string | null;
+ amount: unknown;
+};
+
+export function buildCategoryBreakdownData({
+ rows,
+ budgetRows,
+ period,
+}: {
+ rows: CategoryBreakdownRow[];
+ budgetRows: CategoryBudgetRow[];
+ period: string;
+}): DashboardCategoryBreakdownData {
+ const budgetMap = new Map();
+ for (const row of budgetRows) {
+ if (row.categoriaId) {
+ budgetMap.set(row.categoriaId, toNumber(row.amount));
+ }
+ }
+
+ const categoryMap = new Map<
+ string,
+ {
+ name: string;
+ icon: string | null;
+ current: number;
+ previous: number;
+ }
+ >();
+
+ for (const row of rows) {
+ const entry = categoryMap.get(row.categoryId) ?? {
+ name: row.categoryName,
+ icon: row.categoryIcon,
+ current: 0,
+ previous: 0,
+ };
+
+ const amount = Math.abs(toNumber(row.total));
+ if (row.period === period) {
+ entry.current = amount;
+ } else {
+ entry.previous = amount;
+ }
+
+ categoryMap.set(row.categoryId, entry);
+ }
+
+ let currentTotal = 0;
+ let previousTotal = 0;
+ for (const entry of categoryMap.values()) {
+ currentTotal += entry.current;
+ previousTotal += entry.previous;
+ }
+
+ const categories: DashboardCategoryBreakdownItem[] = [];
+ for (const [categoryId, entry] of categoryMap) {
+ const percentageChange = calculatePercentageChange(
+ entry.current,
+ entry.previous,
+ );
+ const percentageOfTotal =
+ currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
+
+ const budgetAmount = budgetMap.get(categoryId) ?? null;
+ const budgetUsedPercentage =
+ budgetAmount && budgetAmount > 0
+ ? (entry.current / budgetAmount) * 100
+ : null;
+
+ categories.push({
+ categoryId,
+ categoryName: entry.name,
+ categoryIcon: entry.icon,
+ currentAmount: entry.current,
+ previousAmount: entry.previous,
+ percentageChange,
+ percentageOfTotal,
+ budgetAmount,
+ budgetUsedPercentage,
+ });
+ }
+
+ categories.sort((a, b) => b.currentAmount - a.currentAmount);
+
+ return {
+ categories,
+ currentTotal,
+ previousTotal,
+ };
+}
diff --git a/lib/dashboard/categories/category-details.ts b/lib/dashboard/categories/category-details.ts
index 787a23a..f45139b 100644
--- a/lib/dashboard/categories/category-details.ts
+++ b/lib/dashboard/categories/category-details.ts
@@ -5,10 +5,10 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
type MappedLancamentos = ReturnType;
diff --git a/lib/dashboard/categories/category-history.ts b/lib/dashboard/categories/category-history.ts
index 8438121..997e110 100644
--- a/lib/dashboard/categories/category-history.ts
+++ b/lib/dashboard/categories/category-history.ts
@@ -1,12 +1,15 @@
-import { addMonths, format } from "date-fns";
-import { ptBR } from "date-fns/locale";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
+import {
+ addMonthsToPeriod,
+ buildPeriodWindow,
+ formatPeriodMonthShort,
+} from "@/lib/utils/period";
export type CategoryOption = {
id: string;
@@ -34,6 +37,19 @@ export type CategoryHistoryData = {
};
const CHART_COLORS = CATEGORY_COLORS;
+type MonthlyCategoryRow = {
+ categoryId: string;
+ categoryName: string;
+ categoryIcon: string | null;
+ period: string;
+ totalAmount: unknown;
+};
+
+type UniqueCategory = {
+ id: string;
+ name: string;
+ icon: string | null;
+};
export async function fetchAllCategories(
userId: string,
@@ -61,26 +77,16 @@ export async function fetchCategoryHistory(
currentPeriod: string,
): Promise {
// Generate last 8 months, current month, and next month (10 total)
- const periods: string[] = [];
- const monthLabels: string[] = [];
-
- const [year, month] = currentPeriod.split("-").map(Number);
- const currentDate = new Date(year, month - 1, 1);
-
- // Generate months from -8 to +1 (relative to current)
- for (let i = 8; i >= -1; i--) {
- const date = addMonths(currentDate, -i);
- const period = format(date, "yyyy-MM");
- const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
- periods.push(period);
- monthLabels.push(label);
- }
+ const periods = buildPeriodWindow(addMonthsToPeriod(currentPeriod, 1), 10);
+ const monthLabels = periods.map((period) =>
+ formatPeriodMonthShort(period).toUpperCase(),
+ );
// Fetch all categories for the selector
const allCategories = await fetchAllCategories(userId);
// Fetch monthly data for ALL categories with transactions
- const monthlyDataQuery = await db
+ const monthlyDataQuery = (await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
@@ -112,7 +118,7 @@ export async function fetchCategoryHistory(
categorias.name,
categorias.icon,
lancamentos.period,
- );
+ )) as MonthlyCategoryRow[];
if (monthlyDataQuery.length === 0) {
return {
@@ -124,8 +130,8 @@ export async function fetchCategoryHistory(
}
// Get unique categories from query results
- const uniqueCategories = Array.from(
- new Map(
+ const uniqueCategories: UniqueCategory[] = Array.from(
+ new Map(
monthlyDataQuery.map((row) => [
row.categoryId,
{
@@ -178,15 +184,20 @@ export async function fetchCategoryHistory(
});
// Convert to chart data format
- const chartData = monthLabels.map((month) => {
- const dataPoint: Record = { month };
+ const chartData: CategoryHistoryData["chartData"] = monthLabels.map(
+ (month) => {
+ const dataPoint: {
+ month: string;
+ [categoryName: string]: number | string;
+ } = { month };
- categoriesMap.forEach((category) => {
- dataPoint[category.name] = category.data[month];
- });
+ categoriesMap.forEach((category) => {
+ dataPoint[category.name] = category.data[month];
+ });
- return dataPoint;
- });
+ return dataPoint;
+ },
+ );
return {
months: monthLabels,
diff --git a/lib/dashboard/categories/expenses-by-category.ts b/lib/dashboard/categories/expenses-by-category.ts
index 7aaf5f3..72fd704 100644
--- a/lib/dashboard/categories/expenses-by-category.ts
+++ b/lib/dashboard/categories/expenses-by-category.ts
@@ -1,29 +1,20 @@
-import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
+import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
-import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
+import {
+ buildCategoryBreakdownData,
+ type DashboardCategoryBreakdownData,
+ type DashboardCategoryBreakdownItem,
+} from "@/lib/dashboard/categories/category-breakdown";
+import {
+ buildDashboardAdminFilters,
+ excludeAutoInvoiceEntries,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
-import { calculatePercentageChange } from "@/lib/utils/math";
import { getPreviousPeriod } from "@/lib/utils/period";
-export type CategoryExpenseItem = {
- categoryId: string;
- categoryName: string;
- categoryIcon: string | null;
- currentAmount: number;
- previousAmount: number;
- percentageChange: number | null;
- percentageOfTotal: number;
- budgetAmount: number | null;
- budgetUsedPercentage: number | null;
-};
-
-export type ExpensesByCategoryData = {
- categories: CategoryExpenseItem[];
- currentTotal: number;
- previousTotal: number;
-};
+export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
+export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
export async function fetchExpensesByCategory(
userId: string,
@@ -50,15 +41,11 @@ export async function fetchExpensesByCategory(
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
- eq(lancamentos.userId, userId),
- eq(lancamentos.pagadorId, adminPagadorId),
+ ...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"),
eq(categorias.type, "despesa"),
- or(
- isNull(lancamentos.note),
- sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
- ),
+ excludeAutoInvoiceEntries(),
),
)
.groupBy(
@@ -76,85 +63,9 @@ export async function fetchExpensesByCategory(
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
- // Build budget lookup
- const budgetMap = new Map();
- for (const row of budgetRows) {
- if (row.categoriaId) {
- budgetMap.set(row.categoriaId, toNumber(row.amount));
- }
- }
-
- // Build category data from grouped results
- const categoryMap = new Map<
- string,
- {
- name: string;
- icon: string | null;
- current: number;
- previous: number;
- }
- >();
-
- for (const row of rows) {
- const entry = categoryMap.get(row.categoryId) ?? {
- name: row.categoryName,
- icon: row.categoryIcon,
- current: 0,
- previous: 0,
- };
-
- const amount = Math.abs(toNumber(row.total));
- if (row.period === period) {
- entry.current = amount;
- } else {
- entry.previous = amount;
- }
- categoryMap.set(row.categoryId, entry);
- }
-
- // Calculate totals
- let currentTotal = 0;
- let previousTotal = 0;
- for (const entry of categoryMap.values()) {
- currentTotal += entry.current;
- previousTotal += entry.previous;
- }
-
- // Build result
- const categories: CategoryExpenseItem[] = [];
- for (const [categoryId, entry] of categoryMap) {
- const percentageChange = calculatePercentageChange(
- entry.current,
- entry.previous,
- );
- const percentageOfTotal =
- currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
-
- const budgetAmount = budgetMap.get(categoryId) ?? null;
- const budgetUsedPercentage =
- budgetAmount && budgetAmount > 0
- ? (entry.current / budgetAmount) * 100
- : null;
-
- categories.push({
- categoryId,
- categoryName: entry.name,
- categoryIcon: entry.icon,
- currentAmount: entry.current,
- previousAmount: entry.previous,
- percentageChange,
- percentageOfTotal,
- budgetAmount,
- budgetUsedPercentage,
- });
- }
-
- // Ordena por valor atual (maior para menor)
- categories.sort((a, b) => b.currentAmount - a.currentAmount);
-
- return {
- categories,
- currentTotal,
- previousTotal,
- };
+ return buildCategoryBreakdownData({
+ rows,
+ budgetRows,
+ period,
+ });
}
diff --git a/lib/dashboard/categories/income-by-category.ts b/lib/dashboard/categories/income-by-category.ts
index dd38246..2c48481 100644
--- a/lib/dashboard/categories/income-by-category.ts
+++ b/lib/dashboard/categories/income-by-category.ts
@@ -1,32 +1,21 @@
-import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
+import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
import {
- ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
- INITIAL_BALANCE_NOTE,
-} from "@/lib/contas/constants";
+ buildCategoryBreakdownData,
+ type DashboardCategoryBreakdownData,
+ type DashboardCategoryBreakdownItem,
+} from "@/lib/dashboard/categories/category-breakdown";
+import {
+ buildDashboardAdminFilters,
+ excludeAutoInvoiceEntries,
+ excludeInitialBalanceWhenConfigured,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
-import { calculatePercentageChange } from "@/lib/utils/math";
-import { safeToNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
-export type CategoryIncomeItem = {
- categoryId: string;
- categoryName: string;
- categoryIcon: string | null;
- currentAmount: number;
- previousAmount: number;
- percentageChange: number | null;
- percentageOfTotal: number;
- budgetAmount: number | null;
- budgetUsedPercentage: number | null;
-};
-
-export type IncomeByCategoryData = {
- categories: CategoryIncomeItem[];
- currentTotal: number;
- previousTotal: number;
-};
+export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
+export type IncomeByCategoryData = DashboardCategoryBreakdownData;
export async function fetchIncomeByCategory(
userId: string,
@@ -54,21 +43,12 @@ export async function fetchIncomeByCategory(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
- eq(lancamentos.userId, userId),
- eq(lancamentos.pagadorId, adminPagadorId),
+ ...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Receita"),
eq(categorias.type, "receita"),
- or(
- isNull(lancamentos.note),
- sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
- ),
- // Excluir saldos iniciais se a conta tiver o flag ativo
- or(
- ne(lancamentos.note, INITIAL_BALANCE_NOTE),
- isNull(contas.excludeInitialBalanceFromIncome),
- eq(contas.excludeInitialBalanceFromIncome, false),
- ),
+ excludeAutoInvoiceEntries(),
+ excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(
@@ -86,85 +66,9 @@ export async function fetchIncomeByCategory(
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
- // Build budget lookup
- const budgetMap = new Map();
- for (const row of budgetRows) {
- if (row.categoriaId) {
- budgetMap.set(row.categoriaId, safeToNumber(row.amount));
- }
- }
-
- // Build category data from grouped results
- const categoryMap = new Map<
- string,
- {
- name: string;
- icon: string | null;
- current: number;
- previous: number;
- }
- >();
-
- for (const row of rows) {
- const entry = categoryMap.get(row.categoryId) ?? {
- name: row.categoryName,
- icon: row.categoryIcon,
- current: 0,
- previous: 0,
- };
-
- const amount = Math.abs(safeToNumber(row.total));
- if (row.period === period) {
- entry.current = amount;
- } else {
- entry.previous = amount;
- }
- categoryMap.set(row.categoryId, entry);
- }
-
- // Calculate totals
- let currentTotal = 0;
- let previousTotal = 0;
- for (const entry of categoryMap.values()) {
- currentTotal += entry.current;
- previousTotal += entry.previous;
- }
-
- // Build result
- const categories: CategoryIncomeItem[] = [];
- for (const [categoryId, entry] of categoryMap) {
- const percentageChange = calculatePercentageChange(
- entry.current,
- entry.previous,
- );
- const percentageOfTotal =
- currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
-
- const budgetAmount = budgetMap.get(categoryId) ?? null;
- const budgetUsedPercentage =
- budgetAmount && budgetAmount > 0
- ? (entry.current / budgetAmount) * 100
- : null;
-
- categories.push({
- categoryId,
- categoryName: entry.name,
- categoryIcon: entry.icon,
- currentAmount: entry.current,
- previousAmount: entry.previous,
- percentageChange,
- percentageOfTotal,
- budgetAmount,
- budgetUsedPercentage,
- });
- }
-
- // Ordena por valor atual (maior para menor)
- categories.sort((a, b) => b.currentAmount - a.currentAmount);
-
- return {
- categories,
- currentTotal,
- previousTotal,
- };
+ return buildCategoryBreakdownData({
+ rows,
+ budgetRows,
+ period,
+ });
}
diff --git a/lib/dashboard/common.ts b/lib/dashboard/common.ts
deleted file mode 100644
index 3b08baf..0000000
--- a/lib/dashboard/common.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { calculatePercentageChange } from "@/lib/utils/math";
-import { safeToNumber } from "@/lib/utils/number";
-
-export { safeToNumber, calculatePercentageChange };
-
-/**
- * Alias for backward compatibility - dashboard uses "toNumber" naming
- */
-export const toNumber = safeToNumber;
diff --git a/lib/dashboard/metrics.ts b/lib/dashboard/dashboard-metrics.ts
similarity index 86%
rename from lib/dashboard/metrics.ts
rename to lib/dashboard/dashboard-metrics.ts
index 05022d2..c6246f7 100644
--- a/lib/dashboard/metrics.ts
+++ b/lib/dashboard/dashboard-metrics.ts
@@ -1,21 +1,10 @@
-import {
- and,
- asc,
- eq,
- gte,
- ilike,
- isNull,
- lte,
- ne,
- not,
- or,
- sum,
-} from "drizzle-orm";
+import { and, asc, eq, gte, lte, ne, sum } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import {
- ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
- INITIAL_BALANCE_NOTE,
-} from "@/lib/contas/constants";
+ buildDashboardAdminFilters,
+ excludeAutoInvoiceEntries,
+ excludeInitialBalanceWhenConfigured,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber } from "@/lib/utils/number";
@@ -107,21 +96,12 @@ export async function fetchDashboardCardMetrics(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
- eq(lancamentos.userId, userId),
- eq(lancamentos.pagadorId, adminPagadorId),
+ ...buildDashboardAdminFilters({ userId, adminPagadorId }),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, period),
ne(lancamentos.transactionType, TRANSFERENCIA),
- or(
- isNull(lancamentos.note),
- not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
- ),
- // Excluir saldos iniciais se a conta tiver o flag ativo
- or(
- ne(lancamentos.note, INITIAL_BALANCE_NOTE),
- isNull(contas.excludeInitialBalanceFromIncome),
- eq(contas.excludeInitialBalanceFromIncome, false),
- ),
+ excludeAutoInvoiceEntries(),
+ excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType)
diff --git a/lib/dashboard/expenses/installment-analysis.ts b/lib/dashboard/expenses/installment-analysis.ts
index c65b073..c458464 100644
--- a/lib/dashboard/expenses/installment-analysis.ts
+++ b/lib/dashboard/expenses/installment-analysis.ts
@@ -4,23 +4,28 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
+import {
+ buildDateOnlyStringFromPeriodDay,
+ parseLocalDateString,
+} from "@/lib/utils/date";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
// Calcula a data de vencimento baseada no período e dia de vencimento do cartão
function calculateDueDate(period: string, dueDay: string | null): Date | null {
if (!dueDay) return null;
try {
- const [year, month] = period.split("-");
- if (!year || !month) return null;
+ const dueDateString = buildDateOnlyStringFromPeriodDay(period, dueDay);
+ if (!dueDateString) return null;
- const day = parseInt(dueDay, 10);
- if (Number.isNaN(day)) return null;
+ const dueDate = parseLocalDateString(dueDateString);
+ if (Number.isNaN(dueDate.getTime())) return null;
- // Criar data ao meio-dia para evitar problemas de timezone
- return new Date(parseInt(year, 10), parseInt(month, 10) - 1, day, 12, 0, 0);
+ // Meio-dia evita drift visual em serialização/locales diferentes.
+ dueDate.setHours(12, 0, 0, 0);
+ return dueDate;
} catch {
return null;
}
diff --git a/lib/dashboard/expenses/installment-expenses.ts b/lib/dashboard/expenses/installment-expenses.ts
index 939e38d..5b14b6d 100644
--- a/lib/dashboard/expenses/installment-expenses.ts
+++ b/lib/dashboard/expenses/installment-expenses.ts
@@ -4,9 +4,9 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
export type InstallmentExpense = {
id: string;
diff --git a/lib/dashboard/expenses/recurring-expenses.ts b/lib/dashboard/expenses/recurring-expenses.ts
index 94e5cf7..bd11cd8 100644
--- a/lib/dashboard/expenses/recurring-expenses.ts
+++ b/lib/dashboard/expenses/recurring-expenses.ts
@@ -4,9 +4,9 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
export type RecurringExpense = {
id: string;
diff --git a/lib/dashboard/expenses/top-expenses.ts b/lib/dashboard/expenses/top-expenses.ts
index 07c8136..71bd899 100644
--- a/lib/dashboard/expenses/top-expenses.ts
+++ b/lib/dashboard/expenses/top-expenses.ts
@@ -1,12 +1,12 @@
-import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
+import { and, asc, eq } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
- ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
- INITIAL_BALANCE_NOTE,
-} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
+ buildDashboardAdminPeriodFilters,
+ excludeAutoGeneratedEntryNotes,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
export type TopExpense = {
id: string;
@@ -32,19 +32,13 @@ export async function fetchTopExpenses(
}
const conditions = [
- eq(lancamentos.userId, userId),
- eq(lancamentos.period, period),
+ ...buildDashboardAdminPeriodFilters({
+ userId,
+ period,
+ adminPagadorId,
+ }),
eq(lancamentos.transactionType, "Despesa"),
- eq(lancamentos.pagadorId, adminPagadorId),
- or(
- isNull(lancamentos.note),
- and(
- sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
- sql`${
- lancamentos.note
- } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
- ),
- ),
+ excludeAutoGeneratedEntryNotes(),
];
// Se cardOnly for true, filtra apenas pagamentos com cartão
@@ -72,7 +66,7 @@ export async function fetchTopExpenses(
.limit(10);
const expenses = results.map(
- (row): TopExpense => ({
+ (row: (typeof results)[number]): TopExpense => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
diff --git a/lib/dashboard/fetch-dashboard-data.ts b/lib/dashboard/fetch-dashboard-data.ts
index 989a535..fdb71e5 100644
--- a/lib/dashboard/fetch-dashboard-data.ts
+++ b/lib/dashboard/fetch-dashboard-data.ts
@@ -1,15 +1,15 @@
import { unstable_cache } from "next/cache";
import { fetchDashboardAccounts } from "./accounts";
-import { fetchDashboardBoletos } from "./boletos";
+import { fetchDashboardBills } from "./bills";
import { fetchExpensesByCategory } from "./categories/expenses-by-category";
import { fetchIncomeByCategory } from "./categories/income-by-category";
+import { fetchDashboardCardMetrics } from "./dashboard-metrics";
import { fetchInstallmentExpenses } from "./expenses/installment-expenses";
import { fetchRecurringExpenses } from "./expenses/recurring-expenses";
import { fetchTopExpenses } from "./expenses/top-expenses";
import { fetchGoalsProgressData } from "./goals-progress";
import { fetchIncomeExpenseBalance } from "./income-expense-balance";
import { fetchDashboardInvoices } from "./invoices";
-import { fetchDashboardCardMetrics } from "./metrics";
import { fetchDashboardNotes } from "./notes";
import { fetchDashboardPagadores } from "./pagadores";
import { fetchPaymentConditions } from "./payments/payment-conditions";
@@ -23,7 +23,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
metrics,
accountsSnapshot,
invoicesSnapshot,
- boletosSnapshot,
+ billsSnapshot,
goalsProgressData,
paymentStatusData,
incomeExpenseBalanceData,
@@ -43,7 +43,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
fetchDashboardCardMetrics(userId, period),
fetchDashboardAccounts(userId),
fetchDashboardInvoices(userId, period),
- fetchDashboardBoletos(userId, period),
+ fetchDashboardBills(userId, period),
fetchGoalsProgressData(userId, period),
fetchPaymentStatus(userId, period),
fetchIncomeExpenseBalance(userId, period),
@@ -65,7 +65,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
metrics,
accountsSnapshot,
invoicesSnapshot,
- boletosSnapshot,
+ billsSnapshot,
goalsProgressData,
paymentStatusData,
incomeExpenseBalanceData,
@@ -95,7 +95,7 @@ export function fetchDashboardData(userId: string, period: string) {
[`dashboard-${userId}-${period}`],
{
tags: ["dashboard", `dashboard-${userId}`],
- revalidate: 120,
+ revalidate: 60,
},
)();
}
diff --git a/lib/dashboard/goals-progress-helpers.ts b/lib/dashboard/goals-progress-helpers.ts
new file mode 100644
index 0000000..75b240f
--- /dev/null
+++ b/lib/dashboard/goals-progress-helpers.ts
@@ -0,0 +1,45 @@
+import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
+import type {
+ GoalProgressCategory,
+ GoalProgressItem,
+ GoalProgressStatus,
+} from "@/lib/dashboard/goals-progress";
+import { formatPercentage } from "@/lib/utils/percentage";
+
+export const clampGoalProgress = (value: number, min: number, max: number) =>
+ Math.min(max, Math.max(min, value));
+
+export const formatGoalProgressPercentage = (value: number, withSign = false) =>
+ formatPercentage(value, {
+ maximumFractionDigits: 1,
+ signDisplay: withSign ? "always" : "auto",
+ });
+
+export const getGoalProgressStatusColorClass = (status: GoalProgressStatus) =>
+ status === "exceeded" ? "text-destructive" : "";
+
+export const mapGoalProgressCategoriesToBudgetCategories = (
+ categories: GoalProgressCategory[],
+): BudgetCategory[] =>
+ categories.map((category) => ({
+ id: category.id,
+ name: category.name,
+ icon: category.icon,
+ }));
+
+export const mapGoalProgressItemToBudget = (
+ item: GoalProgressItem,
+): Budget => ({
+ id: item.id,
+ amount: item.budgetAmount,
+ spent: item.spentAmount,
+ period: item.period,
+ createdAt: item.createdAt,
+ category: item.categoryId
+ ? {
+ id: item.categoryId,
+ name: item.categoryName,
+ icon: item.categoryIcon,
+ }
+ : null,
+});
diff --git a/lib/dashboard/goals-progress.ts b/lib/dashboard/goals-progress.ts
index 3142a57..a1d5a86 100644
--- a/lib/dashboard/goals-progress.ts
+++ b/lib/dashboard/goals-progress.ts
@@ -1,8 +1,8 @@
import { and, eq, ne, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
const BUDGET_CRITICAL_THRESHOLD = 80;
diff --git a/lib/dashboard/income-expense-balance.ts b/lib/dashboard/income-expense-balance.ts
index 9cabbab..9c9cb66 100644
--- a/lib/dashboard/income-expense-balance.ts
+++ b/lib/dashboard/income-expense-balance.ts
@@ -1,12 +1,18 @@
-import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
+import { and, eq, inArray, sql } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import {
- ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
- INITIAL_BALANCE_NOTE,
-} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
+ buildDashboardAdminFilters,
+ excludeAutoInvoiceEntries,
+ excludeInitialBalanceWhenConfigured,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
+import {
+ buildPeriodWindow,
+ formatPeriodMonthShort,
+ getCurrentPeriod,
+} from "@/lib/utils/period";
export type MonthData = {
month: string;
@@ -20,47 +26,12 @@ export type IncomeExpenseBalanceData = {
months: MonthData[];
};
-const MONTH_LABELS: Record = {
- "01": "jan",
- "02": "fev",
- "03": "mar",
- "04": "abr",
- "05": "mai",
- "06": "jun",
- "07": "jul",
- "08": "ago",
- "09": "set",
- "10": "out",
- "11": "nov",
- "12": "dez",
-};
-
const generateLast6Months = (currentPeriod: string): string[] => {
- const [yearStr, monthStr] = currentPeriod.split("-");
- let year = Number.parseInt(yearStr ?? "", 10);
- let month = Number.parseInt(monthStr ?? "", 10);
-
- if (Number.isNaN(year) || Number.isNaN(month)) {
- const now = new Date();
- year = now.getFullYear();
- month = now.getMonth() + 1;
+ try {
+ return buildPeriodWindow(currentPeriod, 6);
+ } catch {
+ return buildPeriodWindow(getCurrentPeriod(), 6);
}
-
- const periods: string[] = [];
-
- for (let i = 5; i >= 0; i--) {
- let targetMonth = month - i;
- let targetYear = year;
-
- while (targetMonth <= 0) {
- targetMonth += 12;
- targetYear -= 1;
- }
-
- periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`);
- }
-
- return periods;
};
export async function fetchIncomeExpenseBalance(
@@ -85,17 +56,11 @@ export async function fetchIncomeExpenseBalance(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
- eq(lancamentos.userId, userId),
- eq(lancamentos.pagadorId, adminPagadorId),
+ ...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, periods),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
- sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
- // Excluir saldos iniciais se a conta tiver o flag ativo
- or(
- ne(lancamentos.note, INITIAL_BALANCE_NOTE),
- isNull(contas.excludeInitialBalanceFromIncome),
- eq(contas.excludeInitialBalanceFromIncome, false),
- ),
+ excludeAutoInvoiceEntries(),
+ excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType);
@@ -117,12 +82,10 @@ export async function fetchIncomeExpenseBalance(
// Build result array preserving period order
const months = periods.map((period) => {
const entry = dataMap.get(period) ?? { income: 0, expense: 0 };
- const [, monthPart] = period.split("-");
- const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
return {
month: period,
- monthLabel: monthLabel ?? "",
+ monthLabel: formatPeriodMonthShort(period).toLowerCase(),
income: entry.income,
expense: entry.expense,
balance: entry.income - entry.expense,
diff --git a/lib/dashboard/installment-expenses-helpers.ts b/lib/dashboard/installment-expenses-helpers.ts
new file mode 100644
index 0000000..345374a
--- /dev/null
+++ b/lib/dashboard/installment-expenses-helpers.ts
@@ -0,0 +1,116 @@
+import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
+import {
+ calculateLastInstallmentDate,
+ formatLastInstallmentDate,
+} from "@/lib/installments/utils";
+
+export type InstallmentExpenseDisplay = {
+ compactLabel: string | null;
+ isLast: boolean;
+ remainingInstallments: number;
+ remainingAmount: number;
+ endDate: string | null;
+ progress: number;
+};
+
+export const buildInstallmentCompactLabel = (
+ currentInstallment: number | null,
+ installmentCount: number | null,
+) => {
+ if (currentInstallment && installmentCount) {
+ return `${currentInstallment} de ${installmentCount}`;
+ }
+
+ return null;
+};
+
+export const isInstallmentLast = (
+ currentInstallment: number | null,
+ installmentCount: number | null,
+) => {
+ if (!currentInstallment || !installmentCount) {
+ return false;
+ }
+
+ return currentInstallment === installmentCount && installmentCount > 1;
+};
+
+export const calculateInstallmentRemainingCount = (
+ currentInstallment: number | null,
+ installmentCount: number | null,
+) => {
+ if (!currentInstallment || !installmentCount) {
+ return 0;
+ }
+
+ return Math.max(0, installmentCount - currentInstallment);
+};
+
+export const calculateInstallmentRemainingAmount = (
+ amount: number,
+ currentInstallment: number | null,
+ installmentCount: number | null,
+) =>
+ amount *
+ calculateInstallmentRemainingCount(currentInstallment, installmentCount);
+
+export const formatInstallmentEndDate = (
+ period: string,
+ currentInstallment: number | null,
+ installmentCount: number | null,
+) => {
+ if (!currentInstallment || !installmentCount) {
+ return null;
+ }
+
+ const lastDate = calculateLastInstallmentDate(
+ period,
+ currentInstallment,
+ installmentCount,
+ );
+
+ return formatLastInstallmentDate(lastDate);
+};
+
+export const buildInstallmentProgress = (
+ currentInstallment: number | null,
+ installmentCount: number | null,
+) => {
+ if (!currentInstallment || !installmentCount || installmentCount <= 0) {
+ return 0;
+ }
+
+ return Math.min(
+ 100,
+ Math.max(0, (currentInstallment / installmentCount) * 100),
+ );
+};
+
+export const buildInstallmentExpenseDisplay = (
+ expense: InstallmentExpense,
+): InstallmentExpenseDisplay => {
+ const { amount, currentInstallment, installmentCount, period } = expense;
+
+ return {
+ compactLabel: buildInstallmentCompactLabel(
+ currentInstallment,
+ installmentCount,
+ ),
+ isLast: isInstallmentLast(currentInstallment, installmentCount),
+ remainingInstallments: calculateInstallmentRemainingCount(
+ currentInstallment,
+ installmentCount,
+ ),
+ remainingAmount: calculateInstallmentRemainingAmount(
+ amount,
+ currentInstallment,
+ installmentCount,
+ ),
+ endDate: formatInstallmentEndDate(
+ period,
+ currentInstallment,
+ installmentCount,
+ ),
+ progress: buildInstallmentProgress(currentInstallment, installmentCount),
+ };
+};
diff --git a/lib/dashboard/invoices-helpers.ts b/lib/dashboard/invoices-helpers.ts
new file mode 100644
index 0000000..f3303bd
--- /dev/null
+++ b/lib/dashboard/invoices-helpers.ts
@@ -0,0 +1,104 @@
+import type { DashboardInvoice } from "@/lib/dashboard/invoices";
+import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller";
+import {
+ INVOICE_PAYMENT_STATUS,
+ type InvoicePaymentStatus,
+} from "@/lib/faturas";
+import { getBusinessDateString } from "@/lib/utils/date";
+import {
+ buildDueDateInfoFromPeriodDay,
+ formatFinancialDateLabel,
+} from "@/lib/utils/financial-dates";
+import { formatPercentage } from "@/lib/utils/percentage";
+import { formatPeriodForUrl } from "@/lib/utils/period";
+
+export type InvoiceDialogState = PaymentDialogState;
+export type InvoiceLogoTone = "muted" | "accent";
+
+type InvoicePaymentDateInfo = {
+ label: string;
+};
+
+type InvoiceDueDateInfo = {
+ label: string;
+ date: string | null;
+};
+
+export const buildInvoiceInitials = (value: string) => {
+ const parts = value.trim().split(/\s+/).filter(Boolean);
+ if (parts.length === 0) {
+ return "CC";
+ }
+ if (parts.length === 1) {
+ const firstPart = parts[0];
+ return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
+ }
+ const firstChar = parts[0]?.[0] ?? "";
+ const secondChar = parts[1]?.[0] ?? "";
+ return `${firstChar}${secondChar}`.toUpperCase() || "CC";
+};
+
+export const parseInvoiceDueDate = (
+ period: string,
+ dueDay: string,
+): InvoiceDueDateInfo => {
+ return buildDueDateInfoFromPeriodDay(period, dueDay);
+};
+
+export const formatInvoicePaymentDate = (
+ value: string | null,
+): InvoicePaymentDateInfo | null => {
+ const label = formatFinancialDateLabel(value, "Pago em");
+ if (!label) {
+ return null;
+ }
+
+ return {
+ label,
+ };
+};
+
+export const getCurrentDateString = () => getBusinessDateString();
+
+const formatInvoiceSharePercentage = (value: number) => {
+ if (!Number.isFinite(value) || value <= 0) {
+ return "0%";
+ }
+ const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
+ return formatPercentage(value, {
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits,
+ });
+};
+
+export const getInvoiceShareLabel = (amount: number, total: number) => {
+ if (total <= 0) {
+ return "0% do total";
+ }
+ const percentage = (amount / total) * 100;
+ return `${formatInvoiceSharePercentage(percentage)} do total`;
+};
+
+export const getInvoiceStatusBadgeVariant = (
+ statusLabel: string,
+): "success" | "info" => {
+ if (statusLabel.toLowerCase() === "em aberto") {
+ return "info";
+ }
+ return "success";
+};
+
+export const buildInvoiceDetailsHref = (cardId: string, period: string) =>
+ `/cartoes/${cardId}/fatura?periodo=${formatPeriodForUrl(period)}`;
+
+export const markInvoiceAsPaid = (
+ invoice: DashboardInvoice,
+ paidAt: string,
+): DashboardInvoice => ({
+ ...invoice,
+ paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
+ paidAt,
+});
+
+export const isInvoicePaid = (status: InvoicePaymentStatus) =>
+ status === INVOICE_PAYMENT_STATUS.PAID;
diff --git a/lib/dashboard/invoices.ts b/lib/dashboard/invoices.ts
index b19a0e6..a8287e9 100644
--- a/lib/dashboard/invoices.ts
+++ b/lib/dashboard/invoices.ts
@@ -1,13 +1,14 @@
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
} from "@/lib/faturas";
+import { toDateOnlyString } from "@/lib/utils/date";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
type RawDashboardInvoice = {
invoiceId: string | null;
@@ -24,6 +25,15 @@ type RawDashboardInvoice = {
invoiceCreatedAt: Date | null;
};
+type RawInvoiceBreakdownRow = {
+ cardId: string | null;
+ period: string | null;
+ pagadorId: string | null;
+ pagadorName: string | null;
+ pagadorAvatar: string | null;
+ amount: number | string | null;
+};
+
export type InvoicePagadorBreakdown = {
pagadorId: string | null;
pagadorName: string;
@@ -51,22 +61,6 @@ export type DashboardInvoicesSnapshot = {
totalPending: number;
};
-const toISODate = (value: Date | string | null | undefined) => {
- if (!value) {
- return null;
- }
-
- if (value instanceof Date) {
- return value.toISOString().slice(0, 10);
- }
-
- if (typeof value === "string") {
- return value.slice(0, 10);
- }
-
- return null;
-};
-
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
typeof value === "string" &&
(INVOICE_STATUS_VALUES as string[]).includes(value);
@@ -113,7 +107,7 @@ export async function fetchDashboardInvoices(
!Number.isNaN(row.purchaseDate.valueOf())
? row.purchaseDate
: row.createdAt;
- const isoDate = toISODate(resolvedDate);
+ const isoDate = toDateOnlyString(resolvedDate);
if (!isoDate) {
continue;
}
@@ -123,7 +117,10 @@ export async function fetchDashboardInvoices(
}
}
- const [rows, breakdownRows] = await Promise.all([
+ const [rows, breakdownRows]: [
+ RawDashboardInvoice[],
+ RawInvoiceBreakdownRow[],
+ ] = await Promise.all([
db
.select({
invoiceId: faturas.id,
@@ -216,54 +213,57 @@ export async function fetchDashboardInvoices(
breakdownMap.set(key, current);
}
- const invoices = rows
- .map((row: RawDashboardInvoice | null) => {
- if (!row) return null;
+ const invoices: DashboardInvoice[] = [];
- const totalAmount = toNumber(row.totalAmount);
- const transactionCount = toNumber(row.transactionCount);
- const paymentStatus = isInvoiceStatus(row.paymentStatus)
- ? row.paymentStatus
- : INVOICE_PAYMENT_STATUS.PENDING;
+ for (const row of rows) {
+ if (!row) {
+ continue;
+ }
- const shouldInclude =
- transactionCount > 0 ||
- Math.abs(totalAmount) > 0 ||
- row.invoiceId !== null;
+ const totalAmount = toNumber(row.totalAmount);
+ const transactionCount = toNumber(row.transactionCount);
+ const paymentStatus = isInvoiceStatus(row.paymentStatus)
+ ? row.paymentStatus
+ : INVOICE_PAYMENT_STATUS.PENDING;
- if (!shouldInclude) {
- return null;
- }
+ const shouldInclude =
+ transactionCount > 0 ||
+ Math.abs(totalAmount) > 0 ||
+ row.invoiceId !== null;
- const resolvedPeriod = row.period ?? period;
- const paymentKey = `${row.cardId}:${resolvedPeriod}`;
- const paidAt =
- paymentStatus === INVOICE_PAYMENT_STATUS.PAID
- ? (paymentMap.get(paymentKey) ?? toISODate(row.invoiceCreatedAt))
- : null;
+ if (!shouldInclude) {
+ continue;
+ }
- return {
- id: row.invoiceId ?? buildFallbackId(row.cardId, period),
- cardId: row.cardId,
- cardName: row.cardName,
- cardBrand: row.cardBrand,
- cardStatus: row.cardStatus,
- logo: row.logo,
- dueDay: row.dueDay,
- period: resolvedPeriod,
- paymentStatus,
- totalAmount,
- paidAt,
- pagadorBreakdown: (
- breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
- ).sort((a, b) => b.amount - a.amount),
- } satisfies DashboardInvoice;
- })
- .filter((invoice): invoice is DashboardInvoice => invoice !== null)
- .sort((a, b) => {
- // Ordena do maior valor para o menor
- return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
+ const resolvedPeriod = row.period ?? period;
+ const paymentKey = `${row.cardId}:${resolvedPeriod}`;
+ const paidAt =
+ paymentStatus === INVOICE_PAYMENT_STATUS.PAID
+ ? (paymentMap.get(paymentKey) ?? toDateOnlyString(row.invoiceCreatedAt))
+ : null;
+
+ invoices.push({
+ id: row.invoiceId ?? buildFallbackId(row.cardId, period),
+ cardId: row.cardId,
+ cardName: row.cardName,
+ cardBrand: row.cardBrand,
+ cardStatus: row.cardStatus,
+ logo: row.logo,
+ dueDay: row.dueDay,
+ period: resolvedPeriod,
+ paymentStatus,
+ totalAmount,
+ paidAt,
+ pagadorBreakdown: (
+ breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
+ ).sort((a, b) => b.amount - a.amount),
});
+ }
+
+ invoices.sort((a, b) => {
+ // Ordena do maior valor para o menor
+ return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
+ });
const totalPending = invoices.reduce((total, invoice) => {
if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) {
diff --git a/lib/dashboard/lancamento-filters.ts b/lib/dashboard/lancamento-filters.ts
new file mode 100644
index 0000000..3ba7440
--- /dev/null
+++ b/lib/dashboard/lancamento-filters.ts
@@ -0,0 +1,56 @@
+import { and, eq, ilike, isNull, ne, not, or } from "drizzle-orm";
+import { contas, lancamentos } from "@/db/schema";
+import {
+ ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
+ INITIAL_BALANCE_NOTE,
+} from "@/lib/contas/constants";
+
+type DashboardAdminFiltersParams = {
+ userId: string;
+ adminPagadorId: string;
+};
+
+type DashboardAdminPeriodFiltersParams = DashboardAdminFiltersParams & {
+ period: string;
+};
+
+export const buildDashboardAdminFilters = ({
+ userId,
+ adminPagadorId,
+}: DashboardAdminFiltersParams) =>
+ [
+ eq(lancamentos.userId, userId),
+ eq(lancamentos.pagadorId, adminPagadorId),
+ ] as const;
+
+export const buildDashboardAdminPeriodFilters = ({
+ userId,
+ period,
+ adminPagadorId,
+}: DashboardAdminPeriodFiltersParams) =>
+ [
+ ...buildDashboardAdminFilters({ userId, adminPagadorId }),
+ eq(lancamentos.period, period),
+ ] as const;
+
+export const excludeAutoInvoiceEntries = () =>
+ or(
+ isNull(lancamentos.note),
+ not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
+ );
+
+export const excludeAutoGeneratedEntryNotes = () =>
+ or(
+ isNull(lancamentos.note),
+ and(
+ ne(lancamentos.note, INITIAL_BALANCE_NOTE),
+ not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
+ ),
+ );
+
+export const excludeInitialBalanceWhenConfigured = () =>
+ or(
+ ne(lancamentos.note, INITIAL_BALANCE_NOTE),
+ isNull(contas.excludeInitialBalanceFromIncome),
+ eq(contas.excludeInitialBalanceFromIncome, false),
+ );
diff --git a/lib/dashboard/notes-mappers.ts b/lib/dashboard/notes-mappers.ts
new file mode 100644
index 0000000..9dba6fc
--- /dev/null
+++ b/lib/dashboard/notes-mappers.ts
@@ -0,0 +1,15 @@
+import type { Note } from "@/components/anotacoes/types";
+import type { DashboardNote } from "@/lib/dashboard/notes";
+
+export const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
+ id: note.id,
+ title: note.title,
+ description: note.description,
+ type: note.type,
+ tasks: note.tasks,
+ arquivada: note.arquivada,
+ createdAt: note.createdAt,
+});
+
+export const mapDashboardNotesToNotes = (notes: DashboardNote[]) =>
+ notes.map(mapDashboardNoteToNote);
diff --git a/lib/dashboard/notifications.ts b/lib/dashboard/notifications.ts
index b6819c1..8b3ec01 100644
--- a/lib/dashboard/notifications.ts
+++ b/lib/dashboard/notifications.ts
@@ -11,6 +11,14 @@ import {
import { db } from "@/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import {
+ buildDateOnlyStringFromPeriodDay,
+ getBusinessDateString,
+ isDateOnlyPast,
+ isDateOnlyWithinDays,
+ toDateOnlyString,
+} from "@/lib/utils/date";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
export type NotificationType = "overdue" | "due_soon";
@@ -46,100 +54,6 @@ export type DashboardNotificationsSnapshot = {
const PAYMENT_METHOD_BOLETO = "Boleto";
const BUDGET_CRITICAL_THRESHOLD = 80;
-/**
- * Calcula a data de vencimento de uma fatura baseado no período e dia de vencimento
- * @param period Período no formato YYYY-MM
- * @param dueDay Dia do vencimento (1-31)
- * @returns Data de vencimento no formato YYYY-MM-DD
- */
-function calculateDueDate(period: string, dueDay: string): string {
- const [year, month] = period.split("-");
- const yearNumber = Number(year);
- const monthNumber = Number(month);
- const hasValidMonth =
- Number.isInteger(yearNumber) &&
- Number.isInteger(monthNumber) &&
- monthNumber >= 1 &&
- monthNumber <= 12;
-
- const daysInMonth = hasValidMonth
- ? new Date(yearNumber, monthNumber, 0).getDate()
- : null;
-
- const dueDayNumber = Number(dueDay);
- const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0;
-
- const clampedDay =
- hasValidMonth && hasValidDueDay && daysInMonth
- ? Math.min(dueDayNumber, daysInMonth)
- : hasValidDueDay
- ? dueDayNumber
- : null;
-
- const day = clampedDay
- ? String(clampedDay).padStart(2, "0")
- : dueDay.padStart(2, "0");
-
- const normalizedMonth =
- hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month;
-
- return `${year}-${normalizedMonth}-${day}`;
-}
-
-/**
- * Normaliza uma data para o início do dia em UTC (00:00:00)
- */
-function normalizeDate(date: Date): Date {
- return new Date(
- Date.UTC(
- date.getUTCFullYear(),
- date.getUTCMonth(),
- date.getUTCDate(),
- 0,
- 0,
- 0,
- 0,
- ),
- );
-}
-
-/**
- * Converte string "YYYY-MM-DD" para Date em UTC (evita problemas de timezone)
- */
-function parseUTCDate(dateString: string): Date {
- const [year, month, day] = dateString.split("-").map(Number);
- return new Date(Date.UTC(year, month - 1, day));
-}
-
-/**
- * Verifica se uma data está atrasada (antes do dia atual, não incluindo hoje)
- */
-function isOverdue(dueDate: string, today: Date): boolean {
- const due = parseUTCDate(dueDate);
- const dueNormalized = normalizeDate(due);
- return dueNormalized < today;
-}
-
-/**
- * Verifica se uma data vence nos próximos X dias (incluindo hoje)
- */
-function isDueWithinDays(
- dueDate: string,
- today: Date,
- daysThreshold: number,
-): boolean {
- const due = parseUTCDate(dueDate);
- const dueNormalized = normalizeDate(due);
- const limitDate = new Date(today);
- limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
- return dueNormalized >= today && dueNormalized <= limitDate;
-}
-
-function toNum(value: unknown): number {
- if (typeof value === "number") return value;
- return Number(value) || 0;
-}
-
/**
* Busca todas as notificações do dashboard:
* - Faturas de cartão atrasadas ou com vencimento próximo
@@ -150,7 +64,7 @@ export async function fetchDashboardNotifications(
userId: string,
currentPeriod: string,
): Promise {
- const today = normalizeDate(new Date());
+ const today = getBusinessDateString();
const DAYS_THRESHOLD = 5;
const adminPagadorId = await getAdminPagadorId(userId);
@@ -285,8 +199,12 @@ export async function fetchDashboardNotifications(
// Faturas atrasadas (períodos anteriores)
for (const invoice of overdueInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
- const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
- const amount = toNum(invoice.totalAmount);
+ const dueDate = buildDateOnlyStringFromPeriodDay(
+ invoice.period,
+ invoice.dueDay,
+ );
+ if (!dueDate) continue;
+ const amount = toNumber(invoice.totalAmount);
const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`;
@@ -307,8 +225,13 @@ export async function fetchDashboardNotifications(
// Faturas do período atual
for (const invoice of currentInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
- const amount = toNum(invoice.totalAmount);
- const transactionCount = toNum(invoice.transactionCount);
+ const dueDate = buildDateOnlyStringFromPeriodDay(
+ invoice.period,
+ invoice.dueDay,
+ );
+ if (!dueDate) continue;
+ const amount = toNumber(invoice.totalAmount);
+ const transactionCount = toNumber(invoice.transactionCount);
const paymentStatus =
invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
@@ -319,9 +242,12 @@ export async function fetchDashboardNotifications(
if (!shouldInclude) continue;
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
- const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
- const invoiceIsOverdue = isOverdue(dueDate, today);
- const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
+ const invoiceIsOverdue = isDateOnlyPast(dueDate, today);
+ const invoiceIsDueSoon = isDateOnlyWithinDays(
+ dueDate,
+ DAYS_THRESHOLD,
+ today,
+ );
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
const notificationId = invoice.invoiceId
@@ -343,17 +269,18 @@ export async function fetchDashboardNotifications(
// Boletos
for (const boleto of boletosRows) {
- if (!boleto.dueDate) continue;
- const dueDate =
- boleto.dueDate instanceof Date
- ? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}`
- : boleto.dueDate;
+ const dueDate = toDateOnlyString(boleto.dueDate);
+ if (!dueDate) continue;
- const boletoIsOverdue = isOverdue(dueDate, today);
- const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
+ const boletoIsOverdue = isDateOnlyPast(dueDate, today);
+ const boletoIsDueSoon = isDateOnlyWithinDays(
+ dueDate,
+ DAYS_THRESHOLD,
+ today,
+ );
const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod;
- const amount = toNum(boleto.amount);
+ const amount = toNumber(boleto.amount);
if (isOldPeriod) {
notifications.push({
@@ -391,8 +318,8 @@ export async function fetchDashboardNotifications(
const budgetNotifications: BudgetNotification[] = [];
for (const row of budgetRows) {
- const budgetAmount = toNum(row.budgetAmount);
- const spentAmount = toNum(row.spentAmount);
+ const budgetAmount = toNumber(row.budgetAmount);
+ const spentAmount = toNumber(row.spentAmount);
if (budgetAmount <= 0) continue;
const usedPercentage = (spentAmount / budgetAmount) * 100;
diff --git a/lib/dashboard/pagadores.ts b/lib/dashboard/pagadores.ts
index 98d9405..cbd5839 100644
--- a/lib/dashboard/pagadores.ts
+++ b/lib/dashboard/pagadores.ts
@@ -1,10 +1,10 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { calculatePercentageChange } from "@/lib/utils/math";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
export type DashboardPagador = {
diff --git a/lib/dashboard/payment-breakdown-formatters.ts b/lib/dashboard/payment-breakdown-formatters.ts
new file mode 100644
index 0000000..bdf372c
--- /dev/null
+++ b/lib/dashboard/payment-breakdown-formatters.ts
@@ -0,0 +1,10 @@
+import { formatPercentage } from "@/lib/utils/percentage";
+
+export const formatPaymentBreakdownPercentage = (value: number) =>
+ formatPercentage(value, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 1,
+ });
+
+export const formatPaymentBreakdownTransactionsLabel = (transactions: number) =>
+ `${transactions} ${transactions === 1 ? "lançamento" : "lançamentos"}`;
diff --git a/lib/dashboard/payment-overview-tabs.ts b/lib/dashboard/payment-overview-tabs.ts
new file mode 100644
index 0000000..24ebba7
--- /dev/null
+++ b/lib/dashboard/payment-overview-tabs.ts
@@ -0,0 +1,11 @@
+export type PaymentOverviewTab = "conditions" | "methods";
+
+export const DEFAULT_PAYMENT_OVERVIEW_TAB: PaymentOverviewTab = "conditions";
+
+export const parsePaymentOverviewTab = (value: string): PaymentOverviewTab => {
+ if (value === "methods") {
+ return "methods";
+ }
+
+ return DEFAULT_PAYMENT_OVERVIEW_TAB;
+};
diff --git a/lib/dashboard/payments/payment-conditions.ts b/lib/dashboard/payments/payment-conditions.ts
index c1eb0eb..825a54e 100644
--- a/lib/dashboard/payments/payment-conditions.ts
+++ b/lib/dashboard/payments/payment-conditions.ts
@@ -1,12 +1,12 @@
-import { and, eq, isNull, or, sql } from "drizzle-orm";
+import { and, eq, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import {
- ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
- INITIAL_BALANCE_NOTE,
-} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
+ buildDashboardAdminPeriodFilters,
+ excludeAutoGeneratedEntryNotes,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
export type PaymentConditionSummary = {
condition: string;
@@ -37,22 +37,18 @@ export async function fetchPaymentConditions(
.from(lancamentos)
.where(
and(
- eq(lancamentos.userId, userId),
- eq(lancamentos.period, period),
+ ...buildDashboardAdminPeriodFilters({
+ userId,
+ period,
+ adminPagadorId,
+ }),
eq(lancamentos.transactionType, "Despesa"),
- eq(lancamentos.pagadorId, adminPagadorId),
- or(
- isNull(lancamentos.note),
- and(
- sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
- sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
- ),
- ),
+ excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.condition);
- const summaries = rows.map((row) => {
+ const summaries = rows.map((row: (typeof rows)[number]) => {
const totalAmount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
@@ -63,10 +59,13 @@ export async function fetchPaymentConditions(
};
});
- const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
+ const overallTotal = summaries.reduce(
+ (acc: number, item: (typeof summaries)[number]) => acc + item.amount,
+ 0,
+ );
const conditions = summaries
- .map((item) => ({
+ .map((item: (typeof summaries)[number]) => ({
condition: item.condition,
amount: item.amount,
transactions: item.transactions,
@@ -75,7 +74,10 @@ export async function fetchPaymentConditions(
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
- .sort((a, b) => b.amount - a.amount);
+ .sort(
+ (a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
+ b.amount - a.amount,
+ );
return {
conditions,
diff --git a/lib/dashboard/payments/payment-methods.ts b/lib/dashboard/payments/payment-methods.ts
index 38a8583..7616568 100644
--- a/lib/dashboard/payments/payment-methods.ts
+++ b/lib/dashboard/payments/payment-methods.ts
@@ -1,12 +1,12 @@
-import { and, eq, isNull, or, sql } from "drizzle-orm";
+import { and, eq, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import {
- ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
- INITIAL_BALANCE_NOTE,
-} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
+ buildDashboardAdminPeriodFilters,
+ excludeAutoGeneratedEntryNotes,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
export type PaymentMethodSummary = {
paymentMethod: string;
@@ -37,22 +37,18 @@ export async function fetchPaymentMethods(
.from(lancamentos)
.where(
and(
- eq(lancamentos.userId, userId),
- eq(lancamentos.period, period),
+ ...buildDashboardAdminPeriodFilters({
+ userId,
+ period,
+ adminPagadorId,
+ }),
eq(lancamentos.transactionType, "Despesa"),
- eq(lancamentos.pagadorId, adminPagadorId),
- or(
- isNull(lancamentos.note),
- and(
- sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
- sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
- ),
- ),
+ excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.paymentMethod);
- const summaries = rows.map((row) => {
+ const summaries = rows.map((row: (typeof rows)[number]) => {
const amount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
@@ -63,10 +59,13 @@ export async function fetchPaymentMethods(
};
});
- const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
+ const overallTotal = summaries.reduce(
+ (acc: number, item: (typeof summaries)[number]) => acc + item.amount,
+ 0,
+ );
const methods = summaries
- .map((item) => ({
+ .map((item: (typeof summaries)[number]) => ({
paymentMethod: item.paymentMethod,
amount: item.amount,
transactions: item.transactions,
@@ -75,7 +74,10 @@ export async function fetchPaymentMethods(
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
- .sort((a, b) => b.amount - a.amount);
+ .sort(
+ (a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
+ b.amount - a.amount,
+ );
return {
methods,
diff --git a/lib/dashboard/payments/payment-status.ts b/lib/dashboard/payments/payment-status.ts
index 2839a59..8cec7dd 100644
--- a/lib/dashboard/payments/payment-status.ts
+++ b/lib/dashboard/payments/payment-status.ts
@@ -1,9 +1,12 @@
-import { and, eq, inArray, sql } from "drizzle-orm";
+import { and, inArray, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
-import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
+import {
+ buildDashboardAdminPeriodFilters,
+ excludeAutoInvoiceEntries,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
export type PaymentStatusCategory = {
total: number;
@@ -51,11 +54,13 @@ export async function fetchPaymentStatus(
.from(lancamentos)
.where(
and(
- eq(lancamentos.userId, userId),
- eq(lancamentos.period, period),
- eq(lancamentos.pagadorId, adminPagadorId),
+ ...buildDashboardAdminPeriodFilters({
+ userId,
+ period,
+ adminPagadorId,
+ }),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
- sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
+ excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.transactionType);
diff --git a/lib/dashboard/purchases-by-category.ts b/lib/dashboard/purchases-by-category.ts
index 2d266c0..ffc0500 100644
--- a/lib/dashboard/purchases-by-category.ts
+++ b/lib/dashboard/purchases-by-category.ts
@@ -1,12 +1,12 @@
-import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
+import { and, desc, eq, inArray } from "drizzle-orm";
import { cartoes, categorias, contas, lancamentos } from "@/db/schema";
import {
- ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
- INITIAL_BALANCE_NOTE,
-} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
+ buildDashboardAdminPeriodFilters,
+ excludeAutoGeneratedEntryNotes,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
export type CategoryOption = {
id: string;
@@ -68,19 +68,13 @@ export async function fetchPurchasesByCategory(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
- eq(lancamentos.userId, userId),
- eq(lancamentos.period, period),
- eq(lancamentos.pagadorId, adminPagadorId),
+ ...buildDashboardAdminPeriodFilters({
+ userId,
+ period,
+ adminPagadorId,
+ }),
inArray(categorias.type, ["despesa", "receita"]),
- or(
- isNull(lancamentos.note),
- and(
- sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
- sql`${
- lancamentos.note
- } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
- ),
- ),
+ excludeAutoGeneratedEntryNotes(),
),
)
.orderBy(desc(lancamentos.purchaseDate));
diff --git a/lib/dashboard/top-establishments.ts b/lib/dashboard/top-establishments.ts
index 93d5667..3fd9d62 100644
--- a/lib/dashboard/top-establishments.ts
+++ b/lib/dashboard/top-establishments.ts
@@ -1,12 +1,12 @@
-import { and, eq, isNull, or, sql } from "drizzle-orm";
+import { and, eq, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
- ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
- INITIAL_BALANCE_NOTE,
-} from "@/lib/contas/constants";
-import { toNumber } from "@/lib/dashboard/common";
+ buildDashboardAdminPeriodFilters,
+ excludeAutoGeneratedEntryNotes,
+} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
+import { safeToNumber as toNumber } from "@/lib/utils/number";
export type TopEstablishment = {
id: string;
@@ -55,17 +55,13 @@ export async function fetchTopEstablishments(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
- eq(lancamentos.userId, userId),
- eq(lancamentos.period, period),
+ ...buildDashboardAdminPeriodFilters({
+ userId,
+ period,
+ adminPagadorId,
+ }),
eq(lancamentos.transactionType, "Despesa"),
- eq(lancamentos.pagadorId, adminPagadorId),
- or(
- isNull(lancamentos.note),
- and(
- sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
- sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
- ),
- ),
+ excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.name)
@@ -76,9 +72,11 @@ export async function fetchTopEstablishments(
.limit(10);
const establishments = rows
- .filter((row) => shouldIncludeEstablishment(row.name))
+ .filter((row: (typeof rows)[number]) =>
+ shouldIncludeEstablishment(row.name),
+ )
.map(
- (row): TopEstablishment => ({
+ (row: (typeof rows)[number]): TopEstablishment => ({
id: row.name,
name: row.name,
amount: Math.abs(toNumber(row.totalAmount)),
diff --git a/lib/dashboard/use-bill-widget-controller.ts b/lib/dashboard/use-bill-widget-controller.ts
new file mode 100644
index 0000000..6a31463
--- /dev/null
+++ b/lib/dashboard/use-bill-widget-controller.ts
@@ -0,0 +1,46 @@
+"use client";
+
+import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions";
+import type { DashboardBill } from "@/lib/dashboard/bills";
+import {
+ type BillDialogState,
+ getCurrentBillDateString,
+ markBillAsSettled,
+} from "@/lib/dashboard/bills-helpers";
+import {
+ type PaymentDialogController,
+ usePaymentDialogController,
+} from "@/lib/dashboard/use-payment-dialog-controller";
+
+const EMPTY_BILLS: DashboardBill[] = [];
+
+export type BillWidgetController = Omit<
+ PaymentDialogController,
+ "selectedItem"
+> & {
+ selectedBill: DashboardBill | null;
+ modalState: BillDialogState;
+};
+
+export function useBillWidgetController(
+ bills?: DashboardBill[],
+): BillWidgetController {
+ const safeBills = bills ?? EMPTY_BILLS;
+ const controller = usePaymentDialogController({
+ items: safeBills,
+ getItemId: (bill) => bill.id,
+ isItemConfirmed: (bill) => bill.isSettled,
+ executeConfirm: (bill) =>
+ toggleLancamentoSettlementAction({
+ id: bill.id,
+ value: true,
+ }),
+ applyConfirmedState: (bill) =>
+ markBillAsSettled(bill, getCurrentBillDateString()),
+ });
+
+ return {
+ ...controller,
+ selectedBill: controller.selectedItem,
+ };
+}
diff --git a/lib/dashboard/use-goals-progress-widget-controller.ts b/lib/dashboard/use-goals-progress-widget-controller.ts
new file mode 100644
index 0000000..7f3f50d
--- /dev/null
+++ b/lib/dashboard/use-goals-progress-widget-controller.ts
@@ -0,0 +1,56 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
+import type {
+ GoalProgressItem,
+ GoalsProgressData,
+} from "@/lib/dashboard/goals-progress";
+import {
+ mapGoalProgressCategoriesToBudgetCategories,
+ mapGoalProgressItemToBudget,
+} from "@/lib/dashboard/goals-progress-helpers";
+
+export type GoalsProgressWidgetController = {
+ selectedBudget: Budget | null;
+ editOpen: boolean;
+ categories: BudgetCategory[];
+ defaultPeriod: string;
+ handleEdit: (item: GoalProgressItem) => void;
+ handleEditOpenChange: (open: boolean) => void;
+};
+
+export function useGoalsProgressWidgetController(
+ data: GoalsProgressData,
+): GoalsProgressWidgetController {
+ const [editOpen, setEditOpen] = useState(false);
+ const [selectedBudget, setSelectedBudget] = useState(null);
+
+ const categories = useMemo(
+ () => mapGoalProgressCategoriesToBudgetCategories(data.categories),
+ [data.categories],
+ );
+
+ const defaultPeriod = data.items[0]?.period ?? "";
+
+ const handleEdit = (item: GoalProgressItem) => {
+ setSelectedBudget(mapGoalProgressItemToBudget(item));
+ setEditOpen(true);
+ };
+
+ const handleEditOpenChange = (open: boolean) => {
+ setEditOpen(open);
+ if (!open) {
+ setSelectedBudget(null);
+ }
+ };
+
+ return {
+ selectedBudget,
+ editOpen,
+ categories,
+ defaultPeriod,
+ handleEdit,
+ handleEditOpenChange,
+ };
+}
diff --git a/lib/dashboard/use-invoices-widget-controller.ts b/lib/dashboard/use-invoices-widget-controller.ts
new file mode 100644
index 0000000..7d5c74c
--- /dev/null
+++ b/lib/dashboard/use-invoices-widget-controller.ts
@@ -0,0 +1,46 @@
+"use client";
+
+import { updateInvoicePaymentStatusAction } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
+import type { DashboardInvoice } from "@/lib/dashboard/invoices";
+import {
+ getCurrentDateString,
+ type InvoiceDialogState,
+ isInvoicePaid,
+ markInvoiceAsPaid,
+} from "@/lib/dashboard/invoices-helpers";
+import {
+ type PaymentDialogController,
+ usePaymentDialogController,
+} from "@/lib/dashboard/use-payment-dialog-controller";
+import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
+
+export type InvoicesWidgetController = Omit<
+ PaymentDialogController,
+ "selectedItem"
+> & {
+ selectedInvoice: DashboardInvoice | null;
+ modalState: InvoiceDialogState;
+};
+
+export function useInvoicesWidgetController(
+ invoices: DashboardInvoice[],
+): InvoicesWidgetController {
+ const controller = usePaymentDialogController({
+ items: invoices,
+ getItemId: (invoice) => invoice.id,
+ isItemConfirmed: (invoice) => isInvoicePaid(invoice.paymentStatus),
+ executeConfirm: (invoice) =>
+ updateInvoicePaymentStatusAction({
+ cartaoId: invoice.cardId,
+ period: invoice.period,
+ status: INVOICE_PAYMENT_STATUS.PAID,
+ }),
+ applyConfirmedState: (invoice) =>
+ markInvoiceAsPaid(invoice, getCurrentDateString()),
+ });
+
+ return {
+ ...controller,
+ selectedInvoice: controller.selectedItem,
+ };
+}
diff --git a/lib/dashboard/use-notes-widget-controller.ts b/lib/dashboard/use-notes-widget-controller.ts
new file mode 100644
index 0000000..29f4ebc
--- /dev/null
+++ b/lib/dashboard/use-notes-widget-controller.ts
@@ -0,0 +1,65 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Note } from "@/components/anotacoes/types";
+import type { DashboardNote } from "@/lib/dashboard/notes";
+import { mapDashboardNotesToNotes } from "@/lib/dashboard/notes-mappers";
+
+export type NotesWidgetController = {
+ mappedNotes: Note[];
+ noteToEdit: Note | null;
+ isEditOpen: boolean;
+ noteDetails: Note | null;
+ isDetailsOpen: boolean;
+ openEdit: (note: Note) => void;
+ openDetails: (note: Note) => void;
+ handleEditOpenChange: (open: boolean) => void;
+ handleDetailsOpenChange: (open: boolean) => void;
+};
+
+export function useNotesWidgetController(
+ notes: DashboardNote[],
+): NotesWidgetController {
+ const [noteToEdit, setNoteToEdit] = useState(null);
+ const [isEditOpen, setIsEditOpen] = useState(false);
+ const [noteDetails, setNoteDetails] = useState(null);
+ const [isDetailsOpen, setIsDetailsOpen] = useState(false);
+
+ const mappedNotes = useMemo(() => mapDashboardNotesToNotes(notes), [notes]);
+
+ const openEdit = (note: Note) => {
+ setNoteToEdit(note);
+ setIsEditOpen(true);
+ };
+
+ const openDetails = (note: Note) => {
+ setNoteDetails(note);
+ setIsDetailsOpen(true);
+ };
+
+ const handleEditOpenChange = (open: boolean) => {
+ setIsEditOpen(open);
+ if (!open) {
+ setNoteToEdit(null);
+ }
+ };
+
+ const handleDetailsOpenChange = (open: boolean) => {
+ setIsDetailsOpen(open);
+ if (!open) {
+ setNoteDetails(null);
+ }
+ };
+
+ return {
+ mappedNotes,
+ noteToEdit,
+ isEditOpen,
+ noteDetails,
+ isDetailsOpen,
+ openEdit,
+ openDetails,
+ handleEditOpenChange,
+ handleDetailsOpenChange,
+ };
+}
diff --git a/lib/dashboard/use-payment-dialog-controller.ts b/lib/dashboard/use-payment-dialog-controller.ts
new file mode 100644
index 0000000..1980861
--- /dev/null
+++ b/lib/dashboard/use-payment-dialog-controller.ts
@@ -0,0 +1,110 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect, useMemo, useState, useTransition } from "react";
+import { toast } from "sonner";
+import type { ActionResult } from "@/lib/types/actions";
+
+export type PaymentDialogState = "idle" | "processing" | "success";
+
+type UsePaymentDialogControllerOptions = {
+ items: TItem[];
+ getItemId: (item: TItem) => string;
+ isItemConfirmed: (item: TItem) => boolean;
+ executeConfirm: (item: TItem) => Promise;
+ applyConfirmedState: (item: TItem) => TItem;
+};
+
+export type PaymentDialogController = {
+ items: TItem[];
+ selectedItem: TItem | null;
+ isModalOpen: boolean;
+ modalState: PaymentDialogState;
+ isPending: boolean;
+ openPaymentDialog: (itemId: string) => void;
+ closePaymentDialog: () => void;
+ confirmPayment: () => void;
+};
+
+export function usePaymentDialogController({
+ items,
+ getItemId,
+ isItemConfirmed,
+ executeConfirm,
+ applyConfirmedState,
+}: UsePaymentDialogControllerOptions): PaymentDialogController {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const [localItems, setLocalItems] = useState(items);
+ const [selectedId, setSelectedId] = useState(null);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [modalState, setModalState] = useState("idle");
+
+ useEffect(() => {
+ setLocalItems(items);
+ }, [items]);
+
+ const selectedItem = useMemo(
+ () => localItems.find((item) => getItemId(item) === selectedId) ?? null,
+ [localItems, selectedId, getItemId],
+ );
+
+ const openPaymentDialog = (itemId: string) => {
+ setSelectedId(itemId);
+ setModalState("idle");
+ setIsModalOpen(true);
+ };
+
+ const closePaymentDialog = () => {
+ setIsModalOpen(false);
+ setSelectedId(null);
+ setModalState("idle");
+ };
+
+ const confirmPayment = () => {
+ const itemToUpdate = selectedItem;
+ if (
+ !itemToUpdate ||
+ isItemConfirmed(itemToUpdate) ||
+ modalState === "processing" ||
+ isPending
+ ) {
+ return;
+ }
+
+ const itemId = getItemId(itemToUpdate);
+ setModalState("processing");
+
+ startTransition(() => {
+ void (async () => {
+ const result = await executeConfirm(itemToUpdate);
+
+ if (!result.success) {
+ toast.error(result.error);
+ setModalState("idle");
+ return;
+ }
+
+ setLocalItems((previous) =>
+ previous.map((item) =>
+ getItemId(item) === itemId ? applyConfirmedState(item) : item,
+ ),
+ );
+ toast.success(result.message);
+ router.refresh();
+ setModalState("success");
+ })();
+ });
+ };
+
+ return {
+ items: localItems,
+ selectedItem,
+ isModalOpen,
+ modalState,
+ isPending,
+ openPaymentDialog,
+ closePaymentDialog,
+ confirmPayment,
+ };
+}
diff --git a/lib/dashboard/use-payment-overview-widget-controller.ts b/lib/dashboard/use-payment-overview-widget-controller.ts
new file mode 100644
index 0000000..2596a31
--- /dev/null
+++ b/lib/dashboard/use-payment-overview-widget-controller.ts
@@ -0,0 +1,28 @@
+"use client";
+
+import { useState } from "react";
+import {
+ DEFAULT_PAYMENT_OVERVIEW_TAB,
+ type PaymentOverviewTab,
+ parsePaymentOverviewTab,
+} from "@/lib/dashboard/payment-overview-tabs";
+
+export type PaymentOverviewWidgetController = {
+ activeTab: PaymentOverviewTab;
+ handleTabChange: (value: string) => void;
+};
+
+export function usePaymentOverviewWidgetController(): PaymentOverviewWidgetController {
+ const [activeTab, setActiveTab] = useState(
+ DEFAULT_PAYMENT_OVERVIEW_TAB,
+ );
+
+ const handleTabChange = (value: string) => {
+ setActiveTab(parsePaymentOverviewTab(value));
+ };
+
+ return {
+ activeTab,
+ handleTabChange,
+ };
+}
diff --git a/lib/dashboard/widgets/widgets-config.tsx b/lib/dashboard/widgets/widgets-config.tsx
index bbb1d42..08aa4f2 100644
--- a/lib/dashboard/widgets/widgets-config.tsx
+++ b/lib/dashboard/widgets/widgets-config.tsx
@@ -16,16 +16,16 @@ import {
} from "@remixicon/react";
import Link from "next/link";
import type { ReactNode } from "react";
-import { BoletosWidget } from "@/components/dashboard/boletos-widget";
-import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
+import { BillWidget } from "@/components/dashboard/bill-widget";
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
import { GoalsProgressWidget } from "@/components/dashboard/goals-progress-widget";
import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart";
import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget";
+import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget";
import { NotesWidget } from "@/components/dashboard/notes-widget";
-import { PagadoresWidget } from "@/components/dashboard/pagadores-widget";
+import { PayersWidget } from "@/components/dashboard/payers-widget";
import { PaymentOverviewWidget } from "@/components/dashboard/payment-overview-widget";
import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget";
import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget";
@@ -70,9 +70,7 @@ export const widgetsConfig: WidgetConfig[] = [
title: "Boletos",
subtitle: "Controle de boletos do período",
icon: ,
- component: ({ data }) => (
-
- ),
+ component: ({ data }) => ,
},
{
id: "payment-status",
@@ -98,7 +96,7 @@ export const widgetsConfig: WidgetConfig[] = [
subtitle: "Despesas por pagador no período",
icon: ,
component: ({ data }) => (
-
+
),
action: (
+ {greeting}, {displayName}
-{formattedDate}
+{formattedDate}
-
- {data.items.map((item, index) => {
- const statusColor =
- item.status === "exceeded" ? "text-destructive" : "";
- const progressValue = clamp(item.usedPercentage, 0, 100);
- const percentageDelta = item.usedPercentage - 100;
-
- return (
-
-
- ---- -
- --- {item.categoryName} -
--
-de{" "} - - - - {formatPercentage(percentageDelta, true)} - --handleEdit(item)} - aria-label={`Editar orçamento de ${item.categoryName}`} - > - -- - --
- );
- })}
-
+ {item.categoryName} +
+
+
-
+ {items.map((item, index) => (
+
-
- {data.expenses.map((expense) => {
- const compactLabel = buildCompactInstallmentLabel(
- expense.currentInstallment,
- expense.installmentCount,
- );
- const isLast = isLastInstallment(
- expense.currentInstallment,
- expense.installmentCount,
- );
- const remainingInstallments = calculateRemainingInstallments(
- expense.currentInstallment,
- expense.installmentCount,
- );
- const remainingAmount = calculateRemainingAmount(
- expense.amount,
- expense.currentInstallment,
- expense.installmentCount,
- );
- const endDate = formatEndDate(
- expense.period,
- expense.currentInstallment,
- expense.installmentCount,
- );
- const progress = buildProgress(
- expense.currentInstallment,
- expense.installmentCount,
- );
-
- return (
-
-
- ---- ---
- {expense.name} -
- {compactLabel && ( - - {compactLabel} - {isLast && ( -- - )} - - )} -- - -- Última parcela - - - Última parcela! - -- - {endDate && `Termina em ${endDate}`} - {" | Restante "} -
- - -{" "} - ({remainingInstallments}) -
- );
- })}
-
+ {expense.name} +
+ {compactLabel ? ( + + {compactLabel} + {isLast ? ( +
+ {endDate ? `Termina em ${endDate}` : null}
+ {" | Restante "}
+
-
+ {expenses.map((expense) => (
+
-
- {items.map((invoice) => {
- const logo = resolveLogoPath(invoice.logo);
- const initials = buildInitials(invoice.cardName);
- const dueInfo = parseDueDate(invoice.period, invoice.dueDay);
- const isPaid =
- invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
- const isOverdue =
- !isPaid && dueInfo.date !== null && dueInfo.date < new Date();
- const paymentInfo = formatPaymentDate(invoice.paidAt);
-
- return (
-
-
- -- -- {logo ? ( -- -
- ) : ( - - {initials} - - )} - - {(() => { - const breakdown = invoice.pagadorBreakdown ?? []; - const hasBreakdown = breakdown.length > 0; - const linkNode = ( - - {invoice.cardName} --- - ); - - if (!hasBreakdown) { - return linkNode; - } - - const totalForShare = Math.abs(invoice.totalAmount); - - return ( - - - ); - })()} -- {linkNode} - -- -- Distribuição por pagador -
--
- {breakdown.map((share, index) => (
-
-
-
- -- - {buildInitials(share.pagadorName)} - ---- {share.pagadorName} -
-- {getShareLabel( - share.amount, - totalForShare, - )} -
----
- ))}
-
- {!isPaid ? {dueInfo.label} : null} - {isPaid && paymentInfo ? ( - - {paymentInfo.label} - - ) : null} ----- --handleOpenModal(invoice.id)} - variant={"link"} - className="p-0 h-auto disabled:opacity-100" - > - {isPaid ? ( - - -Pago - - ) : isOverdue ? ( - - - Atrasado - - - Pagar - - - ) : ( - Pagar - )} -
- );
- })}
- -
-
+ Distribuição por pagador +
+-
+ {breakdown.map((share, index) => (
+
-
+
+ ++ + {buildInvoiceInitials(share.pagadorName)} + ++++ {share.pagadorName} +
++ {getInvoiceShareLabel( + share.amount, + Math.abs(invoice.totalAmount), + )} +
++++
+ ))}
+
-
+ {invoices.map((invoice) => (
+
-
- {mappedNotes.map((note) => (
-
-
- -- -
- {buildDisplayTitle(note.title)} -
---- {getTasksSummary(note)} - -- {DATE_FORMATTER.format(new Date(note.createdAt))} -
---handleOpenEdit(note)} - aria-label={`Editar anotação ${buildDisplayTitle(note.title)}`} - > - -- handleOpenDetails(note)} - aria-label={`Ver detalhes da anotação ${buildDisplayTitle( - note.title, - )}`} - > - --
- ))}
-
+ {displayTitle} +
++ {createdAtLabel} +
+ ) : null} +-
+ {notes.map((note) => (
+
-
- {data.conditions.map((condition) => {
- const Icon =
- CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"];
- const percentageLabel = formatPercentage(condition.percentage);
-
- return (
-
-
- {Icon}- ----- -
- {condition.condition} -
-- - - {condition.transactions}{" "} - {condition.transactions === 1 - ? "lançamento" - : "lançamentos"} - - {percentageLabel}% -- -- --
- );
- })}
-
-
- {data.methods.map((method) => {
- const icon = resolveIcon(method.paymentMethod);
- const percentageLabel = formatPercentage(method.percentage);
-
- return (
-
-
- {icon}- ----- -
- {method.paymentMethod} -
-- - - {method.transactions}{" "} - {method.transactions === 1 ? "lançamento" : "lançamentos"} - - {percentageLabel}% -- -- --
- );
- })}
-
{item.title}
+-
+ {items.map((item) => (
+
-
{data.expenses.map((expense) => {
return (
@@ -61,6 +60,6 @@ export function RecurringExpensesWidget({
);
})}