chore(release): remove legados e fecha versao 2.0.0

This commit is contained in:
Felipe Coutinho
2026-03-09 17:15:04 +00:00
parent f724d8ac04
commit d92e70f1b9
26 changed files with 73 additions and 1848 deletions

View File

@@ -27,5 +27,6 @@
},
"eslint.enable": false,
"prettier.enable": false,
"typescript.preferences.organizeImportsCollation": "ordinal"
"typescript.preferences.organizeImportsCollation": "ordinal",
"editor.fontSize": 15
}

View File

@@ -7,6 +7,58 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased]
## [2.0.0] - 2026-03-09
### Alterado
- Hooks e memoização: removidos `useCallback`/`useMemo` preventivos em páginas de listagem, navegação, formulários de ajustes, calendário e controllers da dashboard; também foi corrigido o uso indevido de `setState` dentro de `useMemo` no relatório por categoria.
- Pagadores: a tela de detalhe agora mantém o card principal do pagador visível durante a navegação entre abas, sem repetir o bloco completo dentro de cada seção.
- Pagadores: detalhes sensíveis como envio automático, último envio e observações agora ficam ocultos quando o acesso ao pagador é somente leitura.
- Pagadores: o e-mail do pagador agora aparece apenas no cabeçalho fixo, evitando repetição dentro do card de detalhes.
- Relatório de tendências: a tabela e os cards mobile agora exibem a média mensal do período filtrado ao lado do total, com destaque visual em azul; a coluna de categoria também ficou mais compacta com truncamento para nomes longos.
- Dashboard: o welcome banner deixou de ser um bloco colorido para virar apenas texto destacado.
- Tema global: removidos do `globals.css` os tokens legados de `welcome-banner`, que não eram mais usados após a simplificação do dashboard.
- UI base: o `Card` compartilhado agora mantém a borda neutra no estado padrão e aplica um gradiente entre `border` e `primary` no hover.
- Assets: imagens que estavam soltas na raiz de `public/` foram movidas para `public/imagens/`, com atualização dos caminhos usados por landing page, logos, exports e manifesto do app.
- Dashboard: `section-cards` foi renomeado para `dashboard-metrics-cards`, deixando mais claro que o componente representa os cards de métricas no topo da dashboard.
- Dashboard: nomes em `lib/dashboard` foram padronizados por responsabilidade, com domínio `bills` em inglês no código, helpers mais explícitos e remoção do `common.ts` genérico.
- Widgets: `widget-card` foi separado entre um card base e uma versão expansível, isolando a lógica de overflow sem alterar o visual atual dos widgets.
- Dashboard: `invoices-widget` foi dividido em componentes locais (`invoices/`) e teve helpers/controle movidos para `lib/dashboard`, deixando o arquivo principal focado na composição visual.
- Dashboard: `boletos-widget` foi renomeado para `bill-widget`, ganhou componentes locais em `bills/` e teve o estado/formatadores extraídos para `lib/dashboard`.
- Dashboard: `payment-status-widget` teve os componentes visuais internos separados em uma pasta local, deixando o arquivo principal só como ponto de composição.
- Dashboard: `notes-widget` teve lista, item e composição dos dialogs separados em `notes/`, com helpers/controller extraídos para `lib/dashboard` para manter responsabilidades mais claras.
- Dashboard: `goals-progress-widget` agora separa lista, item e diálogo em `goals-progress/`, com helpers/controller extraídos para `lib/dashboard` para manter o componente principal focado em composição.
- Dashboard: `payment-overview-widget` passou a separar o shell de abas da regra de estado, isolando a composição dos widgets de condições e formas de pagamento.
- Dashboard: `payment-conditions-widget` e `payment-methods-widget` passaram a usar uma base visual compartilhada para listas de distribuição, reduzindo duplicação e deixando cada widget responsável só por mapear seus dados.
- Dashboard: os componentes internos de comportamento de pagamento foram reunidos em `payment-overview/`, deixando apenas o widget pai na raiz de `components/dashboard`.
- Dashboard: `installment-expenses-widget` teve item/lista/view separados em `installment-expenses/`, com cálculos auxiliares movidos para `lib/dashboard` para deixar o componente principal focado na composição.
- Datas: helpers de `YYYY-MM-DD`, labels de vencimento/pagamento e o relógio de negócio foram centralizados em `lib/utils/date.ts`, com adoção inicial em dashboard, pagadores, calendário, exports e actions de pagamento para reduzir drift de timezone.
- Dashboard: `bill-widget` e `invoices-widget` agora compartilham um hook base de confirmação de pagamento em `lib/dashboard`, mantendo os wrappers específicos só com as regras de cada domínio.
- Dashboard: os widgets de receita e despesa por categoria passaram a compartilhar uma view de breakdown no client e um builder de agregação no server, preservando queries separadas para não misturar regras financeiras específicas.
- Dashboard: filtros repetidos de `lancamentos` passaram a usar helpers compartilhados em `lib/dashboard/lancamento-filters.ts`, reduzindo duplicação nas queries centrais de métricas, categorias, pagamentos, compras e estabelecimentos.
- Lançamentos: a tabela deixou de quebrar ao formatar datas inválidas ou serializadas como ISO completo, normalizando `purchaseDate` para `YYYY-MM-DD` e adicionando fallback seguro no `formatDate`.
- Períodos: `lib/utils/period` passou a concentrar conversões `Date <-> YYYY-MM` e labels reutilizáveis, com adoção inicial em pickers, calendário e filtros de relatórios.
- Períodos: a adoção dos helpers centrais avançou para histórico de categorias, relatórios de cartões, exportações, insights, actions e cálculos de parcelas, reduzindo parse manual de `YYYY-MM` em regras de domínio.
- Dashboard e faturas: labels financeiros de vencimento/pagamento agora compartilham uma base em `lib/utils/financial-dates.ts`, reduzindo duplicação entre `bills` e `invoices`.
- Formatadores: porcentagens passaram a usar um util compartilhado em `lib/utils/percentage.ts`, com adoção inicial em breakdowns, metas, relatórios e cabeçalhos de categoria.
- Formatadores: moeda passou a ter base compartilhada em `lib/utils/currency.ts`, com adoção inicial em componentes compartilhados, notificações e cards de relatórios.
- Formatadores: a adoção do util de moeda avançou em resumo de fatura, extrato de conta, diálogo de orçamento, cabeçalho de pagador e histórico de categorias.
- Datas e labels: `formatDateTime` foi adicionado em `lib/utils/date.ts`, com adoção em pagadores, notificações relacionadas e no modal de calendário para reduzir repetição de `toLocaleString`/`toLocaleDateString`.
- Logos e cartões: resolução de logos e brand assets foi consolidada em `lib/logo/index.ts` e `lib/cartoes/brand-assets.ts`, com adoção principal em cartões, contas, notificações, inbox, relatórios e seletores.
- Notas: helpers transversais saíram de `lib/dashboard` e foram separados entre `lib/notes/formatters.ts` e `lib/dashboard/notes-mappers.ts`, deixando o dashboard responsável apenas pela adaptação dos dados do widget.
- Documentação: o relatório de duplicações de datas e utils agora inclui um checklist executivo com itens feitos, pendentes e o que não vale abstrair agora.
### Corrigido
- Hooks e sincronização: o provider de privacidade voltou a reagir corretamente às mudanças do modo privado, e o resumo de fatura agora reseta a data de pagamento quando a prop inicial deixa de existir.
- Compatibilidade da refatoração de hooks e relatórios: `useMobile`/`useIsMobile` voltaram a ter exports compatíveis, o shim de `components/ui/use-mobile.ts` foi restaurado para o sidebar e `lib/relatorios/types.ts` voltou a reexportar os tipos usados pelos fetchers legados.
- Widgets expansíveis: o shell compartilhado voltou a aplicar `relative` e `overflow-hidden`, mantendo o gradiente e o botão "Ver tudo" presos ao card.
- Dashboard: o widget "Lançamentos por categoria" deixou de ler a categoria salva no `sessionStorage` durante a renderização inicial, evitando mismatch de hidratação entre servidor e cliente.
### Removido
- Dashboard/Ajustes: toda a implementação legada de `magnet-lines` foi removida, incluindo componente órfão, preferência de usuário e a coluna `disable_magnetlines` do schema com migration dedicada.
## [1.7.7] - 2026-03-05
### Alterado

