mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-11 03:31:47 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
135
src/features/accounts/components/adjust-balance-dialog.tsx
Normal file
135
src/features/accounts/components/adjust-balance-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
127
src/features/invoices/components/adjust-invoice-dialog.tsx
Normal file
127
src/features/invoices/components/adjust-invoice-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
178
src/features/transactions/actions/refund-action.ts
Normal file
178
src/features/transactions/actions/refund-action.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "server-only";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
export const generateShareCode = (): string => {
|
||||
|
||||
Reference in New Issue
Block a user