refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,168 @@
export type Operator = "add" | "subtract" | "multiply" | "divide";
export const OPERATOR_SYMBOLS: Record<Operator, string> = {
add: "+",
subtract: "-",
multiply: "×",
divide: "÷",
};
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) {
return "Erro";
}
const rounded = Number(Math.round(value * 1e10) / 1e10);
return rounded.toString();
}
export function formatLocaleValue(rawValue: string): string {
if (rawValue === "Erro") {
return rawValue;
}
const isNegative = rawValue.startsWith("-");
const unsignedValue = isNegative ? rawValue.slice(1) : rawValue;
if (unsignedValue === "") {
return isNegative ? "-0" : "0";
}
const hasDecimalSeparator = unsignedValue.includes(".");
const [integerPartRaw, decimalPartRaw] = unsignedValue.split(".");
const integerPart = integerPartRaw || "0";
const decimalPart = hasDecimalSeparator ? (decimalPartRaw ?? "") : undefined;
const numericInteger = Number(integerPart);
const formattedInteger = Number.isFinite(numericInteger)
? numericInteger.toLocaleString("pt-BR")
: integerPart;
if (decimalPart === undefined) {
return `${isNegative ? "-" : ""}${formattedInteger}`;
}
return `${isNegative ? "-" : ""}${formattedInteger},${decimalPart}`;
}
export function performOperation(
a: number,
b: number,
operator: Operator,
): number {
switch (operator) {
case "add":
return a + b;
case "subtract":
return a - b;
case "multiply":
return a * b;
case "divide":
return b === 0 ? Infinity : a / b;
default:
return b;
}
}
// Trata colagem de valores com formatação brasileira (ponto para milhar, vírgula para decimal)
// e variações simples em formato internacional.
export function normalizeClipboardNumber(rawValue: string): string | null {
const trimmed = rawValue.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/-?[\d.,\s]+/);
if (!match) {
return null;
}
let extracted = match[0].replace(/\s+/g, "");
if (!extracted) {
return null;
}
const isNegative = extracted.startsWith("-");
if (isNegative) {
extracted = extracted.slice(1);
}
extracted = extracted.replace(/[^\d.,]/g, "");
if (!extracted) {
return null;
}
const countOccurrences = (char: string) =>
(extracted.match(new RegExp(`\\${char}`, "g")) ?? []).length;
const hasComma = extracted.includes(",");
const hasDot = extracted.includes(".");
let decimalSeparator: "," | "." | null = null;
if (hasComma && hasDot) {
decimalSeparator =
extracted.lastIndexOf(",") > extracted.lastIndexOf(".") ? "," : ".";
} else if (hasComma) {
const commaCount = countOccurrences(",");
if (commaCount > 1) {
decimalSeparator = null;
} else {
const digitsAfterComma =
extracted.length - extracted.lastIndexOf(",") - 1;
decimalSeparator =
digitsAfterComma > 0 && digitsAfterComma <= 2 ? "," : null;
}
} else if (hasDot) {
const dotCount = countOccurrences(".");
if (dotCount > 1) {
decimalSeparator = null;
} else {
const digitsAfterDot = extracted.length - extracted.lastIndexOf(".") - 1;
const decimalCandidate = extracted.slice(extracted.lastIndexOf(".") + 1);
const allZeros = /^0+$/.test(decimalCandidate);
const shouldTreatAsDecimal =
digitsAfterDot > 0 &&
digitsAfterDot <= 3 &&
!(digitsAfterDot === 3 && allZeros);
decimalSeparator = shouldTreatAsDecimal ? "." : null;
}
}
let integerPart = extracted;
let decimalPart = "";
if (decimalSeparator) {
const decimalIndex = extracted.lastIndexOf(decimalSeparator);
integerPart = extracted.slice(0, decimalIndex);
decimalPart = extracted.slice(decimalIndex + 1);
}
integerPart = integerPart.replace(/[^\d]/g, "");
decimalPart = decimalPart.replace(/[^\d]/g, "");
if (!integerPart) {
integerPart = "0";
}
let normalized = integerPart;
if (decimalPart) {
normalized = `${integerPart}.${decimalPart}`;
}
if (isNegative && Number(normalized) !== 0) {
normalized = `-${normalized}`;
}
if (!/^(-?\d+(\.\d+)?)$/.test(normalized)) {
return null;
}
const numericValue = Number(normalized);
if (!Number.isFinite(numericValue)) {
return null;
}
return normalized;
}

