feat: divisão por porcentagem e indicador de orçamento no modal de lançamento

Toggle compacto R$/% no card 'Dividir lançamento' usando ToggleGroup do shadcn.
No modo %, cada input exibe o valor convertido em R$ logo abaixo (mesmo padrão
do InlinePeriodPicker). Helpers amountToPercent/percentToAmount reutilizam
safeToNumber, normalizeDecimalInput e formatDecimalForDbRequired.

Indicador de orçamento ao lado do nome da categoria selecionada: mostra
'R$ gasto de R$ orçado (%)' com cores semânticas (verde/âmbar/vermelho).
Busca assíncrona via getCategoryBudgetSummaryAction com cache por instância
(useRef<Map>) e cancelamento de race condition. Suprimido quando o input divide
a linha com o campo de tipo de transação (caso pré-lançamentos).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-10 13:51:44 +00:00
parent c9239c4f3c
commit c4c52c02ab
3 changed files with 272 additions and 37 deletions

View File

@@ -3,6 +3,10 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { budgets, categories } from "@/db/schema";
import {
type CategoryBudgetSummary,
fetchCategoryBudgetSummary,
} from "@/features/budgets/queries";
import {
handleActionError,
revalidateForEntity,
@@ -204,6 +208,34 @@ export async function deleteBudgetAction(
}
}
const getCategoryBudgetSummarySchema = z.object({
categoryId: uuidSchema("Category"),
period: periodSchema,
});
type GetCategoryBudgetSummaryInput = z.input<
typeof getCategoryBudgetSummarySchema
>;
export async function getCategoryBudgetSummaryAction(
input: GetCategoryBudgetSummaryInput,
): Promise<ActionResult<CategoryBudgetSummary | null>> {
try {
const user = await getUser();
const data = getCategoryBudgetSummarySchema.parse(input);
const summary = await fetchCategoryBudgetSummary(
user.id,
data.categoryId,
data.period,
);
return { success: true, message: "ok", data: summary };
} catch (error) {
return handleActionError(
error,
) as ActionResult<CategoryBudgetSummary | null>;
}
}
const duplicatePreviousMonthSchema = z.object({
period: periodSchema,
});

View File