View File

@@ -38,6 +38,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatDateTime } from "@/lib/utils/date";
interface ApiToken {
id: string;
@@ -290,7 +291,11 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
)}
{" · "}
Criado em{" "}
{new Date(token.createdAt).toLocaleDateString("pt-BR")}
{formatDateTime(token.createdAt, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "—"}
</p>
</div>
</div>

View File

@@ -6,7 +6,7 @@ import {
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useMemo, useState, useTransition } from "react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
@@ -32,16 +32,14 @@ export function UpdateEmailForm({
const isGoogleAuth = authProvider === "google";
// Validação em tempo real: e-mails coincidem
const emailsMatch = useMemo(() => {
if (!confirmEmail) return null; // Não mostrar erro se campo vazio
return newEmail.toLowerCase() === confirmEmail.toLowerCase();
}, [newEmail, confirmEmail]);
const emailsMatch = !confirmEmail
? null
: newEmail.toLowerCase() === confirmEmail.toLowerCase();
// Validação: novo e-mail é diferente do atual
const isEmailDifferent = useMemo(() => {
if (!newEmail) return true;
return newEmail.toLowerCase() !== currentEmail.toLowerCase();
}, [newEmail, currentEmail]);
const isEmailDifferent = !newEmail
? true
: newEmail.toLowerCase() !== currentEmail.toLowerCase();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();

View File

@@ -7,7 +7,7 @@ import {
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useMemo, useState, useTransition } from "react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
@@ -85,16 +85,12 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
const isGoogleAuth = authProvider === "google";
// Validação em tempo real: senhas coincidem
const passwordsMatch = useMemo(() => {
if (!confirmPassword) return null; // Não mostrar erro se campo vazio
return newPassword === confirmPassword;
}, [newPassword, confirmPassword]);
const passwordsMatch = !confirmPassword
? null
: newPassword === confirmPassword;
// Validação de requisitos da senha
const passwordValidation = useMemo(
() => validatePassword(newPassword),
[newPassword],
);
const passwordValidation = validatePassword(newPassword);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();

View File

@@ -1,122 +0,0 @@
"use client";
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
interface AnimatedThemeTogglerProps
extends React.ComponentPropsWithoutRef<"button"> {
duration?: number;
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"));
};
updateTheme();
const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return;
await document.startViewTransition(() => {
flushSync(() => {
const newTheme = !isDark;
setIsDark(newTheme);
document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", newTheme ? "dark" : "light");
});
}).ready;
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top),
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
);
}, [isDark, duration]);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={buttonRef}
type="button"
onClick={toggleTheme}
data-state={isDark ? "dark" : "light"}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
className,
)}
{...props}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-200 data-[state=dark]:opacity-100"
>
<span className="absolute inset-0 bg-linear-to-br from-amber-500/5 via-transparent to-amber-500/15 dark:from-amber-500/10 dark:to-amber-500/30" />
</span>
{isDark ? (
<RiSunLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
) : (
<RiMoonClearLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
)}
<span className="sr-only">
{isDark ? "Ativar tema claro" : "Ativar tema escuro"}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
{isDark ? "Tema claro" : "Tema escuro"}
</TooltipContent>
</Tooltip>
);
};