View File

@@ -0,0 +1,61 @@
import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar";
import { toDateOnlyString } from "@/shared/utils/date";
export const formatDateKey = (date: Date) => toDateOnlyString(date) ?? "";
const getWeekdayIndex = (date: Date) => {
const day = date.getUTCDay(); // 0 (domingo) - 6 (sábado)
// Ajusta para segunda-feira como primeiro dia
return day === 0 ? 6 : day - 1;
};
export const buildCalendarDays = ({
year,
monthIndex,
events,
}: {
year: number;
monthIndex: number;
events: Map<string, CalendarEvent[]>;
}): CalendarDay[] => {
const startOfMonth = new Date(Date.UTC(year, monthIndex, 1));
const offset = getWeekdayIndex(startOfMonth);
const startDate = new Date(Date.UTC(year, monthIndex, 1 - offset));
const totalCells = 42; // 6 semanas
const now = new Date();
const todayKey = formatDateKey(
new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())),
);
const days: CalendarDay[] = [];
for (let index = 0; index < totalCells; index += 1) {
const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + index);
const dateKey = formatDateKey(currentDate);
const isCurrentMonth = currentDate.getUTCMonth() === monthIndex;
const dateLabel = currentDate.getUTCDate().toString();
const eventsForDay = events.get(dateKey) ?? [];
days.push({
date: dateKey,
label: dateLabel,
isCurrentMonth,
isToday: dateKey === todayKey,
events: eventsForDay,
});
}
return days;
};
export const WEEK_DAYS_SHORT = [
"Seg",
"Ter",
"Qua",
"Qui",
"Sex",
"Sáb",
"Dom",
];

View File

@@ -0,0 +1,46 @@
/**
* Cores para categorias em widgets e listas
* Usadas para colorir ícones e backgrounds de categorias
*/
export const CATEGORY_COLORS = [
"#ef4444", // red
"#3b82f6", // blue
"#10b981", // emerald
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#6366f1", // indigo
"#84cc16", // lime
] as const;
/**
* Retorna a cor para um índice específico (com ciclo)
*/
export function getCategoryColor(index: number): string {
return CATEGORY_COLORS[index % CATEGORY_COLORS.length];
}
/**
* Retorna a cor de background com transparência
*/
export function getCategoryBgColor(index: number): string {
const color = getCategoryColor(index);
return `${color}15`;
}
/**
* Gera iniciais a partir de um nome
*/
export function buildCategoryInitials(value: string): string {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "CT";
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
}

View File

@@ -0,0 +1,102 @@
/**
* 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
* @returns Formatted string with 2 decimal places, or null if input is null
*/
export function formatDecimalForDb(value: number | null): string | null {
if (value === null) {
return null;
}
return (Math.round(value * 100) / 100).toFixed(2);
}
/**
* Formats a decimal number for database storage (non-nullable version)
* @param value - The number to format
* @returns Formatted string with 2 decimal places
*/
export function formatDecimalForDbRequired(value: number): string {
return (Math.round(value * 100) / 100).toFixed(2);
}
/**
* Normalizes decimal input by replacing comma with period
* @param value - Input string
* @returns Normalized string with period as decimal separator
*/
export function normalizeDecimalInput(value: string): string {
return value.replace(/\s/g, "").replace(",", ".");
}
/**
* Formats a limit/balance input for display
* @param value - The number to format
* @returns Formatted string or empty string
*/
export function formatLimitInput(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return "";
}
return (Math.round(value * 100) / 100).toFixed(2);
}
/**
* Formats an initial balance input for display (defaults to "0.00")
* @param value - The number to format
* @returns Formatted string with default "0.00"
*/
export function formatInitialBalanceInput(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return "0.00";
}
return (Math.round(value * 100) / 100).toFixed(2);
}

538
src/shared/utils/date.ts Normal file
View File

