mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 19:21:46 +00:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user