chore: ajustes de componentes, estilos, dependências e métricas do dashboard

- dashboard: melhorias em métricas, filtros de transações e overview de período
- transactions: colunas, tabela e página com novos campos e ajustes de exibição
- ui: card, table, navigation-menu, navbar, month-picker, logo-picker, theme-toggler
- calculator: ajustes de display, keypad e estado
- calendar: melhorias de grid e day-cell
- insights: atualização de constantes
- settings: pequenos ajustes
- pnpm-lock: atualização de dependências
- pdf.worker: atualização do worker

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-02 22:08:53 +00:00
parent d55173e8c1
commit 94bf93194f
40 changed files with 4699 additions and 477 deletions

View File

@@ -51,7 +51,7 @@ export default async function Page() {
<TabsTrigger value="passkeys">Passkeys</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive">
Deletar conta
Ações perigosas
</TabsTrigger>
</TabsList>
</div>
@@ -190,7 +190,6 @@ export default async function Page() {
ou excluir sua conta inteira de forma irreversível.
</p>
</div>
<Separator />
<DeleteAccountForm />
</div>
</Card>

View File

@@ -8,7 +8,7 @@
}
:root {
--background: oklch(97.412% 0.00332 67.032);
--background: oklch(95.99% 0.00411 55.512);
--foreground: oklch(27% 0.008 45);
--card: oklch(100% 0 0);
--card-foreground: var(--foreground);
@@ -36,7 +36,7 @@
--destructive: oklch(55% 0.22 27);
--destructive-foreground: oklch(98% 0.005 30);
--border: oklch(92.323% 0.01276 63.703);
--border: oklch(87.356% 0.01221 67.486);
--input: var(--border);
--ring: var(--primary);
@@ -116,7 +116,7 @@
--destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30);
--border: oklch(28% 0.0035 55);
--border: oklch(24.957% 0.00355 48.274);
--input: var(--border);
--ring: var(--primary);

View File

@@ -9,7 +9,7 @@ import { inter } from "@/public/fonts/font_index";
export const metadata: Metadata = {
title: {
default: "OpenMonetis | Suas finanças, do seu jeito",
template: "%s | OpenMonetis",
template: "OpenMonetis | %s",
},
description:
"Controle suas finanças pessoais de forma simples e transparente.",
@@ -40,7 +40,7 @@ export default function RootLayout({
/>
)}
</head>
<body className="subpixel-antialiased" suppressHydrationWarning>
<body className="antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light">
<QueryProvider>
<Suspense>{children}</Suspense>

View File

@@ -0,0 +1,135 @@
"use client";
import { RiEqualizerLine } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { adjustAccountBalanceAction } from "@/features/accounts/actions";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
type AdjustBalanceDialogProps = {
accountId: string;
period: string;
currentBalance: number;
};
export function AdjustBalanceDialog({
accountId,
period,
currentBalance,
}: AdjustBalanceDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [amount, setAmount] = useState<string>(currentBalance.toFixed(2));
useEffect(() => {
if (open) {
setAmount(currentBalance.toFixed(2));
}
}, [open, currentBalance]);
const targetBalance = Number(amount);
const diff = Number.isFinite(targetBalance)
? Math.round((targetBalance - currentBalance) * 100) / 100
: 0;
const diffLabel =
diff > 0
? `Será criado um lançamento de receita de ${formatCurrency(diff)}.`
: diff < 0
? `Será criado um lançamento de despesa de ${formatCurrency(Math.abs(diff))}.`
: "Nenhum ajuste será criado — o saldo já está correto.";
const handleSave = () => {
if (!Number.isFinite(targetBalance)) {
toast.error("Informe um valor válido.");
return;
}
startTransition(async () => {
const result = await adjustAccountBalanceAction({
accountId,
period,
currentBalance,
targetBalance,
});
if (result.success) {
toast.success(result.message);
setOpen(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Ajustar saldo"
>
<RiEqualizerLine className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ajustar saldo</DialogTitle>
<DialogDescription>
Informe o saldo correto da conta ao final do período. A diferença em
relação ao saldo atual será lançada como um ajuste.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<p className="text-muted-foreground">Saldo atual no sistema</p>
<p className="font-medium text-foreground">
{formatCurrency(currentBalance)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="adjust-balance-target">Saldo correto</Label>
<CurrencyInput
id="adjust-balance-target"
value={amount}
onValueChange={setAmount}
autoFocus
/>
<p className="text-xs text-muted-foreground">{diffLabel}</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={isPending}>
{isPending ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -16,7 +16,7 @@ export function CalendarGrid({
onCreateDay,
}: CalendarGridProps) {
return (
<div className="overflow-hidden rounded-lg border p-2">
<div className="overflow-hidden">
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="text-center">

View File

@@ -129,7 +129,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
onClick={() => onSelect(day)}
onKeyDown={handleKeyDown}
className={cn(
"group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border bg-card/80 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent",
"group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border bg-card/70 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent",
!day.isCurrentMonth && "bg-muted/20 opacity-60",
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
)}

View File

@@ -20,6 +20,7 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeRefundEntries,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
@@ -168,6 +169,7 @@ export async function fetchDashboardCategoryOverview(
eq(transactions.transactionType, "Receita"),
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeRefundEntries(),
excludeInitialBalanceWhenConfigured(),
),
),

View File

@@ -330,7 +330,7 @@ export function DashboardGridEditable({
>
<div className="relative">
{isEditing && (
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-xs rounded-lg border border-dashed border-primary flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-medium">

View File

@@ -41,6 +41,7 @@ const CARDS = [
"Consideramos lançamentos efetivados e não efetivados da pessoa principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"Reembolsos não entram como receita; eles abatem despesas e afetam o balanço líquido.",
"Saldo inicial também fica fora quando a conta está marcada para desconsiderá-lo das receitas.",
],
},
@@ -57,6 +58,7 @@ const CARDS = [
"Consideramos lançamentos efetivados e não efetivados da pessoa principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"Reembolsos do período reduzem o total de despesas, sem deixar o card negativo.",
"O valor mostrado é a saída efetiva do período, sempre em número positivo no card.",
],
},
@@ -70,6 +72,7 @@ const CARDS = [
helpTitle: "Como calculamos o balanço",
helpLines: [
"Partimos de receitas menos despesas do período.",
"Reembolsos entram no resultado líquido, mas não inflam receitas nem despesas.",
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora do cálculo base.",
"Depois aplicamos ajustes de transferências entre contas consideradas e não consideradas no saldo total.",
"Se a transferência entra em conta considerada, soma. Se sai de conta considerada para conta não considerada, subtrai.",

View File

@@ -21,9 +21,11 @@ import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/tr
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
isRefundNote,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { TRANSFER_CATEGORY_NAME } from "@/shared/lib/transfers/constants";
import {
compareDateOnly,
getBusinessDateString,
@@ -58,6 +60,7 @@ type CurrentPeriodTransactionRow = {
categoryId: string | null;
categoryName: string | null;
categoryType: string | null;
accountId: string | null;
cardLogo: string | null;
accountLogo: string | null;
accountExcludeInitialBalanceFromIncome: boolean | null;
@@ -119,6 +122,9 @@ const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) =>
const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) =>
!isInitialBalanceNote(note) && !isAutoInvoiceNote(note);
const shouldIncludeWithoutRefund = (note: string | null | undefined) =>
!isRefundNote(note);
const shouldIncludeInPaymentStatus = (row: CurrentPeriodTransactionRow) => {
if (!shouldIncludeWithoutAutoInvoice(row.note)) {
return false;
@@ -183,6 +189,7 @@ const buildBillsSnapshot = (
? row.boletoPaymentDate.toISOString().slice(0, 10)
: null,
isSettled: Boolean(row.isSettled),
accountId: row.accountId ?? null,
}))
.sort((a, b) => {
if (a.isSettled !== b.isSettled) {
@@ -259,6 +266,14 @@ const buildPaymentStatusData = (
}
const amount = toNumber(row.amount);
const isRefund = isRefundNote(row.note);
if (isRefund) {
const targetKey = row.isSettled === true ? "confirmed" : "pending";
result.expenses[targetKey] -= Math.abs(amount);
continue;
}
const target =
row.transactionType === TRANSACTION_TYPE_INCOME
? result.income
@@ -271,6 +286,8 @@ const buildPaymentStatusData = (
}
}
result.expenses.confirmed = Math.max(0, result.expenses.confirmed);
result.expenses.pending = Math.max(0, result.expenses.pending);
result.income.total = result.income.confirmed + result.income.pending;
result.expenses.total = result.expenses.confirmed + result.expenses.pending;
@@ -495,7 +512,9 @@ const buildPurchasesByCategoryData = (
!row.categoryName ||
!row.categoryType ||
!["despesa", "receita"].includes(row.categoryType) ||
row.categoryName === TRANSFER_CATEGORY_NAME ||
!shouldIncludeWithoutAutoGenerated(row.note) ||
!shouldIncludeWithoutRefund(row.note) ||
!shouldIncludeNamedItem(row.name)
) {
continue;
@@ -564,6 +583,7 @@ export async function fetchDashboardCurrentPeriodOverview(
categoryId: transactions.categoryId,
categoryName: categories.name,
categoryType: categories.type,
accountId: transactions.accountId,
cardLogo: cards.logo,
accountLogo: financialAccounts.logo,
accountExcludeInitialBalanceFromIncome:

View File

@@ -1,4 +1,4 @@
import { and, asc, eq, gte, inArray, lte, sum } from "drizzle-orm";
import { and, asc, eq, gte, inArray, lte, sql } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema";
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
import type {
@@ -11,6 +11,7 @@ import {
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number";
@@ -31,6 +32,7 @@ const TRANSACTION_TYPE_TRANSFER = "Transferência";
type PeriodTotals = {
receitas: number;
despesas: number;
reembolsos: number;
transferAdjustment: number;
balanco: number;
};
@@ -39,6 +41,7 @@ type PeriodSummaryRow = {
period: string | null;
transactionType: string;
totalAmount: string | number | null;
refundAmount: string | number | null;
accountExcludeFromBalance: boolean | null;
};
@@ -50,6 +53,7 @@ type DashboardPeriodOverview = {
const createEmptyTotals = (): PeriodTotals => ({
receitas: 0,
despesas: 0,
reembolsos: 0,
transferAdjustment: 0,
balanco: 0,
});
@@ -105,11 +109,17 @@ export async function fetchDashboardPeriodOverview(
const chartPeriods = generateLast6Months(period);
const startPeriod = addMonthsToPeriod(period, -24);
const refundPattern = `${REFUND_NOTE_PREFIX}%`;
const rows = (await db
.select({
period: transactions.period,
transactionType: transactions.transactionType,
totalAmount: sum(transactions.amount).as("total"),
totalAmount: sql<number>`coalesce(sum(case when ${transactions.note} ilike ${refundPattern} then 0 else ${transactions.amount} end), 0)`.as(
"total",
),
refundAmount: sql<number>`coalesce(sum(case when ${transactions.note} ilike ${refundPattern} then ${transactions.amount} else 0 end), 0)`.as(
"refund",
),
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
})
.from(transactions)
@@ -151,6 +161,9 @@ export async function fetchDashboardPeriodOverview(
const totals = ensurePeriodTotals(periodTotals, row.period);
const total = safeToNumber(row.totalAmount);
const refund = safeToNumber(row.refundAmount);
totals.reembolsos += Math.abs(refund);
if (row.transactionType === TRANSACTION_TYPE_INCOME) {
totals.receitas += total;
@@ -179,9 +192,14 @@ export async function fetchDashboardPeriodOverview(
for (const key of periodRange) {
const totals = ensurePeriodTotals(periodTotals, key);
const netExpenses = Math.max(0, totals.despesas - totals.reembolsos);
totals.balanco =
totals.receitas - totals.despesas + totals.transferAdjustment;
totals.receitas -
totals.despesas +
totals.reembolsos +
totals.transferAdjustment;
runningForecast += totals.balanco;
totals.despesas = netExpenses;
forecastByPeriod.set(key, runningForecast);
}

View File

@@ -3,6 +3,7 @@ import { financialAccounts, transactions } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
REFUND_NOTE_PREFIX,
} from "@/shared/lib/accounts/constants";
export { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
@@ -27,6 +28,12 @@ export const excludeAutoInvoiceEntries = () =>
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
);
export const excludeRefundEntries = () =>
or(
isNull(transactions.note),
not(ilike(transactions.note, `${REFUND_NOTE_PREFIX}%`)),
);
export const excludeInitialBalanceWhenConfigured = () =>
or(
isNull(transactions.note),

View File

@@ -34,14 +34,16 @@ export const PROVIDERS = {
*/
export const AVAILABLE_MODELS = [
// OpenAI
{ id: "gpt-5.5-pro", name: "GPT-5.5 Pro", provider: "openai" as const },
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" as const },
{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai" as const },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", provider: "openai" as const },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", provider: "openai" as const },
// Anthropic
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
provider: "anthropic" as const,
},
{
@@ -73,7 +75,7 @@ export const AVAILABLE_MODELS = [
},
] as const;
export const DEFAULT_MODEL = "gpt-5.4";
export const DEFAULT_MODEL = "gpt-5.5";
export const DEFAULT_PROVIDER = "openai";
/**

View File

@@ -0,0 +1,127 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { adjustInvoiceAction } from "@/features/invoices/actions";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
type AdjustInvoiceDialogProps = {
trigger: React.ReactNode;
cardId: string;
period: string;
currentTotal: number;
};
export function AdjustInvoiceDialog({
trigger,
cardId,
period,
currentTotal,
}: AdjustInvoiceDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const currentAbs = Math.abs(currentTotal);
const [amount, setAmount] = useState<string>(currentAbs.toFixed(2));
useEffect(() => {
if (open) {
setAmount(currentAbs.toFixed(2));
}
}, [open, currentAbs]);
const targetAmount = Number(amount);
const diff = Number.isFinite(targetAmount)
? Math.round((targetAmount - currentAbs) * 100) / 100
: 0;
const diffLabel =
diff > 0
? `Será criado um lançamento de despesa de ${formatCurrency(diff)}.`
: diff < 0
? `Será criado um lançamento de receita de ${formatCurrency(Math.abs(diff))}.`
: "Nenhum ajuste será criado — o valor já está correto.";
const handleSave = () => {
if (!Number.isFinite(targetAmount) || targetAmount < 0) {
toast.error("Informe um valor válido.");
return;
}
startTransition(async () => {
const result = await adjustInvoiceAction({
cardId,
period,
currentTotal,
targetAmount,
});
if (result.success) {
toast.success(result.message);
setOpen(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ajustar fatura</DialogTitle>
<DialogDescription>
Informe o valor real da fatura. A diferença em relação ao total
atual será lançada como um ajuste no período.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<p className="text-muted-foreground">Total atual no sistema</p>
<p className="font-medium text-foreground">
{formatCurrency(currentAbs)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="adjust-target">Valor correto da fatura</Label>
<CurrencyInput
id="adjust-target"
value={amount}
onValueChange={setAmount}
autoFocus
/>
<p className="text-xs text-muted-foreground">{diffLabel}</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={isPending}>
{isPending ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -98,7 +98,7 @@ export function DeleteAccountForm() {
<li>
Preferências do app, insights salvos e tokens do Companion
</li>
<li className="font-medium text-foreground">
<li>
Categorias padrão e pessoa admin serão recriadas automaticamente
</li>
</ul>
@@ -128,6 +128,7 @@ export function DeleteAccountForm() {
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Faturas, antecipações e pré-lançamentos</li>
<li>Contas, cartões e categorias</li>
<li>Pessoas, credenciais e configurações</li>
<li className="font-medium">

View File

@@ -0,0 +1,178 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { cards, categories, transactions } from "@/db/schema";
import {
buildRefundNote,
isRefundNote,
} from "@/shared/lib/accounts/constants";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { PERIOD_FORMAT_REGEX } from "@/shared/lib/invoices";
import type { ActionResult } from "@/shared/lib/types/actions";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { parseLocalDateString } from "@/shared/utils/date";
import {
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
revalidate,
} from "./core";
const refundSchema = z.object({
originalTransactionId: z
.string({ message: "Lançamento inválido." })
.uuid("Lançamento inválido."),
refundDate: z
.string({ message: "Data inválida." })
.refine(
(value) => !Number.isNaN(parseLocalDateString(value).getTime()),
"Data inválida.",
),
refundPeriod: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
});
type RefundInput = z.infer<typeof refundSchema>;
export async function refundTransactionAction(
input: RefundInput,
): Promise<ActionResult<{ refundId: string }>> {
try {
const user = await getUser();
const data = refundSchema.parse(input);
const original = await db.query.transactions.findFirst({
where: and(
eq(transactions.id, data.originalTransactionId),
eq(transactions.userId, user.id),
),
});
if (!original) {
return { success: false, error: "Lançamento não encontrado." };
}
if (original.transactionType !== "Despesa") {
return {
success: false,
error: "Apenas despesas podem ser estornadas.",
};
}
if (original.condition !== "À vista") {
return {
success: false,
error: "Apenas lançamentos à vista podem ser estornados.",
};
}
if (original.splitGroupId) {
return {
success: false,
error: "Lançamentos divididos não podem ser estornados.",
};
}
if (isRefundNote(original.note)) {
return {
success: false,
error: "Este lançamento já é um reembolso.",
};
}
const [existingRefund, card, paidPeriods, refundCategory] =
await Promise.all([
db.query.transactions.findFirst({
columns: { id: true },
where: and(
eq(transactions.userId, user.id),
eq(transactions.note, buildRefundNote(original.id)),
),
}),
original.cardId
? db.query.cards.findFirst({
columns: { id: true },
where: and(
eq(cards.id, original.cardId),
eq(cards.userId, user.id),
),
})
: Promise.resolve(null),
original.cardId
? getPaidInvoicePeriods(user.id, original.cardId, [data.refundPeriod])
: Promise.resolve([] as string[]),
db.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.name, "Reembolso"),
),
}),
]);
if (existingRefund) {
return {
success: false,
error: "Este lançamento já foi estornado.",
};
}
if (original.cardId && !card) {
return { success: false, error: "Cartão não encontrado." };
}
if (paidPeriods.length > 0) {
return {
success: false,
error: `A fatura de ${formatPaidInvoicePeriods(
paidPeriods,
)} já está paga. Desfaça o pagamento antes de lançar o reembolso.`,
};
}
const amountAbs = Math.abs(Number(original.amount));
const refundDate = parseLocalDateString(data.refundDate);
const [inserted] = await db
.insert(transactions)
.values({
name: `Reembolso de: ${original.name}`,
condition: "À vista",
paymentMethod: original.paymentMethod,
note: buildRefundNote(original.id),
amount: formatDecimalForDbRequired(amountAbs),
purchaseDate: refundDate,
transactionType: "Receita",
period: data.refundPeriod,
isSettled: false,
userId: user.id,
cardId: original.cardId,
accountId: original.accountId,
categoryId: refundCategory?.id ?? null,
payerId: original.payerId,
})
.returning({ id: transactions.id });
revalidate(user.id);
return {
success: true,
message: "Reembolso registrado.",
data: { refundId: inserted?.id ?? "" },
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { refundTransactionAction } from "@/features/transactions/actions/refund-action";
import { deriveCreditCardPeriod } from "@/features/transactions/form-helpers";
import { formatDate } from "@/features/transactions/formatting-helpers";
import { PeriodPicker } from "@/shared/components/period-picker";
import { Button } from "@/shared/components/ui/button";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
import { derivePeriodFromDate, displayPeriod } from "@/shared/utils/period";
import type { SelectOption, TransactionItem } from "../types";
type RefundTransactionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
transaction: TransactionItem | null;
cardOptions: SelectOption[];
};
const todayIso = () => new Date().toISOString().split("T")[0] ?? "";
function deriveDefaultRefundPeriod(
refundDate: string,
transaction: TransactionItem | null,
card: SelectOption | null,
) {
if (transaction?.cardId) {
return deriveCreditCardPeriod(
refundDate,
card?.closingDay ?? null,
card?.dueDay ?? null,
);
}
return derivePeriodFromDate(refundDate);
}
export function RefundTransactionDialog({
open,
onOpenChange,
transaction,
cardOptions,
}: RefundTransactionDialogProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [refundDate, setRefundDate] = useState<string>(todayIso());
const [refundPeriod, setRefundPeriod] = useState<string>("");
const card = useMemo(() => {
if (!transaction?.cardId) return null;
return cardOptions.find((opt) => opt.value === transaction.cardId) ?? null;
}, [transaction?.cardId, cardOptions]);
useEffect(() => {
if (open) {
const today = todayIso();
setRefundDate(today);
setRefundPeriod(deriveDefaultRefundPeriod(today, transaction, card));
}
}, [open, transaction, card]);
const defaultPeriod = useMemo(
() => deriveDefaultRefundPeriod(refundDate, transaction, card),
[refundDate, transaction, card],
);
if (!transaction) return null;
const amountAbs = Math.abs(transaction.amount);
const periodLabel = refundPeriod ? displayPeriod(refundPeriod) : "—";
const destinationLabel = transaction.cardId
? `na fatura de ${periodLabel}`
: `no extrato de ${periodLabel}`;
const handleSubmit = () => {
if (!refundDate) {
toast.error("Informe a data do reembolso.");
return;
}
if (!refundPeriod) {
toast.error("Informe o período do reembolso.");
return;
}
startTransition(async () => {
const result = await refundTransactionAction({
originalTransactionId: transaction.id,
refundDate,
refundPeriod,
});
if (result.success) {
toast.success(result.message);
onOpenChange(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Registrar reembolso</DialogTitle>
<DialogDescription>
Será criado um lançamento de reembolso espelhando esta despesa. O
lançamento original será mantido.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<p className="font-medium text-foreground">{transaction.name}</p>
<p className="text-muted-foreground">
{formatCurrency(amountAbs)} {" "}
{formatDate(transaction.purchaseDate)} {" "}
{transaction.paymentMethod}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="refund-date">Data do reembolso</Label>
<DatePicker
id="refund-date"
value={refundDate}
onChange={(value) => {
if (!value) return;
setRefundDate(value);
setRefundPeriod(
deriveDefaultRefundPeriod(value, transaction, card),
);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="refund-period">
{transaction.cardId
? "Fatura do reembolso"
: "Período do reembolso"}
</Label>
<PeriodPicker
value={refundPeriod || defaultPeriod}
onChange={setRefundPeriod}
disabled={isPending}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
O reembolso será lançado {destinationLabel}.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSubmit} disabled={isPending}>
{isPending ? "Registrando..." : "Registrar reembolso"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -33,6 +33,7 @@ import {
MassAddDialog,
type MassAddFormData,
} from "../dialogs/mass-add-dialog";
import { RefundTransactionDialog } from "../dialogs/refund-transaction-dialog";
import {
SplitPairDialog,
type SplitPairScope,
@@ -183,6 +184,9 @@ export function TransactionsPage({
const [transactionsToImport, setTransactionsToImport] = useState<
TransactionItem[]
>([]);
const [refundOpen, setRefundOpen] = useState(false);
const [transactionToRefund, setTransactionToRefund] =
useState<TransactionItem | null>(null);
const handleToggleSettlement = async (item: TransactionItem) => {
if (item.paymentMethod === "Cartão de crédito") {
@@ -539,6 +543,11 @@ export function TransactionsPage({
setDetailsOpen(true);
};
const handleRefund = (item: TransactionItem) => {
setTransactionToRefund(item);
setRefundOpen(true);
};
const handleAnticipate = (item: TransactionItem) => {
setSelectedForAnticipation(item);
setAnticipateOpen(true);
@@ -571,6 +580,7 @@ export function TransactionsPage({
onBulkDelete={handleMultipleBulkDelete}
onBulkImport={handleBulkImport}
onViewDetails={handleViewDetails}
onRefund={handleRefund}
onToggleSettlement={handleToggleSettlement}
onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory}
@@ -683,6 +693,18 @@ export function TransactionsPage({
onEdit={handleEdit}
/>
<RefundTransactionDialog
open={refundOpen && !!transactionToRefund}
onOpenChange={(open) => {
setRefundOpen(open);
if (!open) {
setTransactionToRefund(null);
}
}}
transaction={transactionToRefund}
cardOptions={cardOptions}
/>
<ConfirmActionDialog
open={deleteOpen && !!transactionToDelete}
onOpenChange={setDeleteOpen}

View File

@@ -12,6 +12,7 @@ import {
RiHistoryLine,
RiMoreFill,
RiPencilLine,
RiRefund2Line,
RiTimeLine,
} from "@remixicon/react";
import type { ColumnDef } from "@tanstack/react-table";
@@ -45,6 +46,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { formatDate } from "@/shared/utils/date";
@@ -60,6 +62,7 @@ export type BuildColumnsArgs = {
onImport?: (item: TransactionItem) => void;
onConfirmDelete?: (item: TransactionItem) => void;
onViewDetails?: (item: TransactionItem) => void;
onRefund?: (item: TransactionItem) => void;
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
@@ -121,6 +124,7 @@ function buildColumns({
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -133,6 +137,7 @@ function buildColumns({
const handleImport = onImport ?? noop;
const handleConfirmDelete = onConfirmDelete ?? noop;
const handleViewDetails = onViewDetails ?? noop;
const handleRefund = onRefund ?? noop;
const handleToggleSettlement = onToggleSettlement ?? noop;
const handleAnticipate = onAnticipate ?? noop;
const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop;
@@ -682,6 +687,25 @@ function buildColumns({
Importar para Minha Conta
</DropdownMenuItem>
)}
{(() => {
const item = row.original;
const canRefund =
item.userId === currentUserId &&
item.transactionType === "Despesa" &&
item.condition === "À vista" &&
!item.splitGroupId &&
!item.readonly &&
!item.note?.startsWith(REFUND_NOTE_PREFIX);
if (!canRefund) return null;
return (
<DropdownMenuItem onSelect={() => handleRefund(item)}>
<RiRefund2Line className="size-4" />
Reembolso
</DropdownMenuItem>
);
})()}
{row.original.userId === currentUserId && (
<DropdownMenuItem
variant="destructive"

View File

@@ -70,6 +70,7 @@ type LancamentosTableProps = {
onBulkDelete?: (items: TransactionItem[]) => void;
onBulkImport?: (items: TransactionItem[]) => void;
onViewDetails?: (item: TransactionItem) => void;
onRefund?: (item: TransactionItem) => void;
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
@@ -98,6 +99,7 @@ export function TransactionsTable({
onBulkDelete,
onBulkImport,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -131,6 +133,7 @@ export function TransactionsTable({
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -147,6 +150,7 @@ export function TransactionsTable({
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,

View File

@@ -88,9 +88,9 @@ export const AnimatedThemeToggler = ({
data-state={isDark ? "dark" : "light"}
className={cn(
buttonVariants({ variant, size: "icon-sm" }),
"group relative transition-all duration-200",
"group relative transition-all duration-200 h-9",
variant === "ghost" &&
"text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
"h-9 text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
className,
)}
{...props}

View File

@@ -34,7 +34,7 @@ export function CalculatorDisplay({
<div className="mt-auto flex items-end justify-end gap-2">
<div
className={cn(
"truncate text-right font-medium tracking-tight leading-none transition-all",
"truncate text-right font-semibold transition-all",
isResultView ? "text-2xl" : "text-3xl",
)}
>

View File

@@ -32,11 +32,11 @@ export function CalculatorKeypad({
variant={isActive ? "default" : (btn.variant ?? "outline")}
onClick={btn.onClick}
className={cn(
"h-12 text-base font-medium",
"h-14 text-lg font-medium",
btn.colSpan === 2 && "col-span-2",
btn.colSpan === 3 && "col-span-3",
isActive &&
"bg-primary text-primary-foreground hover:bg-primary/90 ring-2 ring-primary/30",
"bg-primary text-primary-foreground hover:bg-primary/90",
btn.className,
)}
>

View File

@@ -49,14 +49,14 @@ export function LogoPickerTrigger({
className,
)}
>
<span className="relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/40 bg-background shadow-xs">
<span className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/40 bg-background shadow-xs">
{selectedLogoPath ? (
<Image
src={selectedLogoPath}
alt={selectedLogoLabel || "Logo selecionado"}
fill
sizes="32px"
className="object-contain p-0.5"
className="object-contain"
/>
) : (
<span className="text-xs text-muted-foreground">Logo</span>

View File

@@ -47,7 +47,7 @@ export default function MonthNavigation() {
<div className="flex items-center">
<div
className="mx-1 space-x-1 capitalize font-medium"
className="mx-1 space-x-1 capitalize font-semibold"
aria-current={!isDifferentFromCurrent ? "date" : undefined}
aria-label={`Período selecionado: ${currentMonthLabel}`}
>

View File

@@ -29,10 +29,10 @@ import { NavPill } from "./nav-pill";
import { MobileTools, NavToolsDropdown } from "./nav-tools";
const triggerClass =
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! capitalize! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10! dark:text-white/75! dark:hover:text-white! dark:hover:bg-white/10! dark:focus:text-white! dark:focus:bg-white/10! dark:focus-visible:ring-white/20! dark:data-[state=open]:text-white! dark:data-[state=open]:bg-white/10!";
"h-9! px-2! py-0! bg-transparent! capitalize! [&_svg]:text-current! text-primary-foreground/75! hover:text-primary-foreground! hover:bg-primary-foreground/10! focus:text-primary-foreground! focus:bg-primary-foreground/10! focus-visible:ring-primary-foreground/20! data-[state=open]:text-primary-foreground! data-[state=open]:bg-primary-foreground/10! dark:text-foreground/75! dark:hover:text-foreground! dark:hover:bg-foreground/10! dark:focus:text-foreground! dark:focus:bg-foreground/10! dark:focus-visible:ring-foreground/20! dark:data-[state=open]:text-foreground! dark:data-[state=open]:bg-foreground/10!";
const triggerActiveClass =
"bg-black/15! text-black! dark:bg-white/15! dark:text-white!";
"bg-primary-foreground/15! text-primary-foreground! dark:bg-foreground/15! dark:text-foreground!";
export function NavMenu() {
const pathname = usePathname();

View File

@@ -25,8 +25,9 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
preservePeriod={preservePeriod}
className={cn(
buttonVariants({ variant: "navbar", size: "sm" }),
"capitalize",
isActive && "bg-black/15 text-black dark:bg-white/15 dark:text-white",
"h-9 capitalize text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground focus-visible:ring-primary-foreground/20 dark:text-foreground/75 dark:hover:bg-foreground/10 dark:hover:text-foreground dark:focus-visible:ring-foreground/20",
isActive &&
"bg-primary-foreground/15 text-primary-foreground dark:bg-foreground/15 dark:text-foreground",
)}
>
{children}

View File

@@ -16,7 +16,7 @@ export function NavbarShell({
return (
<header
className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary border-b dark:bg-card dark:border-b-border/60`}
className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary border-b border-b-primary dark:bg-card dark:border-b-border`}
>
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
{logoHref ? (

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-4 border border-border/70 dark:border-border/40 py-6 rounded-lg hover:border-primary/60 transition-colors duration-200",
"bg-card text-card-foreground flex flex-col gap-4 border border-transparent shadow-sm dark:border-border py-6 rounded-lg hover:border-primary/50 transition-colors duration-200",
className,
)}
{...props}

View File

@@ -111,7 +111,7 @@ function NavigationMenuViewport({
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-md border shadow md:w-(--radix-navigation-menu-viewport-width)",
className,
)}
{...props}
@@ -144,7 +144,7 @@ function NavigationMenuIndicator({
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}

View File

@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b border-border/50 transition-colors",
className,
)}
{...props}

View File

@@ -243,8 +243,8 @@ export function useCalculatorState() {
const buttons: CalculatorButtonConfig[][] = [
[
{ label: "C", onClick: reset, variant: "destructive" },
{ label: "⌫", onClick: deleteLastDigit, variant: "secondary" },
{ label: "%", onClick: applyPercent, variant: "secondary" },
{ label: "⌫", onClick: deleteLastDigit },
{ label: "%", onClick: applyPercent },
{
label: "÷",
onClick: makeOperatorHandler("divide"),
@@ -278,7 +278,7 @@ export function useCalculatorState() {
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
],
[
{ label: "±", onClick: toggleSign, variant: "secondary" },
{ label: "±", onClick: toggleSign },
{ label: "0", onClick: () => inputDigit("0") },
{ label: ",", onClick: inputDecimal },
{ label: "=", onClick: evaluate, variant: "default" },

View File

@@ -1,4 +1,3 @@
import "server-only";
import { randomBytes } from "node:crypto";
export const generateShareCode = (): string => {