mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-11 03:31:47 +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 { and, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { budgets, categories } from "@/db/schema";
|
import { budgets, categories } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
type CategoryBudgetSummary,
|
||||||
|
fetchCategoryBudgetSummary,
|
||||||
|
} from "@/features/budgets/queries";
|
||||||
import {
|
import {
|
||||||
handleActionError,
|
handleActionError,
|
||||||
revalidateForEntity,
|
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({
|
const duplicatePreviousMonthSchema = z.object({
|
||||||
period: periodSchema,
|
period: periodSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use client";
|
"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 { TRANSACTION_TYPES } from "@/features/transactions/lib/constants";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +14,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import {
|
import {
|
||||||
CategorySelectContent,
|
CategorySelectContent,
|
||||||
@@ -18,6 +22,22 @@ import {
|
|||||||
} from "../../select-items";
|
} from "../../select-items";
|
||||||
import type { CategorySectionProps } from "./transaction-dialog-types";
|
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({
|
export function CategorySection({
|
||||||
formState,
|
formState,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
@@ -28,6 +48,62 @@ export function CategorySection({
|
|||||||
}: CategorySectionProps) {
|
}: CategorySectionProps) {
|
||||||
const showTransactionTypeField = !isUpdateMode && !hideTransactionType;
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
{showTransactionTypeField ? (
|
{showTransactionTypeField ? (
|
||||||
@@ -77,12 +153,16 @@ export function CategorySection({
|
|||||||
const selectedOption = categoryOptions.find(
|
const selectedOption = categoryOptions.find(
|
||||||
(opt) => opt.value === formState.categoryId,
|
(opt) => opt.value === formState.categoryId,
|
||||||
);
|
);
|
||||||
return selectedOption ? (
|
if (!selectedOption) return null;
|
||||||
<CategorySelectContent
|
return (
|
||||||
label={selectedOption.label}
|
<span className="flex items-center gap-2">
|
||||||
icon={selectedOption.icon}
|
<CategorySelectContent
|
||||||
/>
|
label={selectedOption.label}
|
||||||
) : null;
|
icon={selectedOption.icon}
|
||||||
|
/>
|
||||||
|
{renderBudgetBadge()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
})()}
|
})()}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { RiSliceFill } from "@remixicon/react";
|
import { RiSliceFill } from "@remixicon/react";
|
||||||
|
import { useState } from "react";
|
||||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -11,10 +13,124 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select";
|
} 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 { cn } from "@/shared/utils/ui";
|
||||||
import { PayerSelectContent } from "../../select-items";
|
import { PayerSelectContent } from "../../select-items";
|
||||||
import type { PayerSectionProps } from "./transaction-dialog-types";
|
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({
|
export function PayerSection({
|
||||||
formState,
|
formState,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
@@ -22,17 +138,17 @@ export function PayerSection({
|
|||||||
secondaryPayerOptions,
|
secondaryPayerOptions,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
}: PayerSectionProps) {
|
}: PayerSectionProps) {
|
||||||
|
const [splitMode, setSplitMode] = useState<SplitInputMode>("currency");
|
||||||
|
|
||||||
const handlePrimaryAmountChange = (value: string) => {
|
const handlePrimaryAmountChange = (value: string) => {
|
||||||
onFieldChange("primarySplitAmount", value);
|
onFieldChange("primarySplitAmount", value);
|
||||||
const numericValue = Number.parseFloat(value) || 0;
|
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||||
const remaining = Math.max(0, totalAmount - numericValue);
|
|
||||||
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSecondaryAmountChange = (value: string) => {
|
const handleSecondaryAmountChange = (value: string) => {
|
||||||
onFieldChange("secondarySplitAmount", value);
|
onFieldChange("secondarySplitAmount", value);
|
||||||
const numericValue = Number.parseFloat(value) || 0;
|
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||||
const remaining = Math.max(0, totalAmount - numericValue);
|
|
||||||
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,23 +170,28 @@ export function PayerSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CheckboxPrimitive.Root
|
<div className="flex items-center gap-2">
|
||||||
checked={formState.isSplit}
|
{formState.isSplit ? (
|
||||||
onCheckedChange={(checked) =>
|
<SplitModeToggle mode={splitMode} onModeChange={setSplitMode} />
|
||||||
onFieldChange("isSplit", Boolean(checked))
|
) : null}
|
||||||
}
|
<CheckboxPrimitive.Root
|
||||||
aria-label="Dividir lançamento"
|
checked={formState.isSplit}
|
||||||
className={cn(
|
onCheckedChange={(checked) =>
|
||||||
"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]",
|
onFieldChange("isSplit", Boolean(checked))
|
||||||
formState.isSplit
|
}
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
aria-label="Dividir lançamento"
|
||||||
: "border-input dark:bg-input/30",
|
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
|
||||||
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
<RiSliceFill className="size-3" />
|
: "border-input dark:bg-input/30",
|
||||||
</CheckboxPrimitive.Indicator>
|
)}
|
||||||
</CheckboxPrimitive.Root>
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
||||||
|
<RiSliceFill className="size-3" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
@@ -111,14 +232,15 @@ export function PayerSection({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{formState.isSplit && (
|
{formState.isSplit ? (
|
||||||
<CurrencyInput
|
<SplitAmountField
|
||||||
|
mode={splitMode}
|
||||||
value={formState.primarySplitAmount}
|
value={formState.primarySplitAmount}
|
||||||
onValueChange={handlePrimaryAmountChange}
|
totalAmount={totalAmount}
|
||||||
placeholder="R$ 0,00"
|
onAmountChange={handlePrimaryAmountChange}
|
||||||
className="h-9 w-[45%] text-sm"
|
ariaLabel="Porcentagem da pessoa"
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,11 +285,12 @@ export function PayerSection({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<CurrencyInput
|
<SplitAmountField
|
||||||
|
mode={splitMode}
|
||||||
value={formState.secondarySplitAmount}
|
value={formState.secondarySplitAmount}
|
||||||
onValueChange={handleSecondaryAmountChange}
|
totalAmount={totalAmount}
|
||||||
placeholder="R$ 0,00"
|
onAmountChange={handleSecondaryAmountChange}
|
||||||
className="h-9 w-[45%] text-sm"
|
ariaLabel="Porcentagem do segundo pagador"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user