@@ -1,5 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { getCategoryBudgetSummaryAction } from "@/features/budgets/actions";
import type { CategoryBudgetSummary } from "@/features/budgets/queries";
import { TRANSACTION_TYPES } from "@/features/transactions/lib/constants";
import { Label } from "@/shared/components/ui/label";
import {
@@ -11,6 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { formatCurrency } from "@/shared/utils/currency";
import { cn } from "@/shared/utils/ui";
import {
CategorySelectContent,
@@ -18,6 +22,22 @@ import {
} from "../../select-items";
import type { CategorySectionProps } from "./transaction-dialog-types";
const BUDGET_DANGER_RATIO = 1;
const BUDGET_WARNING_RATIO = 0.8;
const getBudgetTone = (ratio: number) => {
if (ratio >= BUDGET_DANGER_RATIO) return "text-red-600 dark:text-red-400";
if (ratio >= BUDGET_WARNING_RATIO)
return "text-amber-600 dark:text-amber-400";
return "text-emerald-600 dark:text-emerald-400";
};
const formatCompactCurrency = (value: number) =>
formatCurrency(value, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
export function CategorySection({
formState,
onFieldChange,
@@ -28,6 +48,62 @@ export function CategorySection({
}: CategorySectionProps) {
const showTransactionTypeField = !isUpdateMode && !hideTransactionType;
const [budgetSummary, setBudgetSummary] =
useState<CategoryBudgetSummary | null>(null);
const cacheRef = useRef<Map<string, CategoryBudgetSummary | null>>(new Map());
const { categoryId, period, transactionType } = formState;
const shouldFetchBudget =
Boolean(categoryId) && Boolean(period) && transactionType === "Despesa";
useEffect(() => {
if (!shouldFetchBudget || !categoryId || !period) {
setBudgetSummary(null);
return;
}
const key = `${categoryId}::${period}`;
const cached = cacheRef.current.get(key);
if (cached !== undefined) {
setBudgetSummary((prev) => (prev === cached ? prev : cached));
return;
}
let cancelled = false;
getCategoryBudgetSummaryAction({ categoryId, period }).then((result) => {
if (cancelled) return;
const data = result.success ? (result.data ?? null) : null;
cacheRef.current.set(key, data);
setBudgetSummary(data);
});
return () => {
cancelled = true;
};
}, [shouldFetchBudget, categoryId, period]);
const renderBudgetBadge = () => {
if (showTransactionTypeField) return null;
if (!shouldFetchBudget || !budgetSummary) return null;
const { amount, spent } = budgetSummary;
const ratio = amount > 0 ? spent / amount : 0;
const percent = amount > 0 ? Math.round(ratio * 100) : 0;
return (
<span
title={`${formatCurrency(spent)} de ${formatCurrency(amount)} (${percent}%)`}
className={cn(
"shrink-0 text-xs font-semibold leading-none whitespace-nowrap font-mono",
getBudgetTone(ratio),
)}
>
{formatCompactCurrency(spent)} de {formatCompactCurrency(amount)}
<span className="ml-1 opacity-70">({percent}%)</span>
</span>
);
};
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
{showTransactionTypeField ? (
@@ -77,12 +153,16 @@ export function CategorySection({
const selectedOption = categoryOptions.find(
(opt) => opt.value === formState.categoryId,
);
return selectedOption ? (
<CategorySelectContent
label={selectedOption.label}
icon={selectedOption.icon}
/>
) : null;
if (!selectedOption) return null;
return (
<span className="flex items-center gap-2">
<CategorySelectContent
label={selectedOption.label}
icon={selectedOption.icon}
/>
{renderBudgetBadge()}
</span>
);
})()}
</SelectValue>
</SelectTrigger>

View File

@@ -2,7 +2,9 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { RiSliceFill } from "@remixicon/react";
import { useState } from "react";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import {
Select,
@@ -11,10 +13,124 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
import {
formatCurrency,
formatDecimalForDbRequired,
normalizeDecimalInput,
} from "@/shared/utils/currency";
import { safeToNumber } from "@/shared/utils/number";
import { cn } from "@/shared/utils/ui";
import { PayerSelectContent } from "../../select-items";
import type { PayerSectionProps } from "./transaction-dialog-types";
type SplitInputMode = "currency" | "percentage";
const SPLIT_MODE_OPTIONS = [
{ value: "currency", label: "R$" },
{ value: "percentage", label: "%" },
] as const satisfies ReadonlyArray<{ value: SplitInputMode; label: string }>;
const amountToPercent = (amount: string, total: number): string => {
if (total <= 0) return "";
const numeric = safeToNumber(normalizeDecimalInput(amount), Number.NaN);
if (!Number.isFinite(numeric)) return "";
const pct = (numeric / total) * 100;
return (Math.round(pct * 10) / 10).toString();
};
const percentToAmount = (percent: string, total: number): string => {
const pct = safeToNumber(normalizeDecimalInput(percent), Number.NaN);
if (!Number.isFinite(pct) || total <= 0) return "0.00";
const clamped = Math.min(100, Math.max(0, pct));
return formatDecimalForDbRequired((total * clamped) / 100);
};
function SplitModeToggle({
mode,
onModeChange,
}: {
mode: SplitInputMode;
onModeChange: (mode: SplitInputMode) => void;
}) {
return (
<ToggleGroup
type="single"
size="sm"
variant="outline"
value={mode}
onValueChange={(value) => {
if (value) onModeChange(value as SplitInputMode);
}}
aria-label="Modo de entrada do split"
className="h-7 text-xs"
>
{SPLIT_MODE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
className="px-2 py-0 h-7 text-xs"
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
function SplitAmountField({
mode,
value,
totalAmount,
onAmountChange,
ariaLabel,
}: {
mode: SplitInputMode;
value: string;
totalAmount: number;
onAmountChange: (amount: string) => void;
ariaLabel: string;
}) {
if (mode === "currency") {
return (
<CurrencyInput
value={value}
onValueChange={onAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
);
}
return (
<div className="w-[45%] space-y-1">
<div className="relative">
<Input
type="text"
inputMode="decimal"
value={amountToPercent(value, totalAmount)}
onChange={(event) => {
const sanitized = event.target.value.replace(/[^\d.,]/g, "");
onAmountChange(percentToAmount(sanitized, totalAmount));
}}
placeholder="0"
aria-label={ariaLabel}
className="h-9 w-full pr-7 text-sm"
/>
<span className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-xs text-muted-foreground">
%
</span>
</div>
<p className="ml-1 text-xs text-muted-foreground">
{formatCurrency(safeToNumber(value))}
</p>
</div>
);
}
export function PayerSection({
formState,
onFieldChange,
@@ -22,17 +138,17 @@ export function PayerSection({
secondaryPayerOptions,
totalAmount,
}: PayerSectionProps) {
const [splitMode, setSplitMode] = useState<SplitInputMode>("currency");
const handlePrimaryAmountChange = (value: string) => {
onFieldChange("primarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
const remaining = Math.max(0, totalAmount - safeToNumber(value));
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
};
const handleSecondaryAmountChange = (value: string) => {
onFieldChange("secondarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
const remaining = Math.max(0, totalAmount - safeToNumber(value));
onFieldChange("primarySplitAmount", remaining.toFixed(2));
};
@@ -54,23 +170,28 @@ export function PayerSection({
</p>
</div>
</div>
<CheckboxPrimitive.Root
checked={formState.isSplit}
onCheckedChange={(checked) =>
onFieldChange("isSplit", Boolean(checked))
}
aria-label="Dividir lançamento"
className={cn(
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
formState.isSplit
? "border-primary bg-primary text-primary-foreground"
: "border-input dark:bg-input/30",
)}
>
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
<RiSliceFill className="size-3" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<div className="flex items-center gap-2">
{formState.isSplit ? (
<SplitModeToggle mode={splitMode} onModeChange={setSplitMode} />
) : null}
<CheckboxPrimitive.Root
checked={formState.isSplit}
onCheckedChange={(checked) =>
onFieldChange("isSplit", Boolean(checked))
}
aria-label="Dividir lançamento"
className={cn(
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
formState.isSplit
? "border-primary bg-primary text-primary-foreground"
: "border-input dark:bg-input/30",
)}
>
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
<RiSliceFill className="size-3" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
</div>
</div>
<div className="flex w-full flex-col gap-2 md:flex-row">
@@ -111,14 +232,15 @@ export function PayerSection({
))}
</SelectContent>
</Select>
{formState.isSplit && (
<CurrencyInput
{formState.isSplit ? (
<SplitAmountField
mode={splitMode}
value={formState.primarySplitAmount}
onValueChange={handlePrimaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
totalAmount={totalAmount}
onAmountChange={handlePrimaryAmountChange}
ariaLabel="Porcentagem da pessoa"
/>
)}
) : null}
</div>
</div>
@@ -163,11 +285,12 @@ export function PayerSection({
))}
</SelectContent>
</Select>
<CurrencyInput
<SplitAmountField
mode={splitMode}
value={formState.secondarySplitAmount}
onValueChange={handleSecondaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
totalAmount={totalAmount}
onAmountChange={handleSecondaryAmountChange}
ariaLabel="Porcentagem do segundo pagador"
/>
</div>
</div>