mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(core): centraliza hooks, providers e base compartilhada
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import type { ActionResult } from "./types";
|
||||
import { errorResult } from "./types";
|
||||
import type { ActionResult } from "@/lib/types/actions";
|
||||
import { errorResult } from "@/lib/types/actions";
|
||||
|
||||
/**
|
||||
* Handles errors in server actions consistently
|
||||
|
||||
143
lib/calculadora/use-calculator-keyboard.ts
Normal file
143
lib/calculadora/use-calculator-keyboard.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
]);
|
||||
}
|
||||
379
lib/calculadora/use-calculator-state.ts
Normal file
379
lib/calculadora/use-calculator-state.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
112
lib/calculadora/use-draggable-dialog.ts
Normal file
112
lib/calculadora/use-draggable-dialog.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
45
lib/cartoes/brand-assets.ts
Normal file
45
lib/cartoes/brand-assets.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const CARD_BRAND_ASSET_BY_KEY = {
|
||||
visa: "/bandeiras/visa.svg",
|
||||
mastercard: "/bandeiras/mastercard.svg",
|
||||
amex: "/bandeiras/amex.svg",
|
||||
american: "/bandeiras/amex.svg",
|
||||
elo: "/bandeiras/elo.svg",
|
||||
hipercard: "/bandeiras/hipercard.svg",
|
||||
hiper: "/bandeiras/hipercard.svg",
|
||||
} as const;
|
||||
|
||||
const CARD_BRAND_LOGO_BY_KEY = {
|
||||
visa: "/logos/visa.png",
|
||||
mastercard: "/logos/mastercard.png",
|
||||
amex: "/logos/amex.png",
|
||||
american: "/logos/amex.png",
|
||||
elo: "/logos/elo.png",
|
||||
hipercard: "/logos/hipercard.png",
|
||||
hiper: "/logos/hipercard.png",
|
||||
} as const;
|
||||
|
||||
const findMatchingCardBrandKey = (brand?: string | null) => {
|
||||
if (!brand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedBrand = brand.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
(
|
||||
Object.keys(CARD_BRAND_ASSET_BY_KEY) as Array<
|
||||
keyof typeof CARD_BRAND_ASSET_BY_KEY
|
||||
>
|
||||
).find((key) => normalizedBrand.includes(key)) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveCardBrandAsset = (brand?: string | null) => {
|
||||
const key = findMatchingCardBrandKey(brand);
|
||||
return key ? CARD_BRAND_ASSET_BY_KEY[key] : null;
|
||||
};
|
||||
|
||||
export const resolveCardBrandLogoSrc = (brand?: string | null) => {
|
||||
const key = findMatchingCardBrandKey(brand);
|
||||
return key ? CARD_BRAND_LOGO_BY_KEY[key] : null;
|
||||
};
|
||||
7
lib/cartoes/constants.ts
Normal file
7
lib/cartoes/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const DEFAULT_CARD_BRANDS = ["Visa", "Mastercard", "Elo"] as const;
|
||||
|
||||
export const DEFAULT_CARD_STATUS = ["Ativo", "Inativo"] as const;
|
||||
|
||||
export const DAYS_IN_MONTH = Array.from({ length: 31 }, (_, index) =>
|
||||
String(index + 1).padStart(2, "0"),
|
||||
);
|
||||
3
lib/hooks/index.ts
Normal file
3
lib/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useControlledState } from "./use-controlled-state";
|
||||
export { useFormState } from "./use-form-state";
|
||||
export { useIsMobile, useMobile } from "./use-mobile";
|
||||
51
lib/hooks/use-controlled-state.ts
Normal file
51
lib/hooks/use-controlled-state.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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];
|
||||
}
|
||||
60
lib/hooks/use-form-state.ts
Normal file
60
lib/hooks/use-form-state.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
28
lib/hooks/use-mobile.ts
Normal file
28
lib/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||
|
||||
export function useIsMobile() {
|
||||
const subscribe = React.useCallback((onStoreChange: () => void) => {
|
||||
if (typeof window === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const mediaQueryList = window.matchMedia(MOBILE_MEDIA_QUERY);
|
||||
mediaQueryList.addEventListener("change", onStoreChange);
|
||||
return () => mediaQueryList.removeEventListener("change", onStoreChange);
|
||||
}, []);
|
||||
|
||||
const getSnapshot = React.useCallback(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
|
||||
}, []);
|
||||
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
}
|
||||
|
||||
export const useMobile = useIsMobile;
|
||||
@@ -38,3 +38,34 @@ export const deriveNameFromLogo = (logo?: string | null) => {
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
|
||||
|
||||
type ResolveLogoSrcOptions = {
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
export const resolveLogoSrc = (
|
||||
logo?: string | null,
|
||||
options?: ResolveLogoSrcOptions,
|
||||
) => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (LOGO_SRC_PATTERN.test(logo)) {
|
||||
return logo;
|
||||
}
|
||||
|
||||
if (logo.startsWith("/")) {
|
||||
return logo;
|
||||
}
|
||||
|
||||
const fileName = normalizeLogo(logo);
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePath = options?.basePath?.replace(/\/$/, "") || "/logos";
|
||||
return `${basePath}/${fileName}`;
|
||||
};
|
||||
|
||||
3
lib/schemas/index.ts
Normal file
3
lib/schemas/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./common";
|
||||
export * from "./inbox";
|
||||
export * from "./insights";
|
||||
13
lib/types/actions.ts
Normal file
13
lib/types/actions.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
62
lib/types/calendario.ts
Normal file
62
lib/types/calendario.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
LancamentoItem,
|
||||
SelectOption,
|
||||
} from "@/components/lancamentos/types";
|
||||
|
||||
export type CalendarEvent =
|
||||
| {
|
||||
id: string;
|
||||
type: "lancamento";
|
||||
date: string;
|
||||
lancamento: LancamentoItem;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "boleto";
|
||||
date: string;
|
||||
lancamento: LancamentoItem;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "cartao";
|
||||
date: string;
|
||||
card: {
|
||||
id: string;
|
||||
name: string;
|
||||
dueDay: string;
|
||||
closingDay: string;
|
||||
brand: string | null;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
totalDue: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type CalendarPeriod = {
|
||||
period: string;
|
||||
monthName: string;
|
||||
year: number;
|
||||
};
|
||||
|
||||
export type CalendarDay = {
|
||||
date: string;
|
||||
label: string;
|
||||
isCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
events: CalendarEvent[];
|
||||
};
|
||||
|
||||
export type CalendarFormOptions = {
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId: string | null;
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
};
|
||||
|
||||
export type CalendarData = {
|
||||
events: CalendarEvent[];
|
||||
formOptions: CalendarFormOptions;
|
||||
};
|
||||
3
lib/types/index.ts
Normal file
3
lib/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./actions";
|
||||
export * from "./calendario";
|
||||
export * from "./relatorios";
|
||||
52
lib/types/relatorios.ts
Normal file
52
lib/types/relatorios.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Types for Category Report feature
|
||||
*/
|
||||
|
||||
/**
|
||||
* Monthly data for a specific category in a specific period
|
||||
*/
|
||||
export type MonthlyData = {
|
||||
period: string; // Format: "YYYY-MM"
|
||||
amount: number; // Total amount for this category in this period
|
||||
previousAmount: number; // Amount from previous period (for comparison)
|
||||
percentageChange: number | null; // Percentage change from previous period
|
||||
};
|
||||
|
||||
/**
|
||||
* Single category item in the report
|
||||
*/
|
||||
export type CategoryReportItem = {
|
||||
categoryId: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "despesa" | "receita";
|
||||
monthlyData: Map<string, MonthlyData>; // Key: period (YYYY-MM)
|
||||
total: number; // Total across all periods
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete category report data structure
|
||||
*/
|
||||
export type CategoryReportData = {
|
||||
categories: CategoryReportItem[]; // All categories with their data
|
||||
periods: string[]; // All periods in the report (sorted chronologically)
|
||||
totals: Map<string, number>; // Total per period across all categories
|
||||
grandTotal: number; // Total of all categories and all periods
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters for category report query
|
||||
*/
|
||||
export type CategoryReportFilters = {
|
||||
startPeriod: string; // Format: "YYYY-MM"
|
||||
endPeriod: string; // Format: "YYYY-MM"
|
||||
categoryIds?: string[]; // Optional: filter by specific categories
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation result for date range
|
||||
*/
|
||||
export type DateRangeValidation = {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
};
|
||||
@@ -2,6 +2,48 @@
|
||||
* Utility functions for currency/decimal formatting and parsing
|
||||
*/
|
||||
|
||||
type CurrencyFormatOptions = {
|
||||
maximumFractionDigits?: number;
|
||||
minimumFractionDigits?: number;
|
||||
notation?: Intl.NumberFormatOptions["notation"];
|
||||
};
|
||||
|
||||
export const currencyFormatter = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const currencyFormatterNoCents = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export const formatCurrency = (
|
||||
value: number,
|
||||
options: CurrencyFormatOptions = {},
|
||||
) =>
|
||||
new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
minimumFractionDigits: options.minimumFractionDigits ?? 2,
|
||||
maximumFractionDigits: options.maximumFractionDigits ?? 2,
|
||||
...(options.notation ? { notation: options.notation } : {}),
|
||||
}).format(value);
|
||||
|
||||
export const formatCurrencyCompact = (
|
||||
value: number,
|
||||
options: CurrencyFormatOptions = {},
|
||||
) =>
|
||||
formatCurrency(value, {
|
||||
minimumFractionDigits: options.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: options.maximumFractionDigits ?? 0,
|
||||
notation: options.notation ?? "compact",
|
||||
});
|
||||
|
||||
/**
|
||||
* Formats a decimal number for database storage (2 decimal places)
|
||||
* @param value - The number to format
|
||||
|
||||
@@ -37,6 +37,83 @@ const MONTH_NAMES = [
|
||||
"dezembro",
|
||||
] as const;
|
||||
|
||||
export const OPENMONETIS_TIME_ZONE = "America/Sao_Paulo";
|
||||
|
||||
type DateOnlyParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.length > 0
|
||||
? value[0]?.toUpperCase().concat(value.slice(1))
|
||||
: value;
|
||||
}
|
||||
|
||||
function buildDateOnlyString({ year, month, day }: DateOnlyParts): string {
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseDateOnlyParts(value: string): DateOnlyParts | null {
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, yearStr, monthStr, dayStr] = match;
|
||||
const year = Number.parseInt(yearStr ?? "", 10);
|
||||
const month = Number.parseInt(monthStr ?? "", 10);
|
||||
const day = Number.parseInt(dayStr ?? "", 10);
|
||||
|
||||
if (
|
||||
Number.isNaN(year) ||
|
||||
Number.isNaN(month) ||
|
||||
Number.isNaN(day) ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const utcDate = new Date(Date.UTC(year, month - 1, day));
|
||||
if (
|
||||
utcDate.getUTCFullYear() !== year ||
|
||||
utcDate.getUTCMonth() !== month - 1 ||
|
||||
utcDate.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
function getTimeZoneParts(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
): { year: number; month: number; day: number; hour: number } {
|
||||
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const parts = formatter.formatToParts(date);
|
||||
const getPart = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? "";
|
||||
|
||||
return {
|
||||
year: Number.parseInt(getPart("year"), 10),
|
||||
month: Number.parseInt(getPart("month"), 10),
|
||||
day: Number.parseInt(getPart("day"), 10),
|
||||
hour: Number.parseInt(getPart("hour"), 10),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATE CREATION & MANIPULATION
|
||||
// ============================================================================
|
||||
@@ -53,48 +130,146 @@ const MONTH_NAMES = [
|
||||
* @returns Date object in local timezone
|
||||
*/
|
||||
export function parseLocalDateString(dateString: string): Date {
|
||||
const [year, month, day] = dateString.split("-");
|
||||
return new Date(
|
||||
Number.parseInt(year ?? "0", 10),
|
||||
Number.parseInt(month ?? "1", 10) - 1,
|
||||
Number.parseInt(day ?? "1", 10),
|
||||
);
|
||||
const parts = parseDateOnlyParts(dateString);
|
||||
if (!parts) {
|
||||
return new Date(Number.NaN);
|
||||
}
|
||||
|
||||
return new Date(parts.year, parts.month - 1, parts.day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses a date string (YYYY-MM-DD) as UTC midnight
|
||||
*/
|
||||
export function parseUtcDateString(dateString: string): Date | null {
|
||||
const parts = parseDateOnlyParts(dateString);
|
||||
if (!parts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Date or date string to YYYY-MM-DD
|
||||
*/
|
||||
export function toDateOnlyString(
|
||||
value: Date | string | null | undefined,
|
||||
): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const directValue = value.slice(0, 10);
|
||||
return parseDateOnlyParts(directValue) ? directValue : null;
|
||||
}
|
||||
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildDateOnlyString({
|
||||
year: value.getUTCFullYear(),
|
||||
month: value.getUTCMonth() + 1,
|
||||
day: value.getUTCDate(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a local Date object to YYYY-MM-DD without timezone normalization
|
||||
*/
|
||||
export function toLocalDateString(
|
||||
value: Date | null | undefined,
|
||||
): string | null {
|
||||
if (!value || Number.isNaN(value.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildDateOnlyString({
|
||||
year: value.getFullYear(),
|
||||
month: value.getMonth() + 1,
|
||||
day: value.getDate(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets today's date as YYYY-MM-DD string
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
export function getTodayDateString(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
export function getTodayDateString(date: Date = new Date()): string {
|
||||
return toLocalDateString(date) ?? "";
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
/**
|
||||
* Gets a date string in YYYY-MM-DD format for a specific timezone
|
||||
*/
|
||||
export function getDateStringInTimeZone(
|
||||
timeZone: string,
|
||||
date: Date = new Date(),
|
||||
): string {
|
||||
const parts = getTimeZoneParts(date, timeZone);
|
||||
return buildDateOnlyString(parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets today's date using the app business timezone
|
||||
*/
|
||||
export function getBusinessDateString(date: Date = new Date()): string {
|
||||
return getDateStringInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets today's date as Date object
|
||||
* @returns Date object for today
|
||||
*/
|
||||
export function getTodayDate(): Date {
|
||||
return parseLocalDateString(getTodayDateString());
|
||||
export function getTodayDate(date: Date = new Date()): Date {
|
||||
return parseLocalDateString(getTodayDateString(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets today's date as Date object using the app business timezone
|
||||
*/
|
||||
export function getBusinessTodayDate(date: Date = new Date()): Date {
|
||||
return parseLocalDateString(getBusinessDateString(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets today's info (date and period)
|
||||
* @returns Object with date and period
|
||||
*/
|
||||
export function getTodayInfo(): { date: Date; period: string } {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const day = now.getDate();
|
||||
export function getTodayInfo(date: Date = new Date()): {
|
||||
date: Date;
|
||||
period: string;
|
||||
} {
|
||||
const today = getTodayDateString(date);
|
||||
const parts = parseDateOnlyParts(today);
|
||||
if (!parts) {
|
||||
return { date: new Date(Number.NaN), period: "" };
|
||||
}
|
||||
|
||||
return {
|
||||
date: new Date(year, month, day),
|
||||
period: `${year}-${String(month + 1).padStart(2, "0")}`,
|
||||
date: new Date(parts.year, parts.month - 1, parts.day),
|
||||
period: `${parts.year}-${String(parts.month).padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets today's info using the app business timezone
|
||||
*/
|
||||
export function getBusinessTodayInfo(date: Date = new Date()): {
|
||||
date: Date;
|
||||
period: string;
|
||||
} {
|
||||
const today = getBusinessDateString(date);
|
||||
const parts = parseDateOnlyParts(today);
|
||||
if (!parts) {
|
||||
return { date: new Date(Number.NaN), period: "" };
|
||||
}
|
||||
|
||||
return {
|
||||
date: new Date(parts.year, parts.month - 1, parts.day),
|
||||
period: `${parts.year}-${String(parts.month).padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,12 +301,20 @@ export function addMonthsToDate(value: Date, offset: number): Date {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Formats a date string (YYYY-MM-DD) to short display format
|
||||
* Formats a date value to short display format
|
||||
* @example
|
||||
* formatDate("2024-11-14") // "qui 14 nov"
|
||||
*/
|
||||
export function formatDate(value: string): string {
|
||||
const parsed = parseLocalDateString(value);
|
||||
export function formatDate(value: string | Date | null | undefined): string {
|
||||
const dateString = toDateOnlyString(value);
|
||||
if (!dateString) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
const parsed = parseLocalDateString(dateString);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("pt-BR", {
|
||||
weekday: "short",
|
||||
@@ -143,6 +326,154 @@ export function formatDate(value: string): string {
|
||||
.replace(" de", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date-only value (YYYY-MM-DD) using UTC to preserve the civil day
|
||||
*/
|
||||
export function formatDateOnly(
|
||||
value: string | Date | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions = {},
|
||||
): string | null {
|
||||
const dateString = toDateOnlyString(value);
|
||||
if (!dateString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseUtcDateString(dateString);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
...options,
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
export function formatDateTime(
|
||||
value: string | Date | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
},
|
||||
): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("pt-BR", options).format(parsed);
|
||||
}
|
||||
|
||||
export function formatDateOnlyLabel(
|
||||
value: string | Date | null | undefined,
|
||||
prefix?: string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string | null {
|
||||
const formatted = formatDateOnly(value, options);
|
||||
if (!formatted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return prefix ? `${prefix} ${formatted}` : formatted;
|
||||
}
|
||||
|
||||
export function formatDateTimeLabel(
|
||||
value: string | Date | null | undefined,
|
||||
prefix?: string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string | null {
|
||||
const formatted = formatDateTime(value, options);
|
||||
if (!formatted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return prefix ? `${prefix} ${formatted}` : formatted;
|
||||
}
|
||||
|
||||
export function compareDateOnly(
|
||||
left: string | Date | null | undefined,
|
||||
right: string | Date | null | undefined,
|
||||
): number {
|
||||
const leftValue = toDateOnlyString(left);
|
||||
const rightValue = toDateOnlyString(right);
|
||||
|
||||
if (!leftValue || !rightValue || leftValue === rightValue) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return leftValue < rightValue ? -1 : 1;
|
||||
}
|
||||
|
||||
export function isDateOnlyPast(
|
||||
value: string | Date | null | undefined,
|
||||
reference: string | Date | null | undefined = getBusinessDateString(),
|
||||
): boolean {
|
||||
return compareDateOnly(value, reference) < 0;
|
||||
}
|
||||
|
||||
export function isDateOnlyWithinDays(
|
||||
value: string | Date | null | undefined,
|
||||
daysThreshold: number,
|
||||
reference: string | Date | null | undefined = getBusinessDateString(),
|
||||
): boolean {
|
||||
const dateValue = toDateOnlyString(value);
|
||||
const referenceValue = toDateOnlyString(reference);
|
||||
if (
|
||||
!dateValue ||
|
||||
!referenceValue ||
|
||||
compareDateOnly(dateValue, referenceValue) < 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetDate = parseUtcDateString(dateValue);
|
||||
const referenceDate = parseUtcDateString(referenceValue);
|
||||
if (!targetDate || !referenceDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const limitDate = new Date(referenceDate);
|
||||
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
|
||||
return targetDate <= limitDate;
|
||||
}
|
||||
|
||||
export function buildDateOnlyStringFromPeriodDay(
|
||||
period: string,
|
||||
dayValue: string | number,
|
||||
): string | null {
|
||||
const [yearPart, monthPart] = period.split("-");
|
||||
const year = Number.parseInt(yearPart ?? "", 10);
|
||||
const month = Number.parseInt(monthPart ?? "", 10);
|
||||
const day = typeof dayValue === "number" ? dayValue : Number(dayValue);
|
||||
|
||||
if (
|
||||
Number.isNaN(year) ||
|
||||
Number.isNaN(month) ||
|
||||
Number.isNaN(day) ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const clampedDay = Math.min(day, daysInMonth);
|
||||
|
||||
return buildDateOnlyString({
|
||||
year,
|
||||
month,
|
||||
day: clampedDay,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date to friendly long format
|
||||
* @example
|
||||
@@ -173,5 +504,39 @@ export function getGreeting(date: Date = new Date()): string {
|
||||
return "Boa noite";
|
||||
}
|
||||
|
||||
export function getGreetingInTimeZone(
|
||||
timeZone: string,
|
||||
date: Date = new Date(),
|
||||
): string {
|
||||
const { hour } = getTimeZoneParts(date, timeZone);
|
||||
if (hour >= 5 && hour < 12) return "Bom dia";
|
||||
if (hour >= 12 && hour < 18) return "Boa tarde";
|
||||
return "Boa noite";
|
||||
}
|
||||
|
||||
export function getBusinessGreeting(date: Date = new Date()): string {
|
||||
return getGreetingInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
||||
}
|
||||
|
||||
export function formatCurrentDateInTimeZone(
|
||||
timeZone: string,
|
||||
date: Date = new Date(),
|
||||
): string {
|
||||
return capitalize(
|
||||
new Intl.DateTimeFormat("pt-BR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour12: false,
|
||||
timeZone,
|
||||
}).format(date),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBusinessCurrentDate(date: Date = new Date()): string {
|
||||
return formatCurrentDateInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
||||
}
|
||||
|
||||
// Re-export MONTH_NAMES for convenience
|
||||
export { MONTH_NAMES };
|
||||
|
||||
66
lib/utils/financial-dates.ts
Normal file
66
lib/utils/financial-dates.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
buildDateOnlyStringFromPeriodDay,
|
||||
formatDateOnlyLabel,
|
||||
} from "@/lib/utils/date";
|
||||
|
||||
type FinancialStatusLabelInput = {
|
||||
isSettled: boolean;
|
||||
dueDate: string | null;
|
||||
paidAt: string | null;
|
||||
paidPrefix?: string;
|
||||
duePrefix?: string;
|
||||
};
|
||||
|
||||
type FinancialDueDateInfo = {
|
||||
label: string;
|
||||
date: string | null;
|
||||
};
|
||||
|
||||
export function formatFinancialDateLabel(
|
||||
value: string | null,
|
||||
prefix?: string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string | null {
|
||||
return formatDateOnlyLabel(value, prefix, options);
|
||||
}
|
||||
|
||||
export function buildFinancialStatusLabel({
|
||||
isSettled,
|
||||
dueDate,
|
||||
paidAt,
|
||||
paidPrefix = "Pago em",
|
||||
duePrefix = "Vence em",
|
||||
}: FinancialStatusLabelInput): string | null {
|
||||
if (isSettled) {
|
||||
return formatFinancialDateLabel(paidAt, paidPrefix);
|
||||
}
|
||||
|
||||
return formatFinancialDateLabel(dueDate, duePrefix);
|
||||
}
|
||||
|
||||
export function buildDueDateInfoFromPeriodDay(
|
||||
period: string,
|
||||
dueDay: string,
|
||||
options?: {
|
||||
prefix?: string;
|
||||
fallbackPrefix?: string;
|
||||
},
|
||||
): FinancialDueDateInfo {
|
||||
const prefix = options?.prefix ?? "Vence em";
|
||||
const fallbackPrefix = options?.fallbackPrefix ?? "Vence dia";
|
||||
const dueDate = buildDateOnlyStringFromPeriodDay(period, dueDay);
|
||||
|
||||
if (!dueDate) {
|
||||
return {
|
||||
label: `${fallbackPrefix} ${dueDay}`,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label:
|
||||
formatFinancialDateLabel(dueDate, prefix) ??
|
||||
`${fallbackPrefix} ${dueDay}`,
|
||||
date: dueDate,
|
||||
};
|
||||
}
|
||||
43
lib/utils/percentage.ts
Normal file
43
lib/utils/percentage.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
type FormatPercentageOptions = {
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
absolute?: boolean;
|
||||
signDisplay?: Intl.NumberFormatOptions["signDisplay"];
|
||||
};
|
||||
|
||||
export function formatPercentage(
|
||||
value: number,
|
||||
options?: FormatPercentageOptions,
|
||||
): string {
|
||||
const normalizedValue = options?.absolute ? Math.abs(value) : value;
|
||||
|
||||
return `${new Intl.NumberFormat("pt-BR", {
|
||||
minimumFractionDigits: options?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: options?.maximumFractionDigits ?? 1,
|
||||
...(options?.signDisplay ? { signDisplay: options.signDisplay } : {}),
|
||||
}).format(normalizedValue)}%`;
|
||||
}
|
||||
|
||||
export function formatPercentageChange(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const absoluteValue = Math.abs(value);
|
||||
const formatterOptions =
|
||||
absoluteValue < 10
|
||||
? {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}
|
||||
: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
};
|
||||
|
||||
return formatPercentage(value, {
|
||||
...formatterOptions,
|
||||
absolute: true,
|
||||
signDisplay: value === 0 ? "auto" : "always",
|
||||
});
|
||||
}
|
||||
@@ -70,9 +70,8 @@ export function formatPeriod(year: number, month: number): string {
|
||||
* @example
|
||||
* getCurrentPeriod() // "2025-11"
|
||||
*/
|
||||
export function getCurrentPeriod(): string {
|
||||
const now = new Date();
|
||||
return formatPeriod(now.getFullYear(), now.getMonth() + 1);
|
||||
export function getCurrentPeriod(date: Date = new Date()): string {
|
||||
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,11 +174,31 @@ export function buildPeriodRange(start: string, end: string): string[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a trailing period window ending at the reference period
|
||||
* @example
|
||||
* buildPeriodWindow("2025-11", 3) // ["2025-09", "2025-10", "2025-11"]
|
||||
*/
|
||||
export function buildPeriodWindow(
|
||||
referencePeriod: string,
|
||||
totalMonths: number,
|
||||
): string[] {
|
||||
if (totalMonths <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from({ length: totalMonths }, (_, index) =>
|
||||
addMonthsToPeriod(referencePeriod, index - (totalMonths - 1)),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// URL PARAM HANDLING (mes-ano format for Portuguese URLs)
|
||||
// ============================================================================
|
||||
|
||||
const MONTH_MAP = new Map(MONTH_NAMES.map((name, index) => [name, index]));
|
||||
const MONTH_MAP = new Map<string, number>(
|
||||
MONTH_NAMES.map((name, index) => [name, index]),
|
||||
);
|
||||
|
||||
const normalize = (value: string | null | undefined) =>
|
||||
(value ?? "").trim().toLowerCase();
|
||||
@@ -271,6 +290,32 @@ function capitalize(value: string): string {
|
||||
: value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts period string (YYYY-MM) to Date object for the first day of month
|
||||
*/
|
||||
export function periodToDate(period: string): Date {
|
||||
const { year, month } = parsePeriod(period);
|
||||
return new Date(year, month - 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Date object to period string (YYYY-MM)
|
||||
*/
|
||||
export function dateToPeriod(date: Date): string {
|
||||
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats period as "Mes Ano"
|
||||
* @example
|
||||
* formatMonthYearLabel("2025-11") // "Novembro 2025"
|
||||
*/
|
||||
export function formatMonthYearLabel(period: string): string {
|
||||
const { year, month } = parsePeriod(period);
|
||||
const monthName = MONTH_NAMES[month - 1] ?? "";
|
||||
return `${capitalize(monthName)} ${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats period for display in Portuguese
|
||||
* @example
|
||||
@@ -291,6 +336,46 @@ export function formatMonthLabel(period: string): string {
|
||||
return displayPeriod(period);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats period for short display with full year
|
||||
* @example
|
||||
* formatShortPeriodLabel("2025-11") // "Nov/2025"
|
||||
*/
|
||||
export function formatShortPeriodLabel(period: string): string {
|
||||
const rawLabel = new Intl.DateTimeFormat("pt-BR", {
|
||||
month: "short",
|
||||
}).format(periodToDate(period));
|
||||
const label = capitalize(rawLabel.replace(".", ""));
|
||||
const { year } = parsePeriod(period);
|
||||
return `${label}/${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats period for compact display
|
||||
* @example
|
||||
* formatCompactPeriodLabel("2025-11") // "Nov/25"
|
||||
*/
|
||||
export function formatCompactPeriodLabel(period: string): string {
|
||||
const { year } = parsePeriod(period);
|
||||
const rawLabel = new Intl.DateTimeFormat("pt-BR", {
|
||||
month: "short",
|
||||
}).format(periodToDate(period));
|
||||
const label = capitalize(rawLabel.replace(".", ""));
|
||||
return `${label}/${String(year).slice(-2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats period as short month only
|
||||
* @example
|
||||
* formatPeriodMonthShort("2025-11") // "Nov"
|
||||
*/
|
||||
export function formatPeriodMonthShort(period: string): string {
|
||||
const rawLabel = new Intl.DateTimeFormat("pt-BR", {
|
||||
month: "short",
|
||||
}).format(periodToDate(period));
|
||||
return capitalize(rawLabel.replace(".", ""));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATE DERIVATION
|
||||
// ============================================================================
|
||||
@@ -320,5 +405,5 @@ export function derivePeriodFromDate(value?: string | null): string {
|
||||
return getCurrentPeriod();
|
||||
}
|
||||
|
||||
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||
return dateToPeriod(date);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user