View File

@@ -1,143 +0,0 @@
import { useEffect } from "react";
import type { Operator } from "@/lib/utils/calculator";
type UseCalculatorKeyboardParams = {
isOpen: boolean;
canCopy: boolean;
onCopy: () => 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 {
if (!target || !(target instanceof HTMLElement)) {
return false;
}
const tagName = target.tagName;
return (
tagName === "INPUT" || tagName === "TEXTAREA" || target.isContentEditable
);
}
const KEY_TO_OPERATOR: Record<string, Operator> = {
"+": "add",
"-": "subtract",
"*": "multiply",
"/": "divide",
};
export function useCalculatorKeyboard({
isOpen,
canCopy,
onCopy,
onPaste,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
}: UseCalculatorKeyboardParams) {
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
const { key, ctrlKey, metaKey } = event;
// Ctrl/Cmd shortcuts
if (ctrlKey || metaKey) {
if (shouldIgnoreForEditableTarget(event.target)) return;
const lowerKey = key.toLowerCase();
if (lowerKey === "c" && canCopy) {
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) return;
event.preventDefault();
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);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [
isOpen,
canCopy,
onCopy,
onPaste,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
]);
}

View File

@@ -1,379 +0,0 @@
import type { VariantProps } from "class-variance-authority";
import { useEffect, useRef, useState } from "react";
import type { buttonVariants } from "@/components/ui/button";
import {
formatLocaleValue,
formatNumber,
normalizeClipboardNumber,
OPERATOR_SYMBOLS,
type Operator,
performOperation,
} from "@/lib/utils/calculator";
export type CalculatorButtonConfig = {
label: string;
onClick: () => void;
variant?: VariantProps<typeof buttonVariants>["variant"];
colSpan?: number;
className?: string;
};
export function useCalculatorState() {
const [display, setDisplay] = useState("0");
const [accumulator, setAccumulator] = useState<number | null>(null);
const [operator, setOperator] = useState<Operator | null>(null);
const [overwrite, setOverwrite] = useState(false);
const [history, setHistory] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const resetCopiedTimeoutRef = useRef<number | undefined>(undefined);
const currentValue = Number(display);
const resultText = (() => {
if (display === "Erro") {
return null;
}
const normalized = formatNumber(currentValue);
if (normalized === "Erro") {
return null;
}
return formatLocaleValue(normalized);
})();
const reset = () => {
setDisplay("0");
setAccumulator(null);
setOperator(null);
setOverwrite(false);
setHistory(null);
};
const inputDigit = (digit: string) => {
// Check conditions before state updates
const shouldReset = overwrite || display === "Erro";
setDisplay((prev) => {
if (shouldReset) {
return digit;
}
if (prev === "0") {
return digit;
}
// Limitar a 10 dígitos (excluindo sinal negativo e ponto decimal)
const digitCount = prev.replace(/[-.]/g, "").length;
if (digitCount >= 10) {
return prev;
}
return `${prev}${digit}`;
});
// Update related states after display update
if (shouldReset) {
setOverwrite(false);
setHistory(null);
}
};
const inputDecimal = () => {
// Check conditions before state updates
const shouldReset = overwrite || display === "Erro";
setDisplay((prev) => {
if (shouldReset) {
return "0.";
}
if (prev.includes(".")) {
return prev;
}
// Limitar a 10 dígitos antes de adicionar o ponto decimal
const digitCount = prev.replace(/[-]/g, "").length;
if (digitCount >= 10) {
return prev;
}
return `${prev}.`;
});
// Update related states after display update
if (shouldReset) {
setOverwrite(false);
setHistory(null);
}
};
const setNextOperator = (nextOperator: Operator) => {
if (display === "Erro") {
reset();
return;
}
const value = currentValue;
if (accumulator === null || operator === null || overwrite) {
setAccumulator(value);
} else {
const result = performOperation(accumulator, value, operator);
const formatted = formatNumber(result);
setAccumulator(Number.isFinite(result) ? result : null);
setDisplay(formatted);
if (!Number.isFinite(result)) {
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
}
setOperator(nextOperator);
setOverwrite(true);
setHistory(null);
};
const evaluate = () => {
if (operator === null || accumulator === null || display === "Erro") {
return;
}
const value = currentValue;
const left = formatNumber(accumulator);
const right = formatNumber(value);
const symbol = OPERATOR_SYMBOLS[operator];
const operation = `${formatLocaleValue(left)} ${symbol} ${formatLocaleValue(
right,
)}`;
const result = performOperation(accumulator, value, operator);
const formatted = formatNumber(result);
setDisplay(formatted);
setAccumulator(Number.isFinite(result) ? result : null);
setOperator(null);
setOverwrite(true);
setHistory(operation);
};
const toggleSign = () => {
setDisplay((prev) => {
if (prev === "Erro") {
return prev;
}
if (prev.startsWith("-")) {
return prev.slice(1);
}
return prev === "0" ? prev : `-${prev}`;
});
if (overwrite) {
setOverwrite(false);
setHistory(null);
}
};
const deleteLastDigit = () => {
setHistory(null);
// Check conditions before state updates
const isError = display === "Erro";
setDisplay((prev) => {
if (prev === "Erro") {
return "0";
}
if (overwrite) {
return "0";
}
if (prev.length <= 1 || (prev.length === 2 && prev.startsWith("-"))) {
return "0";
}
return prev.slice(0, -1);
});
// Update related states after display update
if (isError) {
setAccumulator(null);
setOperator(null);
setOverwrite(false);
} else if (overwrite) {
setOverwrite(false);
}
};
const applyPercent = () => {
setDisplay((prev) => {
if (prev === "Erro") {
return prev;
}
const value = Number(prev);
return formatNumber(value / 100);
});
setOverwrite(true);
setHistory(null);
};
const expression = (() => {
if (display === "Erro") {
return "Erro";
}
if (operator && accumulator !== null) {
const symbol = OPERATOR_SYMBOLS[operator];
const left = formatLocaleValue(formatNumber(accumulator));
if (overwrite) {
return `${left} ${symbol}`;
}
return `${left} ${symbol} ${formatLocaleValue(display)}`;
}
return formatLocaleValue(display);
})();
const makeOperatorHandler = (nextOperator: Operator) => () =>
setNextOperator(nextOperator);
const buttons: CalculatorButtonConfig[][] = [
[
{ label: "C", onClick: reset, variant: "destructive" },
{ label: "⌫", onClick: deleteLastDigit, variant: "secondary" },
{ label: "%", onClick: applyPercent, variant: "secondary" },
{
label: "÷",
onClick: makeOperatorHandler("divide"),
variant: "outline",
},
],
[
{ label: "7", onClick: () => inputDigit("7") },
{ label: "8", onClick: () => inputDigit("8") },
{ label: "9", onClick: () => inputDigit("9") },
{
label: "×",
onClick: makeOperatorHandler("multiply"),
variant: "outline",
},
],
[
{ label: "4", onClick: () => inputDigit("4") },
{ label: "5", onClick: () => inputDigit("5") },
{ label: "6", onClick: () => inputDigit("6") },
{
label: "-",
onClick: makeOperatorHandler("subtract"),
variant: "outline",
},
],
[
{ label: "1", onClick: () => inputDigit("1") },
{ label: "2", onClick: () => inputDigit("2") },
{ label: "3", onClick: () => inputDigit("3") },
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
],
[
{ label: "±", onClick: toggleSign, variant: "secondary" },
{ label: "0", onClick: () => inputDigit("0") },
{ label: ",", onClick: inputDecimal },
{ label: "=", onClick: evaluate, variant: "default" },
],
];
const copyToClipboard = async () => {
if (!resultText) return;
try {
await navigator.clipboard.writeText(resultText);
setCopied(true);
if (resetCopiedTimeoutRef.current !== undefined) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}
resetCopiedTimeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, 2000);
} catch (error) {
console.error(
"Não foi possível copiar o resultado da calculadora.",
error,
);
}
};
const pasteFromClipboard = async () => {
if (!navigator.clipboard?.readText) return;
try {
const rawValue = await navigator.clipboard.readText();
const normalized = normalizeClipboardNumber(rawValue);
if (resetCopiedTimeoutRef.current !== undefined) {
window.clearTimeout(resetCopiedTimeoutRef.current);
resetCopiedTimeoutRef.current = undefined;
}
setCopied(false);
if (!normalized) {
setDisplay("Erro");
setAccumulator(null);
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
// Limitar a 10 dígitos
const digitCount = normalized.replace(/[-.]/g, "").length;
if (digitCount > 10) {
setDisplay("Erro");
setAccumulator(null);
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
setDisplay(normalized);
setAccumulator(null);
setOperator(null);
setOverwrite(false);
setHistory(null);
} catch (error) {
console.error("Não foi possível colar o valor na calculadora.", error);
}
};
useEffect(() => {
return () => {
if (resetCopiedTimeoutRef.current !== undefined) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}
};
}, []);
return {
display,
operator,
expression,
history,
resultText,
copied,
buttons,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
copyToClipboard,
pasteFromClipboard,
};
}