@@ -0,0 +1,538 @@
/**
* Date utilities - Functions for date manipulation and formatting
*
* This module consolidates date-related utilities from:
* - /lib/utils/date.ts (basic date manipulation)
* - /lib/date/index.ts (formatting and display)
*
* Note: Period-related functions (YYYY-MM) are in /lib/utils/period
*/
import { capitalize } from "@/shared/utils/string";
// ============================================================================
// CONSTANTS
// ============================================================================
const WEEKDAY_NAMES = [
"Domingo",
"Segunda",
"Terça",
"Quarta",
"Quinta",
"Sexta",
"Sábado",
] as const;
const MONTH_NAMES = [
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro",
] as const;
export const OPENMONETIS_TIME_ZONE = "America/Sao_Paulo";
type DateOnlyParts = {
year: number;
month: number;
day: number;
};
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
// ============================================================================
/**
* Safely parses a date string (YYYY-MM-DD) as a local date
*
* IMPORTANT: new Date("2025-11-25") treats the date as UTC midnight,
* which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time!
*
* This function always interprets the date string in the local timezone.
*
* @param dateString - Date string in YYYY-MM-DD format
* @returns Date object in local timezone
*/
export function parseLocalDateString(dateString: string): Date {
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(date: Date = new Date()): string {
return toLocalDateString(date) ?? "";
}
/**
* 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: 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 = 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(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")}`,
};
}
/**
* Adds months to a date
* @param value - Date to add months to
* @param offset - Number of months to add (can be negative)
* @returns New date with months added
*/
export function addMonthsToDate(value: Date, offset: number): Date {
const result = new Date(value);
const originalDay = result.getDate();
result.setDate(1);
result.setMonth(result.getMonth() + offset);
const lastDay = new Date(
result.getFullYear(),
result.getMonth() + 1,
0,
).getDate();
result.setDate(Math.min(originalDay, lastDay));
return result;
}
// ============================================================================
// DATE FORMATTING
// ============================================================================
/**
* Formats a date value to short display format
* @example
* formatDate("2024-11-14") // "qui 14 nov"
*/
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",
day: "2-digit",
month: "short",
})
.format(parsed)
.replace(".", "")
.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
* friendlyDate(new Date()) // "Segunda, 14 de novembro de 2025"
*/
export function friendlyDate(date: Date): string {
const weekday = WEEKDAY_NAMES[date.getDay()];
const day = date.getDate();
const month = MONTH_NAMES[date.getMonth()];
const year = date.getFullYear();
return `${weekday}, ${day} de ${month} de ${year}`;
}
// ============================================================================
// TIME-BASED UTILITIES
// ============================================================================
/**
* Gets appropriate greeting based on time of day
* @param date - Date to get greeting for (defaults to now)
* @returns "Bom dia", "Boa tarde", or "Boa noite"
*/
export function getGreeting(date: Date = new Date()): string {
const hour = date.getHours();
if (hour >= 5 && hour < 12) return "Bom dia";
if (hour >= 12 && hour < 18) return "Boa tarde";
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 };

View File

@@ -0,0 +1,108 @@
const FALLBACK_PRIMARY_COLOR: [number, number, number] = [201, 106, 58];
const RGB_PATTERN = /\d+(?:\.\d+)?/g;
function parseRgbColor(value: string): [number, number, number] | null {
if (!value.toLowerCase().startsWith("rgb")) {
return null;
}
const channels = value.match(RGB_PATTERN);
if (!channels || channels.length < 3) {
return null;
}
const red = Number.parseFloat(channels[0]);
const green = Number.parseFloat(channels[1]);
const blue = Number.parseFloat(channels[2]);
if ([red, green, blue].some((channel) => Number.isNaN(channel))) {
return null;
}
return [Math.round(red), Math.round(green), Math.round(blue)];
}
function resolveCssColor(value: string): [number, number, number] | null {
if (typeof window === "undefined" || typeof document === "undefined") {
return null;
}
const probe = document.createElement("span");
probe.style.position = "fixed";
probe.style.opacity = "0";
probe.style.pointerEvents = "none";
probe.style.color = "";
probe.style.color = value;
if (!probe.style.color) {
return null;
}
document.body.appendChild(probe);
const resolved = window.getComputedStyle(probe).color;
document.body.removeChild(probe);
return parseRgbColor(resolved);
}
export function getPrimaryPdfColor(): [number, number, number] {
if (typeof window === "undefined" || typeof document === "undefined") {
return FALLBACK_PRIMARY_COLOR;
}
const rootStyles = window.getComputedStyle(document.documentElement);
const rawPrimary = rootStyles.getPropertyValue("--primary").trim();
const rawColorPrimary = rootStyles.getPropertyValue("--color-primary").trim();
const candidates = [rawPrimary, rawColorPrimary].filter(Boolean);
for (const candidate of candidates) {
const resolved = resolveCssColor(candidate);
if (resolved) {
return resolved;
}
}
return FALLBACK_PRIMARY_COLOR;
}
export async function loadExportLogoDataUrl(
logoPath = "/images/logo_text.png",
): Promise<string | null> {
if (typeof window === "undefined" || typeof document === "undefined") {
return null;
}
return new Promise((resolve) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (!width || !height) {
resolve(null);
return;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
resolve(null);
return;
}
context.drawImage(image, 0, 0, width, height);
try {
resolve(canvas.toDataURL("image/png"));
} catch {
resolve(null);
}
};
image.onerror = () => resolve(null);
image.src = logoPath;
});
}

