feat(calculadora): adicionar dialog arrastável e seleção de valor

- Calculadora agora é arrastável via drag handle no header
- Novo callback onSelectValue permite inserir valor no campo de lançamento
- Ajustado subtitle de categorias e estilo do collapse na sidebar
- Atualizado snapshot drizzle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-06 13:22:31 +00:00
parent 4152a27f4d
commit 5bb5693baf
11 changed files with 2631 additions and 2510 deletions

View File

@@ -15,7 +15,7 @@ export default function RootLayout({
<PageDescription <PageDescription
icon={<RiPriceTag3Line />} icon={<RiPriceTag3Line />}
title="Categorias" title="Categorias"
subtitle="Gerencie suas categorias de despesas e receitas acompanhando o histórico de desempenho dos últimos 9 meses, permitindo ajustes financeiros precisos conforme necessário." subtitle="Gerencie suas categorias de despesas e receitas, permitindo ajustes financeiros precisos conforme necessário."
/> />
{children} {children}
</section> </section>

View File

@@ -16,6 +16,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useDraggableDialog } from "@/hooks/use-draggable-dialog";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
type Variant = React.ComponentProps<typeof Button>["variant"]; type Variant = React.ComponentProps<typeof Button>["variant"];
@@ -27,18 +28,62 @@ type CalculatorDialogButtonProps = {
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
withTooltip?: boolean; withTooltip?: boolean;
onSelectValue?: (value: string) => void;
}; };
function CalculatorDialogContent({
open,
onSelectValue,
}: {
open: boolean;
onSelectValue?: (value: string) => void;
}) {
const { dragHandleProps, contentRefCallback, resetPosition } =
useDraggableDialog();
React.useEffect(() => {
if (!open) {
resetPosition();
}
}, [open, resetPosition]);
return (
<DialogContent
ref={contentRefCallback}
className="p-4 sm:max-w-sm"
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader
className="cursor-grab select-none space-y-2 active:cursor-grabbing"
{...dragHandleProps}
>
<DialogTitle className="flex items-center gap-2 text-lg">
<RiCalculatorLine className="h-5 w-5" />
Calculadora
</DialogTitle>
</DialogHeader>
<Calculator isOpen={open} onSelectValue={onSelectValue} />
</DialogContent>
);
}
export function CalculatorDialogButton({ export function CalculatorDialogButton({
variant = "ghost", variant = "ghost",
size = "sm", size = "sm",
className, className,
children, children,
withTooltip = false, withTooltip = false,
onSelectValue,
}: CalculatorDialogButtonProps) { }: CalculatorDialogButtonProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
// Se withTooltip for true, usa o estilo do header const handleSelectValue = onSelectValue
? (value: string) => {
onSelectValue(value);
setOpen(false);
}
: undefined;
if (withTooltip) { if (withTooltip) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -72,20 +117,14 @@ export function CalculatorDialogButton({
Calculadora Calculadora
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<DialogContent className="p-4 sm:max-w-sm"> <CalculatorDialogContent
<DialogHeader className="space-y-2"> open={open}
<DialogTitle className="flex items-center gap-2 text-lg"> onSelectValue={handleSelectValue}
<RiCalculatorLine className="h-5 w-5" /> />
Calculadora
</DialogTitle>
</DialogHeader>
<Calculator />
</DialogContent>
</Dialog> </Dialog>
); );
} }
// Estilo padrão para outros usos
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -95,15 +134,7 @@ export function CalculatorDialogButton({
)} )}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="p-4 sm:max-w-sm"> <CalculatorDialogContent open={open} onSelectValue={handleSelectValue} />
<DialogHeader className="space-y-2">
<DialogTitle className="flex items-center gap-2 text-lg">
<RiCalculatorLine className="h-5 w-5" />
Calculadora
</DialogTitle>
</DialogHeader>
<Calculator />
</DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -1,29 +1,49 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { CalculatorButtonConfig } from "@/hooks/use-calculator-state"; import type { CalculatorButtonConfig } from "@/hooks/use-calculator-state";
import type { Operator } from "@/lib/utils/calculator";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
type CalculatorKeypadProps = { type CalculatorKeypadProps = {
buttons: CalculatorButtonConfig[][]; buttons: CalculatorButtonConfig[][];
activeOperator: Operator | null;
}; };
export function CalculatorKeypad({ buttons }: CalculatorKeypadProps) { const LABEL_TO_OPERATOR: Record<string, Operator> = {
"÷": "divide",
"×": "multiply",
"-": "subtract",
"+": "add",
};
export function CalculatorKeypad({
buttons,
activeOperator,
}: CalculatorKeypadProps) {
return ( return (
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{buttons.flat().map((btn, index) => ( {buttons.flat().map((btn, index) => {
const op = LABEL_TO_OPERATOR[btn.label];
const isActive = op != null && op === activeOperator;
return (
<Button <Button
key={`${btn.label}-${index}`} key={`${btn.label}-${index}`}
type="button" type="button"
variant={btn.variant ?? "outline"} variant={isActive ? "default" : (btn.variant ?? "outline")}
onClick={btn.onClick} onClick={btn.onClick}
className={cn( className={cn(
"h-12 text-base font-semibold", "h-12 text-base font-semibold",
btn.colSpan === 2 && "col-span-2", btn.colSpan === 2 && "col-span-2",
btn.colSpan === 3 && "col-span-3", btn.colSpan === 3 && "col-span-3",
isActive &&
"bg-primary text-primary-foreground hover:bg-primary/90 ring-2 ring-primary/30",
btn.className,
)} )}
> >
{btn.label} {btn.label}
</Button> </Button>
))} );
})}
</div> </div>
); );
} }

