diff --git a/src/features/budgets/actions.ts b/src/features/budgets/actions.ts index aa9e9f0..88d67a4 100644 --- a/src/features/budgets/actions.ts +++ b/src/features/budgets/actions.ts @@ -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> { + 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; + } +} + const duplicatePreviousMonthSchema = z.object({ period: periodSchema, }); diff --git a/src/features/transactions/components/dialogs/transaction-dialog/category-section.tsx b/src/features/transactions/components/dialogs/transaction-dialog/category-section.tsx index b253b85..347b6d5 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/category-section.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/category-section.tsx @@ -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(null); + const cacheRef = useRef>(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 ( + + {formatCompactCurrency(spent)} de {formatCompactCurrency(amount)} + ({percent}%) + + ); + }; + return (
{showTransactionTypeField ? ( @@ -77,12 +153,16 @@ export function CategorySection({ const selectedOption = categoryOptions.find( (opt) => opt.value === formState.categoryId, ); - return selectedOption ? ( - - ) : null; + if (!selectedOption) return null; + return ( + + + {renderBudgetBadge()} + + ); })()} diff --git a/src/features/transactions/components/dialogs/transaction-dialog/payer-section.tsx b/src/features/transactions/components/dialogs/transaction-dialog/payer-section.tsx index 476d861..ab4928e 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/payer-section.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/payer-section.tsx @@ -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 ( + { + if (value) onModeChange(value as SplitInputMode); + }} + aria-label="Modo de entrada do split" + className="h-7 text-xs" + > + {SPLIT_MODE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + ); +} + +function SplitAmountField({ + mode, + value, + totalAmount, + onAmountChange, + ariaLabel, +}: { + mode: SplitInputMode; + value: string; + totalAmount: number; + onAmountChange: (amount: string) => void; + ariaLabel: string; +}) { + if (mode === "currency") { + return ( + + ); + } + + return ( +
+
+ { + 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" + /> + + % + +
+

+ {formatCurrency(safeToNumber(value))} +

+
+ ); +} + export function PayerSection({ formState, onFieldChange, @@ -22,17 +138,17 @@ export function PayerSection({ secondaryPayerOptions, totalAmount, }: PayerSectionProps) { + const [splitMode, setSplitMode] = useState("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({

- - 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", - )} - > - - - - +
+ {formState.isSplit ? ( + + ) : null} + + 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", + )} + > + + + + +
@@ -111,14 +232,15 @@ export function PayerSection({ ))} - {formState.isSplit && ( - - )} + ) : null}
@@ -163,11 +285,12 @@ export function PayerSection({ ))} -