View File

@@ -1,112 +0,0 @@
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 {
// Dialog starts centered (left/top 50% + translate(-50%, -50%)).
// Clamp offsets so at least MIN_VISIBLE_PX remains visible on each axis.
const halfViewportWidth = window.innerWidth / 2;
const halfViewportHeight = window.innerHeight / 2;
const halfElementWidth = elementWidth / 2;
const halfElementHeight = elementHeight / 2;
const minX = MIN_VISIBLE_PX - (halfViewportWidth + halfElementWidth);
const maxX = halfViewportWidth + halfElementWidth - MIN_VISIBLE_PX;
const minY = MIN_VISIBLE_PX - (halfViewportHeight + halfElementHeight);
const maxY = halfViewportHeight + halfElementHeight - MIN_VISIBLE_PX;
return {
x: Math.min(Math.max(x, minX), maxX),
y: Math.min(Math.max(y, minY), maxY),
};
}
function applyPosition(el: HTMLElement, x: number, y: number) {
if (x === 0 && y === 0) {
el.style.translate = "";
el.style.transform = "";
} else {
// Keep the dialog's centered baseline (-50%, -50%) and only add drag offset.
el.style.translate = `calc(-50% + ${x}px) calc(-50% + ${y}px)`;
el.style.transform = "";
}
}
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;
applyPosition(el, clamped.x, clamped.y);
}, []);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLElement>) => {
dragStart.current = null;
if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}
}, []);
const onPointerCancel = useCallback(() => {
dragStart.current = null;
}, []);
const onLostPointerCapture = useCallback(() => {
dragStart.current = null;
}, []);
const resetPosition = useCallback(() => {
offset.current = { x: 0, y: 0 };
if (contentRef.current) {
applyPosition(contentRef.current, 0, 0);
}
}, []);
const dragHandleProps = {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture,
style: { touchAction: "none" as const, cursor: "grab" },
};
const contentRefCallback = useCallback((node: HTMLElement | null) => {
contentRef.current = node;
}, []);
return {
dragHandleProps,
contentRefCallback,
resetPosition,
};
}