View File

@@ -1,27 +1,61 @@
"use client"; "use client";
import { CalculatorKeypad } from "@/components/calculadora/calculator-keypad"; import { CalculatorKeypad } from "@/components/calculadora/calculator-keypad";
import { Button } from "@/components/ui/button";
import { useCalculatorKeyboard } from "@/hooks/use-calculator-keyboard"; import { useCalculatorKeyboard } from "@/hooks/use-calculator-keyboard";
import { useCalculatorState } from "@/hooks/use-calculator-state"; import { useCalculatorState } from "@/hooks/use-calculator-state";
import { CalculatorDisplay } from "./calculator-display"; import { CalculatorDisplay } from "./calculator-display";
export default function Calculator() { type CalculatorProps = {
isOpen?: boolean;
onSelectValue?: (value: string) => void;
};
export default function Calculator({
isOpen = true,
onSelectValue,
}: CalculatorProps) {
const { const {
display,
operator,
expression, expression,
history, history,
resultText, resultText,
copied, copied,
buttons, buttons,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
copyToClipboard, copyToClipboard,
pasteFromClipboard, pasteFromClipboard,
} = useCalculatorState(); } = useCalculatorState();
useCalculatorKeyboard({ useCalculatorKeyboard({
isOpen,
canCopy: Boolean(resultText), canCopy: Boolean(resultText),
onCopy: copyToClipboard, onCopy: copyToClipboard,
onPaste: pasteFromClipboard, onPaste: pasteFromClipboard,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
}); });
const canUseValue = onSelectValue && display !== "Erro" && display !== "0";
const handleSelectValue = () => {
if (!onSelectValue) return;
const numericValue = Math.abs(Number(display)).toFixed(2);
onSelectValue(numericValue);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<CalculatorDisplay <CalculatorDisplay
@@ -31,7 +65,18 @@ export default function Calculator() {
copied={copied} copied={copied}
onCopy={copyToClipboard} onCopy={copyToClipboard}
/> />
<CalculatorKeypad buttons={buttons} /> <CalculatorKeypad buttons={buttons} activeOperator={operator} />
{onSelectValue && (
<Button
type="button"
variant="default"
className="w-full"
disabled={!canUseValue}
onClick={handleSelectValue}
>
Usar valor
</Button>
)}
</div> </div>
); );
} }

View File