View File

@@ -0,0 +1,66 @@
import {
buildDateOnlyStringFromPeriodDay,
formatDateOnlyLabel,
} from "@/shared/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,
};
}

View File

@@ -0,0 +1,65 @@
import * as RemixIcons from "@remixicon/react";
import type { ComponentType, ReactNode } from "react";
const ICON_CLASS = "h-4 w-4";
const normalizeKey = (value: string) =>
value
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
export const getIconComponent = (
iconName: string,
): ComponentType<{ className?: string }> | null => {
// Busca o ícone no objeto de ícones do Remix Icon
const icon = (RemixIcons as Record<string, unknown>)[iconName];
if (icon && typeof icon === "function") {
return icon as ComponentType<{ className?: string }>;
}
return null;
};
export const getConditionIcon = (condition: string): ReactNode => {
const key = normalizeKey(condition);
const registry: Record<string, ReactNode> = {
parcelado: <RemixIcons.RiLoader2Fill className={ICON_CLASS} aria-hidden />,
recorrente: <RemixIcons.RiRefreshLine className={ICON_CLASS} aria-hidden />,
avista: <RemixIcons.RiCheckLine className={ICON_CLASS} aria-hidden />,
vista: <RemixIcons.RiCheckLine className={ICON_CLASS} aria-hidden />,
};
return registry[key] ?? null;
};
export const getPaymentMethodIcon = (paymentMethod: string): ReactNode => {
const key = normalizeKey(paymentMethod);
const registry: Record<string, ReactNode> = {
dinheiro: (
<RemixIcons.RiMoneyDollarCircleLine className={ICON_CLASS} aria-hidden />
),
pix: <RemixIcons.RiPixLine className={ICON_CLASS} aria-hidden />,
boleto: <RemixIcons.RiBarcodeLine className={ICON_CLASS} aria-hidden />,
credito: (
<RemixIcons.RiMoneyDollarCircleLine className={ICON_CLASS} aria-hidden />
),
cartaodecredito: (
<RemixIcons.RiBankCard2Line className={ICON_CLASS} aria-hidden />
),
cartaodedebito: (
<RemixIcons.RiBankCard2Line className={ICON_CLASS} aria-hidden />
),
debito: <RemixIcons.RiBankCard2Line className={ICON_CLASS} aria-hidden />,
prepagovrva: <RemixIcons.RiCouponLine className={ICON_CLASS} aria-hidden />,
transferenciabancaria: (
<RemixIcons.RiExchangeLine className={ICON_CLASS} aria-hidden />
),
};
return registry[key] ?? null;
};

View File

@@ -0,0 +1,2 @@
// Re-export from lib/utils/ui.ts for shadcn compatibility
export { cn } from "./ui";

26
src/shared/utils/math.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Utility functions for mathematical calculations
*/
/**
* Calculates percentage change between two values
* @param current - Current value
* @param previous - Previous value
* @returns Percentage change or null if previous is 0 and current is also 0
*/
export function calculatePercentageChange(
current: number,
previous: number,
): number | null {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return null;
return current > 0 ? 100 : -100;
}
const change = ((current - previous) / Math.abs(previous)) * 100;
// Protege contra valores absurdos (retorna null se > 1 milhão %)
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
}

View File

@@ -0,0 +1,27 @@
/**
* Utility functions for safe number conversions
*/
/**
* Safely converts unknown value to number
* @param value - Value to convert
* @param defaultValue - Default value if conversion fails
* @returns Converted number or default value
*/
export function safeToNumber(value: unknown, defaultValue: number = 0): number {
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
if (value === null || value === undefined) {
return defaultValue;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
}

View 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",
});
}

View File