View File

@@ -1,103 +0,0 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { useState, useTransition } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { buttonVariants } from "@/components/ui/button";
interface ConfirmActionDialogProps {
trigger?: React.ReactNode;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
pendingLabel?: string;
confirmVariant?: VariantProps<typeof buttonVariants>["variant"];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onConfirm?: () => Promise<void> | void;
disabled?: boolean;
className?: string;
}
export function ConfirmActionDialog({
trigger,
title,
description,
confirmLabel = "Confirmar",
cancelLabel = "Cancelar",
pendingLabel,
confirmVariant = "default",
open,
onOpenChange,
onConfirm,
disabled = false,
className,
}: ConfirmActionDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const dialogOpen = open ?? internalOpen;
const setDialogOpen = (value: boolean) => {
if (open === undefined) {
setInternalOpen(value);
}
onOpenChange?.(value);
};
const resolvedPendingLabel = pendingLabel ?? confirmLabel;
const handleConfirm = () => {
if (!onConfirm) {
setDialogOpen(false);
return;
}
startTransition(async () => {
try {
await onConfirm();
setDialogOpen(false);
} catch {
// Mantém o diálogo aberto para que o chamador trate o erro.
}
});
};
return (
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? (
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
) : null}
<AlertDialogContent className={className}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending || disabled}>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending || disabled}
className={buttonVariants({ variant: confirmVariant })}
>
{isPending ? resolvedPendingLabel : confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,11 +0,0 @@
type DotIconProps = {
color: string;
};
export default function DotIcon({ color }: DotIconProps) {
return (
<span>
<span className={`${color} flex size-2 rounded-full`}></span>
</span>
);
}

View File

@@ -1,80 +0,0 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { getFontVariable } from "@/public/fonts/font_index";
type FontContextValue = {
systemFont: string;
moneyFont: string;
setSystemFont: (key: string) => void;
setMoneyFont: (key: string) => void;
};
const FontContext = createContext<FontContextValue | null>(null);
export function FontProvider({
systemFont: initialSystemFont,
moneyFont: initialMoneyFont,
children,
}: {
systemFont: string;
moneyFont: string;
children: React.ReactNode;
}) {
const [systemFont, setSystemFontState] = useState(initialSystemFont);
const [moneyFont, setMoneyFontState] = useState(initialMoneyFont);
const applyFontVars = useCallback((sys: string, money: string) => {
document.documentElement.style.setProperty(
"--font-app",
getFontVariable(sys),
);
document.documentElement.style.setProperty(
"--font-money",
getFontVariable(money),
);
}, []);
useEffect(() => {
applyFontVars(systemFont, moneyFont);
}, [systemFont, moneyFont, applyFontVars]);
const setSystemFont = useCallback((key: string) => {
setSystemFontState(key);
}, []);
const setMoneyFont = useCallback((key: string) => {
setMoneyFontState(key);
}, []);
const value = useMemo(
() => ({ systemFont, moneyFont, setSystemFont, setMoneyFont }),
[systemFont, moneyFont, setSystemFont, setMoneyFont],
);
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `:root { --font-app: ${getFontVariable(initialSystemFont)}; --font-money: ${getFontVariable(initialMoneyFont)}; }`,
}}
/>
<FontContext value={value}>{children}</FontContext>
</>
);
}
export function useFont() {
const ctx = useContext(FontContext);
if (!ctx) {
throw new Error("useFont must be used within FontProvider");
}
return ctx;
}

View File