@@ -67,6 +67,7 @@ export function BasicFieldsSection({
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2" className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
onSelectValue={(value) => onFieldChange("amount", value)}
> >
<RiCalculatorLine className="h-4 w-4 text-muted-foreground" /> <RiCalculatorLine className="h-4 w-4 text-muted-foreground" />
</CalculatorDialogButton> </CalculatorDialogButton>

View File

@@ -7,7 +7,6 @@ import {
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@@ -134,8 +133,8 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
{item.items?.length ? ( {item.items?.length ? (
<> <>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90 text-foreground px-2 trasition-transform duration-200"> <SidebarMenuAction className="data-[state=open]:rotate-90 px-2 trasition-transform duration-200">
<RiArrowRightSLine /> <RiArrowRightSLine className="text-primary" />
<span className="sr-only">Toggle</span> <span className="sr-only">Toggle</span>
</SidebarMenuAction> </SidebarMenuAction>
</CollapsibleTrigger> </CollapsibleTrigger>

View File

@@ -93,12 +93,8 @@
"name": "account_userId_user_id_fk", "name": "account_userId_user_id_fk",
"tableFrom": "account", "tableFrom": "account",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -172,12 +168,8 @@
"name": "anotacoes_user_id_user_id_fk", "name": "anotacoes_user_id_user_id_fk",
"tableFrom": "anotacoes", "tableFrom": "anotacoes",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -317,12 +309,8 @@
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk", "name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "lancamentos", "tableTo": "lancamentos",
"columnsFrom": [ "columnsFrom": ["lancamento_id"],
"lancamento_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -330,12 +318,8 @@
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk", "name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -343,12 +327,8 @@
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk", "name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -356,12 +336,8 @@
"name": "antecipacoes_parcelas_user_id_user_id_fk", "name": "antecipacoes_parcelas_user_id_user_id_fk",
"tableFrom": "antecipacoes_parcelas", "tableFrom": "antecipacoes_parcelas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -479,12 +455,8 @@
"name": "cartoes_user_id_user_id_fk", "name": "cartoes_user_id_user_id_fk",
"tableFrom": "cartoes", "tableFrom": "cartoes",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -492,12 +464,8 @@
"name": "cartoes_conta_id_contas_id_fk", "name": "cartoes_conta_id_contas_id_fk",
"tableFrom": "cartoes", "tableFrom": "cartoes",
"tableTo": "contas", "tableTo": "contas",
"columnsFrom": [ "columnsFrom": ["conta_id"],
"conta_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -579,12 +547,8 @@
"name": "categorias_user_id_user_id_fk", "name": "categorias_user_id_user_id_fk",
"tableFrom": "categorias", "tableFrom": "categorias",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -667,12 +631,8 @@
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk", "name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -680,12 +640,8 @@
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk", "name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["shared_with_user_id"],
"shared_with_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -693,12 +649,8 @@
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk", "name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
"tableFrom": "compartilhamentos_pagador", "tableFrom": "compartilhamentos_pagador",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["created_by_user_id"],
"created_by_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -813,12 +765,8 @@
"name": "contas_user_id_user_id_fk", "name": "contas_user_id_user_id_fk",
"tableFrom": "contas", "tableFrom": "contas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -921,12 +869,8 @@
"name": "faturas_user_id_user_id_fk", "name": "faturas_user_id_user_id_fk",
"tableFrom": "faturas", "tableFrom": "faturas",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -934,12 +878,8 @@
"name": "faturas_cartao_id_cartoes_id_fk", "name": "faturas_cartao_id_cartoes_id_fk",
"tableFrom": "faturas", "tableFrom": "faturas",
"tableTo": "cartoes", "tableTo": "cartoes",
"columnsFrom": [ "columnsFrom": ["cartao_id"],
"cartao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -1028,12 +968,8 @@
"name": "insights_salvos_user_id_user_id_fk", "name": "insights_salvos_user_id_user_id_fk",
"tableFrom": "insights_salvos", "tableFrom": "insights_salvos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1379,12 +1315,8 @@
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk", "name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "antecipacoes_parcelas", "tableTo": "antecipacoes_parcelas",
"columnsFrom": [ "columnsFrom": ["antecipacao_id"],
"antecipacao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1392,12 +1324,8 @@
"name": "lancamentos_user_id_user_id_fk", "name": "lancamentos_user_id_user_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1405,12 +1333,8 @@
"name": "lancamentos_cartao_id_cartoes_id_fk", "name": "lancamentos_cartao_id_cartoes_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "cartoes", "tableTo": "cartoes",
"columnsFrom": [ "columnsFrom": ["cartao_id"],
"cartao_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -1418,12 +1342,8 @@
"name": "lancamentos_conta_id_contas_id_fk", "name": "lancamentos_conta_id_contas_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "contas", "tableTo": "contas",
"columnsFrom": [ "columnsFrom": ["conta_id"],
"conta_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -1431,12 +1351,8 @@
"name": "lancamentos_categoria_id_categorias_id_fk", "name": "lancamentos_categoria_id_categorias_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
}, },
@@ -1444,12 +1360,8 @@
"name": "lancamentos_pagador_id_pagadores_id_fk", "name": "lancamentos_pagador_id_pagadores_id_fk",
"tableFrom": "lancamentos", "tableFrom": "lancamentos",
"tableTo": "pagadores", "tableTo": "pagadores",
"columnsFrom": [ "columnsFrom": ["pagador_id"],
"pagador_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -1531,12 +1443,8 @@
"name": "orcamentos_user_id_user_id_fk", "name": "orcamentos_user_id_user_id_fk",
"tableFrom": "orcamentos", "tableFrom": "orcamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1544,12 +1452,8 @@
"name": "orcamentos_categoria_id_categorias_id_fk", "name": "orcamentos_categoria_id_categorias_id_fk",
"tableFrom": "orcamentos", "tableFrom": "orcamentos",
"tableTo": "categorias", "tableTo": "categorias",
"columnsFrom": [ "columnsFrom": ["categoria_id"],
"categoria_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "cascade" "onUpdate": "cascade"
} }
@@ -1705,12 +1609,8 @@
"name": "pagadores_user_id_user_id_fk", "name": "pagadores_user_id_user_id_fk",
"tableFrom": "pagadores", "tableFrom": "pagadores",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1869,12 +1769,8 @@
"name": "pre_lancamentos_user_id_user_id_fk", "name": "pre_lancamentos_user_id_user_id_fk",
"tableFrom": "pre_lancamentos", "tableFrom": "pre_lancamentos",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1882,12 +1778,8 @@
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk", "name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
"tableFrom": "pre_lancamentos", "tableFrom": "pre_lancamentos",
"tableTo": "lancamentos", "tableTo": "lancamentos",
"columnsFrom": [ "columnsFrom": ["lancamento_id"],
"lancamento_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1949,12 +1841,8 @@
"name": "preferencias_usuario_user_id_user_id_fk", "name": "preferencias_usuario_user_id_user_id_fk",
"tableFrom": "preferencias_usuario", "tableFrom": "preferencias_usuario",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1964,9 +1852,7 @@
"preferencias_usuario_user_id_unique": { "preferencias_usuario_user_id_unique": {
"name": "preferencias_usuario_user_id_unique", "name": "preferencias_usuario_user_id_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["user_id"]
"user_id"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2032,12 +1918,8 @@
"name": "session_userId_user_id_fk", "name": "session_userId_user_id_fk",
"tableFrom": "session", "tableFrom": "session",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2047,9 +1929,7 @@
"session_token_unique": { "session_token_unique": {
"name": "session_token_unique", "name": "session_token_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token"]
"token"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2160,12 +2040,8 @@
"name": "tokens_api_user_id_user_id_fk", "name": "tokens_api_user_id_user_id_fk",
"tableFrom": "tokens_api", "tableFrom": "tokens_api",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2230,9 +2106,7 @@
"user_email_unique": { "user_email_unique": {
"name": "user_email_unique", "name": "user_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},

View File

@@ -1,9 +1,18 @@
import { useEffect } from "react"; import { useEffect } from "react";
import type { Operator } from "@/lib/utils/calculator";
type UseCalculatorKeyboardParams = { type UseCalculatorKeyboardParams = {
isOpen: boolean;
canCopy: boolean; canCopy: boolean;
onCopy: () => void | Promise<void>; onCopy: () => void | Promise<void>;
onPaste: () => void | Promise<void>; onPaste: () => void | Promise<void>;
inputDigit: (digit: string) => void;
inputDecimal: () => void;
setNextOperator: (op: Operator) => void;
evaluate: () => void;
deleteLastDigit: () => void;
reset: () => void;
applyPercent: () => void;
}; };
function shouldIgnoreForEditableTarget(target: EventTarget | null): boolean { function shouldIgnoreForEditableTarget(target: EventTarget | null): boolean {
@@ -17,76 +26,118 @@ function shouldIgnoreForEditableTarget(target: EventTarget | null): boolean {
); );
} }
const KEY_TO_OPERATOR: Record<string, Operator> = {
"+": "add",
"-": "subtract",
"*": "multiply",
"/": "divide",
};
export function useCalculatorKeyboard({ export function useCalculatorKeyboard({
isOpen,
canCopy, canCopy,
onCopy, onCopy,
onPaste, onPaste,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
}: UseCalculatorKeyboardParams) { }: UseCalculatorKeyboardParams) {
useEffect(() => { useEffect(() => {
if (!canCopy) { if (!isOpen) return;
return;
}
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey)) { const { key, ctrlKey, metaKey } = event;
return;
}
if (shouldIgnoreForEditableTarget(event.target)) { // Ctrl/Cmd shortcuts
return; if (ctrlKey || metaKey) {
} if (shouldIgnoreForEditableTarget(event.target)) return;
if (event.key.toLowerCase() !== "c") {
return;
}
const lowerKey = key.toLowerCase();
if (lowerKey === "c" && canCopy) {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) { if (selection && selection.toString().trim().length > 0) return;
return;
}
event.preventDefault(); event.preventDefault();
void onCopy(); void onCopy();
} else if (lowerKey === "v") {
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) return;
if (!navigator.clipboard?.readText) return;
event.preventDefault();
void onPaste();
}
return;
}
// Digits
if (key >= "0" && key <= "9") {
event.preventDefault();
inputDigit(key);
return;
}
// Decimal
if (key === "." || key === ",") {
event.preventDefault();
inputDecimal();
return;
}
// Operators
const op = KEY_TO_OPERATOR[key];
if (op) {
event.preventDefault();
setNextOperator(op);
return;
}
// Evaluate
if (key === "Enter" || key === "=") {
event.preventDefault();
evaluate();
return;
}
// Backspace
if (key === "Backspace") {
event.preventDefault();
deleteLastDigit();
return;
}
// Escape resets calculator (dialog close is handled by onEscapeKeyDown)
if (key === "Escape") {
event.preventDefault();
reset();
return;
}
// Percent
if (key === "%") {
event.preventDefault();
applyPercent();
return;
}
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [canCopy, onCopy]); }, [
isOpen,
useEffect(() => { canCopy,
const handlePasteShortcut = (event: KeyboardEvent) => { onCopy,
if (!(event.ctrlKey || event.metaKey)) { onPaste,
return; inputDigit,
} inputDecimal,
setNextOperator,
if (event.key.toLowerCase() !== "v") { evaluate,
return; deleteLastDigit,
} reset,
applyPercent,
if (shouldIgnoreForEditableTarget(event.target)) { ]);
return;
}
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) {
return;
}
if (!navigator.clipboard?.readText) {
return;
}
event.preventDefault();
void onPaste();
};
document.addEventListener("keydown", handlePasteShortcut);
return () => {
document.removeEventListener("keydown", handlePasteShortcut);
};
}, [onPaste]);
} }

View File

@@ -15,6 +15,7 @@ export type CalculatorButtonConfig = {
onClick: () => void; onClick: () => void;
variant?: VariantProps<typeof buttonVariants>["variant"]; variant?: VariantProps<typeof buttonVariants>["variant"];
colSpan?: number; colSpan?: number;
className?: string;
}; };
export function useCalculatorState() { export function useCalculatorState() {
@@ -242,8 +243,8 @@ export function useCalculatorState() {
const buttons: CalculatorButtonConfig[][] = [ const buttons: CalculatorButtonConfig[][] = [
[ [
{ label: "C", onClick: reset, variant: "destructive" }, { label: "C", onClick: reset, variant: "destructive" },
{ label: "⌫", onClick: deleteLastDigit, variant: "default" }, { label: "⌫", onClick: deleteLastDigit, variant: "secondary" },
{ label: "%", onClick: applyPercent, variant: "default" }, { label: "%", onClick: applyPercent, variant: "secondary" },
{ {
label: "÷", label: "÷",
onClick: makeOperatorHandler("divide"), onClick: makeOperatorHandler("divide"),
@@ -277,7 +278,7 @@ export function useCalculatorState() {
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" }, { label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
], ],
[ [
{ label: "±", onClick: toggleSign, variant: "default" }, { label: "±", onClick: toggleSign, variant: "secondary" },
{ label: "0", onClick: () => inputDigit("0") }, { label: "0", onClick: () => inputDigit("0") },
{ label: ",", onClick: inputDecimal }, { label: ",", onClick: inputDecimal },
{ label: "=", onClick: evaluate, variant: "default" }, { label: "=", onClick: evaluate, variant: "default" },
@@ -358,11 +359,20 @@ export function useCalculatorState() {
}, []); }, []);
return { return {
display,
operator,
expression, expression,
history, history,
resultText, resultText,
copied, copied,
buttons, buttons,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
copyToClipboard, copyToClipboard,
pasteFromClipboard, pasteFromClipboard,
}; };

View File

@@ -0,0 +1,90 @@
import { useCallback, useRef } from "react";
type Position = { x: number; y: number };
const MIN_VISIBLE_PX = 20;
function clampPosition(
x: number,
y: number,
elementWidth: number,
elementHeight: number,
): Position {
const maxX = window.innerWidth - MIN_VISIBLE_PX;
const minX = MIN_VISIBLE_PX - elementWidth;
const maxY = window.innerHeight - MIN_VISIBLE_PX;
const minY = MIN_VISIBLE_PX - elementHeight;
return {
x: Math.min(Math.max(x, minX), maxX),
y: Math.min(Math.max(y, minY), maxY),
};
}
function applyTranslate(el: HTMLElement, x: number, y: number) {
if (x === 0 && y === 0) {
el.style.translate = "";
} else {
el.style.translate = `${x}px ${y}px`;
}
}
export function useDraggableDialog() {
const offset = useRef<Position>({ x: 0, y: 0 });
const dragStart = useRef<Position | null>(null);
const initialOffset = useRef<Position>({ x: 0, y: 0 });
const contentRef = useRef<HTMLElement | null>(null);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
dragStart.current = { x: e.clientX, y: e.clientY };
initialOffset.current = { x: offset.current.x, y: offset.current.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (!dragStart.current || !contentRef.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
const rawX = initialOffset.current.x + dx;
const rawY = initialOffset.current.y + dy;
const el = contentRef.current;
const clamped = clampPosition(rawX, rawY, el.offsetWidth, el.offsetHeight);
offset.current = clamped;
applyTranslate(el, clamped.x, clamped.y);
}, []);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLElement>) => {
dragStart.current = null;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, []);
const resetPosition = useCallback(() => {
offset.current = { x: 0, y: 0 };
if (contentRef.current) {
applyTranslate(contentRef.current, 0, 0);
}
}, []);
const dragHandleProps = {
onPointerDown,
onPointerMove,
onPointerUp,
style: { touchAction: "none" as const, cursor: "grab" },
};
const contentRefCallback = useCallback((node: HTMLElement | null) => {
contentRef.current = node;
}, []);
return {
dragHandleProps,
contentRefCallback,
resetPosition,
};
}