@@ -0,0 +1,405 @@
/**
* Period utilities - Consolidated module for period (YYYY-MM) manipulation
*
* This module consolidates period-related functionality from:
* - /lib/month-period.ts (URL param handling)
* - /lib/period/index.ts (YYYY-MM operations)
* - /hooks/use-dates.ts (month navigation)
* - /lib/transactions/period-helpers.ts (formatting)
*
* Moved from /lib/period to /lib/utils/period for better organization
*/
import { capitalize } from "@/shared/utils/string";
// ============================================================================
// CONSTANTS
// ============================================================================
export const MONTH_NAMES = [
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro",
] as const;
// ============================================================================
// CORE PARSING & FORMATTING (YYYY-MM format)
// ============================================================================
/**
* Parses period string into year and month
* @param period - Period string in YYYY-MM format
* @returns Object with year and month numbers
* @throws Error if period format is invalid
*/
export function parsePeriod(period: string): { year: number; month: number } {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) {
throw new Error(`Período inválido: ${period}`);
}
return { year, month };
}
/**
* Formats year and month into period string
* @param year - Year number
* @param month - Month number (1-12)
* @returns Period string in YYYY-MM format
*/
export function formatPeriod(year: number, month: number): string {
return `${year}-${String(month).padStart(2, "0")}`;
}
// ============================================================================
// PERIOD NAVIGATION
// ============================================================================
/**
* Returns the current period in YYYY-MM format
* @example
* getCurrentPeriod() // "2025-11"
*/
export function getCurrentPeriod(date: Date = new Date()): string {
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
}
/**
* Gets the previous period
* @param period - Current period in YYYY-MM format
* @returns Previous period string
*/
export function getPreviousPeriod(period: string): string {
const { year, month } = parsePeriod(period);
if (month === 1) {
return formatPeriod(year - 1, 12);
}
return formatPeriod(year, month - 1);
}
/**
* Gets the next period
* @param period - Current period in YYYY-MM format
* @returns Next period string
*/
export function getNextPeriod(period: string): string {
const { year, month } = parsePeriod(period);
if (month === 12) {
return formatPeriod(year + 1, 1);
}
return formatPeriod(year, month + 1);
}
/**
* Adds months to a period
* @param period - Period string in YYYY-MM format
* @param offset - Number of months to add (can be negative)
* @returns New period string
*/
export function addMonthsToPeriod(period: string, offset: number): string {
const { year: baseYear, month: baseMonth } = parsePeriod(period);
const date = new Date(baseYear, baseMonth - 1, 1);
date.setMonth(date.getMonth() + offset);
const nextYear = date.getFullYear();
const nextMonth = date.getMonth() + 1;
return formatPeriod(nextYear, nextMonth);
}
// ============================================================================
// PERIOD COMPARISON & RANGES
// ============================================================================
/**
* Compares two periods
* @param a - First period
* @param b - Second period
* @returns -1 if a < b, 0 if equal, 1 if a > b
*/
export function comparePeriods(a: string, b: string): number {
if (a === b) return 0;
return a < b ? -1 : 1;
}
/**
* Builds a range of periods between start and end (inclusive)
* @param start - Start period
* @param end - End period
* @returns Array of period strings
*/
export function buildPeriodRange(start: string, end: string): string[] {
const [startKey, endKey] =
comparePeriods(start, end) <= 0 ? [start, end] : [end, start];
const startParts = parsePeriod(startKey);
const endParts = parsePeriod(endKey);
const items: string[] = [];
let currentYear = startParts.year;
let currentMonth = startParts.month;
while (
currentYear < endParts.year ||
(currentYear === endParts.year && currentMonth <= endParts.month)
) {
items.push(formatPeriod(currentYear, currentMonth));
if (currentYear === endParts.year && currentMonth === endParts.month) {
break;
}
currentMonth += 1;
if (currentMonth > 12) {
currentMonth = 1;
currentYear += 1;
}
}
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<string, number>(
MONTH_NAMES.map((name, index) => [name, index]),
);
const normalize = (value: string | null | undefined) =>
(value ?? "").trim().toLowerCase();
export type ParsedPeriod = {
period: string;
monthName: string;
year: number;
};
/**
* Parses URL param in "mes-ano" format (e.g., "novembro-2025")
* @param periodParam - URL parameter string
* @param referenceDate - Fallback date if param is invalid
* @returns Parsed period object
*/
export function parsePeriodParam(
periodParam: string | null | undefined,
referenceDate = new Date(),
): ParsedPeriod {
const fallbackMonthIndex = referenceDate.getMonth();
const fallbackYear = referenceDate.getFullYear();
const fallbackPeriod = formatPeriod(fallbackYear, fallbackMonthIndex + 1);
if (!periodParam) {
const monthName = MONTH_NAMES[fallbackMonthIndex];
return { period: fallbackPeriod, monthName, year: fallbackYear };
}
const [rawMonth, rawYear] = periodParam.split("-");
const normalizedMonth = normalize(rawMonth);
const monthIndex = MONTH_MAP.get(normalizedMonth);
const parsedYear = Number.parseInt(rawYear ?? "", 10);
if (monthIndex === undefined || Number.isNaN(parsedYear)) {
const monthName = MONTH_NAMES[fallbackMonthIndex];
return { period: fallbackPeriod, monthName, year: fallbackYear };
}
const monthName = MONTH_NAMES[monthIndex];
return {
period: formatPeriod(parsedYear, monthIndex + 1),
monthName,
year: parsedYear,
};
}
/**
* Formats month name and year to URL param format
* @param monthName - Month name in Portuguese
* @param year - Year number
* @returns URL param string in "mes-ano" format
*/
export function formatPeriodParam(monthName: string, year: number): string {
return `${normalize(monthName)}-${year}`;
}
/**
* Converts period from YYYY-MM format to URL param format
* @example
* formatPeriodForUrl("2025-11") // "novembro-2025"
* formatPeriodForUrl("2025-01") // "janeiro-2025"
*/
export function formatPeriodForUrl(period: string): string {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1;
if (
Number.isNaN(year) ||
Number.isNaN(monthIndex) ||
monthIndex < 0 ||
monthIndex > 11
) {
return period;
}
const monthName = MONTH_NAMES[monthIndex] ?? "";
return formatPeriodParam(monthName, year);
}
// ============================================================================
// DISPLAY FORMATTING
// ============================================================================
/**
* 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
* displayPeriod("2025-11") // "Novembro de 2025"
*/
export function displayPeriod(period: string): string {
const { year, month } = parsePeriod(period);
const monthName = MONTH_NAMES[month - 1] ?? "";
return `${capitalize(monthName)} de ${year}`;
}
/**
* Alias for displayPeriod - formats period for display
* @example
* formatMonthLabel("2024-01") // "Janeiro de 2024"
*/
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
// ============================================================================
/**
* Derives a period (YYYY-MM) from a date string or current date
* @example
* derivePeriodFromDate("2024-01-15") // "2024-01"
* derivePeriodFromDate() // current period
*/
export function derivePeriodFromDate(value?: string | null): string {
if (!value) {
return getCurrentPeriod();
}
// Parse date string as local date to avoid timezone issues
// IMPORTANT: new Date("2025-11-25") treats the date as UTC midnight,
// which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time!
const [year, month, day] = value.split("-");
const date = new Date(
Number.parseInt(year ?? "0", 10),
Number.parseInt(month ?? "1", 10) - 1,
Number.parseInt(day ?? "1", 10),
);
if (Number.isNaN(date.getTime())) {
return getCurrentPeriod();
}
return dateToPeriod(date);
}

View File

@@ -0,0 +1,43 @@
/**
* Utility functions for string normalization and manipulation
*/
/**
* Capitalizes the first letter of a string
*/
export function capitalize(value: string): string {
return value.length > 0
? value[0]?.toUpperCase().concat(value.slice(1))
: value;
}
/**
* Normalizes optional string - trims and returns null if empty
* @param value - String to normalize
* @returns Trimmed string or null if empty
*/
export function normalizeOptionalString(
value: string | null | undefined,
): string | null {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
}
/**
* Normalizes file path by extracting filename
* @param path - File path to normalize
* @returns Filename without path
*/
export function normalizeFilePath(path: string | null | undefined): string {
return path?.split("/").filter(Boolean).pop() ?? "";
}
/**
* Normalizes icon input - trims and returns null if empty
* @param icon - Icon string to normalize
* @returns Trimmed icon string or null
*/
export function normalizeIconInput(icon?: string | null): string | null {
const trimmed = icon?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
}

11
src/shared/utils/ui.ts Normal file
View File

@@ -0,0 +1,11 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merges Tailwind CSS classes with proper override handling
* @param inputs - Class values to merge
* @returns Merged className string
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}