mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
168
src/shared/utils/calculator.ts
Normal file
168
src/shared/utils/calculator.ts
Normal 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;
|
||||
}
|
||||
61
src/shared/utils/calendar.ts
Normal file
61
src/shared/utils/calendar.ts
Normal 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",
|
||||
];
|
||||
46
src/shared/utils/category-colors.ts
Normal file
46
src/shared/utils/category-colors.ts
Normal 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";
|
||||
}
|
||||
102
src/shared/utils/currency.ts
Normal file
102
src/shared/utils/currency.ts
Normal 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
538
src/shared/utils/date.ts
Normal 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 };
|
||||
108
src/shared/utils/export-branding.ts
Normal file
108
src/shared/utils/export-branding.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
66
src/shared/utils/financial-dates.ts
Normal file
66
src/shared/utils/financial-dates.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
65
src/shared/utils/icons.tsx
Normal file
65
src/shared/utils/icons.tsx
Normal 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;
|
||||
};
|
||||
2
src/shared/utils/index.ts
Normal file
2
src/shared/utils/index.ts
Normal 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
26
src/shared/utils/math.ts
Normal 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;
|
||||
}
|
||||
27
src/shared/utils/number.ts
Normal file
27
src/shared/utils/number.ts
Normal 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;
|
||||
}
|
||||
43
src/shared/utils/percentage.ts
Normal file
43
src/shared/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",
|
||||
});
|
||||
}
|
||||
405
src/shared/utils/period/index.ts
Normal file
405
src/shared/utils/period/index.ts
Normal 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);
|
||||
}
|
||||
43
src/shared/utils/string.ts
Normal file
43
src/shared/utils/string.ts
Normal 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
11
src/shared/utils/ui.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user