@@ -1,194 +0,0 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { deriveNameFromLogo } from "@/lib/logo";
import { cn } from "@/lib/utils/ui";
const DEFAULT_BASE_PATH = "/logos";
const resolveLogoSrc = (logo: string, basePath: string) => {
if (/^https?:\/\//.test(logo)) {
return logo;
}
return `${basePath.replace(/\/$/, "")}/${logo.replace(/^\//, "")}`;
};
interface LogoPickerTriggerProps {
selectedLogo?: string | null;
disabled?: boolean;
helperText?: string;
placeholder?: string;
basePath?: string;
onOpen: () => void;
className?: string;
}
export function LogoPickerTrigger({
selectedLogo,
disabled,
helperText = "Clique para trocar o logo",
placeholder = "Selecionar logo",
basePath = DEFAULT_BASE_PATH,
onOpen,
className,
}: LogoPickerTriggerProps) {
const hasLogo = Boolean(selectedLogo);
const selectedLogoLabel = deriveNameFromLogo(selectedLogo);
const selectedLogoPath =
hasLogo && selectedLogo ? resolveLogoSrc(selectedLogo, basePath) : null;
return (
<button
type="button"
onClick={onOpen}
disabled={disabled}
className={cn(
"flex w-full items-center gap-2 rounded-md border p-2 text-left transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
>
<span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/40 bg-background shadow-xs">
{selectedLogoPath ? (
<Image
src={selectedLogoPath}
alt={selectedLogoLabel || "Logo selecionado"}
width={28}
height={28}
className="h-full w-full object-contain"
/>
) : (
<span className="text-[10px] text-muted-foreground">Logo</span>
)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">
{selectedLogoLabel || placeholder}
</span>
<span className="text-xs text-muted-foreground">
{disabled ? "Nenhum logo disponível" : helperText}
</span>
</span>
</button>
);
}
interface LogoPickerDialogProps {
open: boolean;
logos: string[];
value: string;
onOpenChange: (open: boolean) => void;
onSelect: (logo: string) => void;
basePath?: string;
title?: string;
description?: string;
emptyState?: React.ReactNode;
}
export function LogoPickerDialog({
open,
logos,
value,
onOpenChange,
onSelect,
basePath = DEFAULT_BASE_PATH,
title = "Escolher logo",
description = "Selecione o logo que será usado para identificar este item.",
emptyState,
}: LogoPickerDialogProps) {
const [search, setSearch] = useState("");
const filteredLogos = logos.filter((logo) => {
if (!search.trim()) return true;
const logoLabel = deriveNameFromLogo(logo).toLowerCase();
return logoLabel.includes(search.toLowerCase().trim());
});
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) setSearch("");
onOpenChange(isOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
{logos.length > 0 && (
<Input
type="text"
placeholder="Pesquisar..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-sm"
/>
)}
{logos.length === 0 ? (
(emptyState ?? (
<p className="text-sm text-muted-foreground">
Nenhum logo encontrado. Adicione arquivos na pasta de logos.
</p>
))
) : filteredLogos.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Nenhum logo encontrado para &ldquo;{search}&rdquo;
</p>
) : (
<div className="grid max-h-custom-height-1 grid-cols-4 gap-2 overflow-y-auto p-1 sm:grid-cols-4 md:grid-cols-5">
{filteredLogos.map((logo) => {
const isActive = value === logo;
const logoLabel = deriveNameFromLogo(logo);
return (
<button
type="button"
key={logo}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onSelect(logo);
}}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className={cn(
"flex flex-col items-center gap-1 rounded-md bg-card p-2 text-center text-xs transition-all hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isActive &&
"border-primary bg-primary/5 ring-2 ring-primary/40",
)}
>
<span className="flex w-full items-center justify-center overflow-hidden rounded-full">
<Image
src={resolveLogoSrc(logo, basePath)}
alt={logoLabel || logo}
width={40}
height={40}
className="rounded-full"
/>
</span>
<span className="line-clamp-1 text-[10px] leading-tight text-muted-foreground">
{logoLabel}
</span>
</button>
);
})}
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,77 +0,0 @@
import Image from "next/image";
import { cn } from "@/lib/utils/ui";
import { version } from "@/package.json";
interface LogoProps {
variant?: "full" | "small" | "compact";
className?: string;
showVersion?: boolean;
}
export function Logo({
variant = "full",
className,
showVersion = false,
}: LogoProps) {
if (variant === "compact") {
return (
<div className={cn("flex items-center gap-1", className)}>
<Image
src="/logo_small.png"
alt="OpenMonetis"
width={32}
height={32}
className="object-contain"
priority
/>
<Image
src="/logo_text.png"
alt="OpenMonetis"
width={110}
height={32}
className="object-contain dark:invert hidden sm:block"
priority
/>
</div>
);
}
if (variant === "small") {
return (
<Image
src="/logo_small.png"
alt="OpenMonetis"
width={32}
height={32}
className={cn("object-contain", className)}
priority
/>
);
}
return (
<div className={cn("flex items-center gap-1.5 py-4", className)}>
<Image
src="/logo_small.png"
alt="OpenMonetis"
width={28}
height={28}
className="object-contain"
priority
/>
<Image
src="/logo_text.png"
alt="OpenMonetis"
width={100}
height={32}
className="object-contain dark:invert"
priority
/>
{showVersion && (
<span className="text-[9px] font-medium text-muted-foreground">
{version}
</span>
)}
</div>
);
}

View File

@@ -1,44 +0,0 @@
"use client";
import { cn } from "@/lib/utils/ui";
import { usePrivacyMode } from "./privacy-provider";
type Props = {
amount: number;
className?: string;
showPositiveSign?: boolean;
};
function MoneyValues({ amount, className, showPositiveSign = false }: Props) {
const { privacyMode } = usePrivacyMode();
const formattedValue = amount.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
const displayValue =
showPositiveSign && amount > 0 ? `+${formattedValue}` : formattedValue;
return (
<span
style={{ fontFamily: "var(--font-money)" }}
className={cn(
"inline-flex items-baseline transition-all duration-200 tracking-tighter",
privacyMode &&
"blur-[6px] select-none hover:blur-none focus-within:blur-none",
className,
)}
aria-label={privacyMode ? "Valor oculto" : displayValue}
data-privacy={privacyMode ? "hidden" : undefined}
title={
privacyMode ? "Valor oculto - passe o mouse para revelar" : undefined
}
>
{displayValue}
</span>
);
}
export default MoneyValues;

View File

@@ -1,91 +0,0 @@
"use client";
import { RiCalendarLine } from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { MonthPicker } from "@/components/ui/month-picker";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils/ui";
interface PeriodPickerProps {
value: string; // "YYYY-MM" format
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
placeholder?: string;
variant?: "default" | "outline" | "ghost";
size?: "default" | "sm" | "lg";
}
export function PeriodPicker({
value,
onChange,
disabled = false,
className,
placeholder = "Selecione o período",
variant = "outline",
size = "default",
}: PeriodPickerProps) {
const [open, setOpen] = useState(false);
// Convert period string (YYYY-MM) to Date object
const periodToDate = (period: string): Date => {
const [year, month] = period.split("-").map(Number);
return new Date(year, month - 1, 1);
};
// Convert Date object to period string (YYYY-MM)
const dateToPeriod = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
};
// Format date for display
const formatDisplay = (period: string): string => {
try {
const date = periodToDate(period);
return format(date, "MMMM yyyy", { locale: ptBR });
} catch {
return placeholder;
}
};
const handleSelect = (date: Date) => {
const period = dateToPeriod(date);
onChange(period);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={variant}
size={size}
disabled={disabled}
className={cn(
"justify-start text-left font-normal capitalize",
!value && "text-muted-foreground",
className,
)}
>
<RiCalendarLine className="h-4 w-4" />
{value ? formatDisplay(value) : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<MonthPicker
selectedMonth={value ? periodToDate(value) : new Date()}
onMonthSelect={handleSelect}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,80 +0,0 @@
"use client";
import type React from "react";
import {
createContext,
useContext,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
interface PrivacyContextType {
privacyMode: boolean;
toggle: () => void;
set: (value: boolean) => void;
}
const PrivacyContext = createContext<PrivacyContextType | undefined>(undefined);
const STORAGE_KEY = "app:privacyMode";
// Read from localStorage safely (returns false on server)
function getStoredValue(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem(STORAGE_KEY) === "true";
}
// Subscribe to storage changes
function subscribeToStorage(callback: () => void) {
window.addEventListener("storage", callback);
return () => window.removeEventListener("storage", callback);
}
export function PrivacyProvider({ children }: { children: React.ReactNode }) {
// useSyncExternalStore handles hydration safely
const storedValue = useSyncExternalStore(
subscribeToStorage,
getStoredValue,
() => false, // Server snapshot
);
const [privacyMode, setPrivacyMode] = useState(storedValue);
const isFirstRender = useRef(true);
// Sync with stored value on mount
useEffect(() => {
if (isFirstRender.current) {
setPrivacyMode(storedValue);
isFirstRender.current = false;
}
}, [storedValue]);
// Persist to localStorage when privacyMode changes
useEffect(() => {
localStorage.setItem(STORAGE_KEY, String(privacyMode));
}, [privacyMode]);
const toggle = () => {
setPrivacyMode((prev) => !prev);
};
const set = (value: boolean) => {
setPrivacyMode(value);
};
return (
<PrivacyContext.Provider value={{ privacyMode, toggle, set }}>
{children}
</PrivacyContext.Provider>
);
}
export function usePrivacyMode() {
const context = useContext(PrivacyContext);
if (context === undefined) {
throw new Error("usePrivacyMode must be used within a PrivacyProvider");
}
return context;
}

View File

@@ -1,11 +0,0 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type * as React from "react";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -1,63 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils/ui";
import DotIcon from "./dot-icon";
type TypeBadgeType =
| "receita"
| "despesa"
| "Receita"
| "Despesa"
| "Transferência"
| "transferência"
| "Saldo inicial"
| "Saldo Inicial";
interface TypeBadgeProps {
type: TypeBadgeType | string;
className?: string;
}
const TYPE_LABELS: Record<string, string> = {
receita: "Receita",
despesa: "Despesa",
Receita: "Receita",
Despesa: "Despesa",
Transferência: "Transferência",
transferência: "Transferência",
"Saldo inicial": "Saldo Inicial",
"Saldo Inicial": "Saldo Inicial",
};
export function TypeBadge({ type, className }: TypeBadgeProps) {
const normalizedType = type.toLowerCase();
const isReceita = normalizedType === "receita";
const isTransferencia = normalizedType === "transferência";
const isSaldoInicial = normalizedType === "saldo inicial";
const label = TYPE_LABELS[type] || type;
const colorClass = isTransferencia
? "text-info"
: isReceita || isSaldoInicial
? "text-success"
: "text-destructive";
const dotColor = isTransferencia
? "bg-info"
: isReceita || isSaldoInicial
? "bg-success"
: "bg-destructive";
return (
<Badge
variant={"outline"}
className={cn(
"flex items-center gap-1 px-2 text-xs",
colorClass,
className,
)}
>
<DotIcon color={dotColor} />
{label}
</Badge>
);
}

View File

@@ -1,131 +0,0 @@
"use client";
import { RiExpandDiagonalLine } from "@remixicon/react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "./ui/button";
const OVERFLOW_THRESHOLD_PX = 16;
const OVERFLOW_CHECK_DEBOUNCE_MS = 100;
type WidgetProps = {
title: string;
subtitle: string;
children: React.ReactNode;
icon: React.ReactElement;
action?: React.ReactNode;
};
export default function WidgetCard({
title,
subtitle,
icon,
children,
action,
}: WidgetProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const [hasOverflow, setHasOverflow] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const checkOverflow = useCallback(() => {
const el = contentRef.current;
if (!el) return;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
const hasOverflowNow =
el.scrollHeight - el.clientHeight > OVERFLOW_THRESHOLD_PX;
setHasOverflow(hasOverflowNow);
}, OVERFLOW_CHECK_DEBOUNCE_MS);
}, []);
useEffect(() => {
const el = contentRef.current;
if (!el) return;
// Checagem inicial
checkOverflow();
// Observa apenas resize do container (suficiente para detectar overflow)
const ro = new ResizeObserver(checkOverflow);
ro.observe(el);
return () => {
ro.disconnect();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [checkOverflow]);
return (
<Card className="md:h-custom-height-1 relative h-auto md:overflow-hidden">
<CardHeader className="border-b [.border-b]:pb-2">
<div className="flex w-full items-start justify-between">
<div>
<CardTitle className="flex items-center gap-1">
<span className="text-primary">{icon}</span>
{title}
</CardTitle>
<CardDescription className="text-muted-foreground text-sm capitalize mt-1">
{subtitle}
</CardDescription>
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
</CardHeader>
<CardContent
ref={contentRef}
className="max-h-[calc(var(--spacing-custom-height-1)-5rem)] overflow-hidden md:max-h-[calc(100%-5rem)]"
>
{children}
</CardContent>
{hasOverflow && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-linear-to-t from-card to-transparent pt-12 pb-6">
<Button
variant="outline"
className="pointer-events-auto rounded-full text-xs dark:text-white"
onClick={() => setIsOpen(true)}
aria-label="Expandir para ver todo o conteúdo"
>
Ver tudo <RiExpandDiagonalLine size={10} aria-hidden="true" />
</Button>
</div>
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-h-[85vh] w-full max-w-[calc(100%-2rem)] sm:max-w-3xl overflow-hidden p-6">
<DialogHeader className="text-left">
<DialogTitle className="flex items-center gap-2">
{icon}
<span>{title}</span>
</DialogTitle>
{subtitle ? (
<p className="text-muted-foreground text-sm">{subtitle}</p>
) : null}
</DialogHeader>
<div className="scrollbar-hide max-h-[calc(85vh-6rem)] overflow-y-auto pb-6">
{children}
</div>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -1,30 +0,0 @@
import type { ReactNode } from "react";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
type WidgetEmptyStateProps = {
icon?: ReactNode;
title: string;
description?: string;
};
export function WidgetEmptyState({
icon,
title,
description,
}: WidgetEmptyStateProps) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia>{icon}</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

View File

@@ -1,51 +0,0 @@
import { useCallback, useEffect, useState } from "react";
/**
* Hook for managing controlled/uncontrolled state pattern
* Allows a component to work both in controlled and uncontrolled mode
*
* @param controlledValue - The controlled value (undefined for uncontrolled mode)
* @param defaultValue - Default value for uncontrolled mode
* @param onChange - Callback when value changes
* @returns Tuple of [currentValue, setValue]
*
* @example
* ```tsx
* function MyComponent({ value, onChange }) {
* const [internalValue, setValue] = useControlledState(value, false, onChange);
* // Works both as controlled and uncontrolled
* }
* ```
*/
export function useControlledState<T>(
controlledValue: T | undefined,
defaultValue: T,
onChange?: (value: T) => void,
): [T, (value: T) => void] {
const [internalValue, setInternalValue] = useState<T>(defaultValue);
// Sync internal value when controlled value changes
useEffect(() => {
if (controlledValue !== undefined) {
setInternalValue(controlledValue);
}
}, [controlledValue]);
// Use controlled value if provided, otherwise use internal value
const value = controlledValue !== undefined ? controlledValue : internalValue;
const setValue = useCallback(
(newValue: T) => {
// Update internal state if uncontrolled
if (controlledValue === undefined) {
setInternalValue(newValue);
}
// Always call onChange if provided
onChange?.(newValue);
},
[controlledValue, onChange],
);
return [value, setValue];
}

View File

@@ -1,60 +0,0 @@
import { useCallback, useRef, useState } from "react";
/**
* Hook for managing form state with type-safe field updates
*
* @param initialValues - Initial form values
* @returns Object with formState, updateField, updateFields, replaceForm, resetForm
*
* @example
* ```tsx
* const { formState, updateField, resetForm } = useFormState({
* name: '',
* email: ''
* });
*
* updateField('name', 'John');
* ```
*/
export function useFormState<T extends object>(initialValues: T) {
const latestInitialValuesRef = useRef(initialValues);
latestInitialValuesRef.current = initialValues;
const [formState, setFormState] = useState<T>(initialValues);
/**
* Updates a single field in the form state
*/
const updateField = useCallback(
<K extends keyof T>(field: K, value: T[K]) => {
setFormState((prev) => ({ ...prev, [field]: value }));
},
[],
);
/**
* Resets form to initial values
*/
const resetForm = useCallback((nextValues?: T) => {
setFormState(nextValues ?? latestInitialValuesRef.current);
}, []);
/**
* Updates multiple fields at once
*/
const updateFields = useCallback((updates: Partial<T>) => {
setFormState((prev) => ({ ...prev, ...updates }));
}, []);
const replaceForm = useCallback((nextValues: T) => {
setFormState(nextValues);
}, []);
return {
formState,
updateField,
updateFields,
replaceForm,
resetForm,
};
}

View File

@@ -1,13 +0,0 @@
/**
* Standard action result type
*/
export type ActionResult<TData = void> =
| { success: true; message: string; data?: TData }
| { success: false; error: string };
/**
* Error result helper
*/
export function errorResult(error: string): ActionResult {
return { success: false, error };
}

View File

@@ -1,32 +0,0 @@
export const INVOICE_PAYMENT_STATUS = {
PENDING: "pendente",
PAID: "pago",
} as const;
export const INVOICE_STATUS_VALUES = Object.values(INVOICE_PAYMENT_STATUS);
export type InvoicePaymentStatus =
(typeof INVOICE_PAYMENT_STATUS)[keyof typeof INVOICE_PAYMENT_STATUS];
export const INVOICE_STATUS_LABEL: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Em aberto",
[INVOICE_PAYMENT_STATUS.PAID]: "Pago",
};
export const INVOICE_STATUS_BADGE_VARIANT: Record<
InvoicePaymentStatus,
"default" | "secondary" | "success" | "info"
> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "info",
[INVOICE_PAYMENT_STATUS.PAID]: "success",
};
export const INVOICE_STATUS_DESCRIPTION: Record<InvoicePaymentStatus, string> =
{
[INVOICE_PAYMENT_STATUS.PENDING]:
"Esta fatura ainda não foi quitada. Você pode realizar o pagamento assim que revisar os lançamentos.",
[INVOICE_PAYMENT_STATUS.PAID]:
"Esta fatura está quitada. Caso tenha sido um engano, é possível desfazer o pagamento.",
};
export const PERIOD_FORMAT_REGEX = /^\d{4}-\d{2}$/;

View File

@@ -1,6 +1,6 @@
{
"name": "openmonetis",
"version": "1.7.7",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",