feat: implementar relatórios de categorias e substituir seleção de período por picker visual
BREAKING CHANGE: Remove feature de seleção de período das preferências do usuário
Alterações principais:
- Adiciona sistema completo de relatórios por categoria
- Cria página /relatorios/categorias com filtros e visualizações
- Implementa tabela e gráfico de evolução mensal
- Adiciona funcionalidade de exportação de dados
- Cria skeleton otimizado para melhor UX de loading
- Remove feature de seleção de período das preferências
- Deleta lib/user-preferences/period.ts
- Remove colunas periodMonthsBefore e periodMonthsAfter do schema
- Remove todas as referências em 16+ arquivos
- Atualiza database schema via Drizzle
- Substitui Select de período por MonthPicker visual
- Implementa componente PeriodPicker reutilizável
- Integra shadcn MonthPicker customizado (português, Remix icons)
- Substitui createMonthOptions em todos os formulários
- Mantém formato "YYYY-MM" no banco de dados
- Melhora design da tabela de relatórios
- Mescla colunas Categoria e Tipo em uma única coluna
- Substitui badge de tipo por dot colorido discreto
- Reduz largura da tabela em ~120px
- Atualiza skeleton para refletir nova estrutura
- Melhorias gerais de UI
- Reduz espaçamento entre títulos da sidebar (p-2 → px-2 py-1)
- Adiciona MonthNavigation para navegação entre períodos
- Otimiza loading states com skeletons detalhados
This commit is contained in:
180
lib/relatorios/fetch-category-chart-data.ts
Normal file
180
lib/relatorios/fetch-category-chart-data.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Data fetching function for Category Chart (based on selected filters)
|
||||
*/
|
||||
|
||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { generatePeriodRange } from "./utils";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
|
||||
export type CategoryChartData = {
|
||||
months: string[]; // Short month labels (e.g., "JAN", "FEV")
|
||||
categories: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "despesa" | "receita";
|
||||
}>;
|
||||
chartData: Array<{
|
||||
month: string;
|
||||
[categoryName: string]: number | string;
|
||||
}>;
|
||||
allCategories: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "despesa" | "receita";
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function fetchCategoryChartData(
|
||||
userId: string,
|
||||
startPeriod: string,
|
||||
endPeriod: string,
|
||||
categoryIds?: string[]
|
||||
): Promise<CategoryChartData> {
|
||||
// Generate all periods in the range
|
||||
const periods = generatePeriodRange(startPeriod, endPeriod);
|
||||
|
||||
// Build WHERE conditions
|
||||
const whereConditions = [
|
||||
eq(lancamentos.userId, userId),
|
||||
inArray(lancamentos.period, periods),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
),
|
||||
];
|
||||
|
||||
// Add optional category filter
|
||||
if (categoryIds && categoryIds.length > 0) {
|
||||
whereConditions.push(inArray(categorias.id, categoryIds));
|
||||
}
|
||||
|
||||
// Query to get aggregated data by category and period
|
||||
const rows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
categoryType: categorias.type,
|
||||
period: lancamentos.period,
|
||||
total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.where(and(...whereConditions))
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
categorias.type,
|
||||
lancamentos.period
|
||||
);
|
||||
|
||||
// Fetch all categories for the user (for category selection)
|
||||
const allCategoriesRows = await db
|
||||
.select({
|
||||
id: categorias.id,
|
||||
name: categorias.name,
|
||||
icon: categorias.icon,
|
||||
type: categorias.type,
|
||||
})
|
||||
.from(categorias)
|
||||
.where(eq(categorias.userId, userId))
|
||||
.orderBy(categorias.type, categorias.name);
|
||||
|
||||
// Map all categories
|
||||
const allCategories = allCategoriesRows.map((cat: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: string;
|
||||
}) => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
type: cat.type as "despesa" | "receita",
|
||||
}));
|
||||
|
||||
// Process results into chart format
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "despesa" | "receita";
|
||||
dataByPeriod: Map<string, number>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Process each row
|
||||
for (const row of rows) {
|
||||
const amount = Math.abs(toNumber(row.total));
|
||||
const { categoryId, categoryName, categoryIcon, categoryType, period } =
|
||||
row;
|
||||
|
||||
if (!categoryMap.has(categoryId)) {
|
||||
categoryMap.set(categoryId, {
|
||||
id: categoryId,
|
||||
name: categoryName,
|
||||
icon: categoryIcon,
|
||||
type: categoryType as "despesa" | "receita",
|
||||
dataByPeriod: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
const categoryItem = categoryMap.get(categoryId)!;
|
||||
categoryItem.dataByPeriod.set(period, amount);
|
||||
}
|
||||
|
||||
// Build chart data
|
||||
const chartData = periods.map((period) => {
|
||||
const [year, month] = period.split("-");
|
||||
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
|
||||
const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase();
|
||||
|
||||
const dataPoint: { month: string; [key: string]: number | string } = {
|
||||
month: monthLabel,
|
||||
};
|
||||
|
||||
// Add data for each category
|
||||
for (const category of categoryMap.values()) {
|
||||
const value = category.dataByPeriod.get(period) ?? 0;
|
||||
dataPoint[category.name] = value;
|
||||
}
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
|
||||
// Generate month labels
|
||||
const months = periods.map((period) => {
|
||||
const [year, month] = period.split("-");
|
||||
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
|
||||
return format(date, "MMM", { locale: ptBR }).toUpperCase();
|
||||
});
|
||||
|
||||
// Build categories array
|
||||
const categories = Array.from(categoryMap.values()).map((cat) => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
icon: cat.icon,
|
||||
type: cat.type,
|
||||
}));
|
||||
|
||||
return {
|
||||
months,
|
||||
categories,
|
||||
chartData,
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
198
lib/relatorios/fetch-category-report.ts
Normal file
198
lib/relatorios/fetch-category-report.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Data fetching function for Category Report
|
||||
*/
|
||||
|
||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import type {
|
||||
CategoryReportData,
|
||||
CategoryReportFilters,
|
||||
CategoryReportItem,
|
||||
MonthlyData,
|
||||
} from "./types";
|
||||
import { calculatePercentageChange, generatePeriodRange } from "./utils";
|
||||
|
||||
/**
|
||||
* Fetches category report data for multiple periods
|
||||
*
|
||||
* @param userId - User ID to filter data
|
||||
* @param filters - Report filters (startPeriod, endPeriod, categoryIds)
|
||||
* @returns Complete category report data
|
||||
*/
|
||||
export async function fetchCategoryReport(
|
||||
userId: string,
|
||||
filters: CategoryReportFilters
|
||||
): Promise<CategoryReportData> {
|
||||
const { startPeriod, endPeriod, categoryIds } = filters;
|
||||
|
||||
// Generate all periods in the range
|
||||
const periods = generatePeriodRange(startPeriod, endPeriod);
|
||||
|
||||
// Build WHERE conditions
|
||||
const whereConditions = [
|
||||
eq(lancamentos.userId, userId),
|
||||
inArray(lancamentos.period, periods),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
or(
|
||||
eq(categorias.type, "despesa"),
|
||||
eq(categorias.type, "receita")
|
||||
),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
),
|
||||
];
|
||||
|
||||
// Add optional category filter
|
||||
if (categoryIds && categoryIds.length > 0) {
|
||||
whereConditions.push(inArray(categorias.id, categoryIds));
|
||||
}
|
||||
|
||||
// Query to get aggregated data by category and period
|
||||
const rows = await db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
categoryType: categorias.type,
|
||||
period: lancamentos.period,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.where(and(...whereConditions))
|
||||
.groupBy(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
categorias.type,
|
||||
lancamentos.period
|
||||
);
|
||||
|
||||
// Process results into CategoryReportData structure
|
||||
const categoryMap = new Map<string, CategoryReportItem>();
|
||||
const periodTotalsMap = new Map<string, number>();
|
||||
|
||||
// Initialize period totals
|
||||
for (const period of periods) {
|
||||
periodTotalsMap.set(period, 0);
|
||||
}
|
||||
|
||||
// Process each row
|
||||
for (const row of rows) {
|
||||
const amount = Math.abs(toNumber(row.total));
|
||||
const { categoryId, categoryName, categoryIcon, categoryType, period } = row;
|
||||
|
||||
// Get or create category item
|
||||
if (!categoryMap.has(categoryId)) {
|
||||
categoryMap.set(categoryId, {
|
||||
categoryId,
|
||||
name: categoryName,
|
||||
icon: categoryIcon,
|
||||
type: categoryType as "despesa" | "receita",
|
||||
monthlyData: new Map<string, MonthlyData>(),
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const categoryItem = categoryMap.get(categoryId)!;
|
||||
|
||||
// Add monthly data (will calculate percentage later)
|
||||
categoryItem.monthlyData.set(period, {
|
||||
period,
|
||||
amount,
|
||||
previousAmount: 0, // Will be filled in next step
|
||||
percentageChange: null, // Will be calculated in next step
|
||||
});
|
||||
|
||||
// Update category total
|
||||
categoryItem.total += amount;
|
||||
|
||||
// Update period total
|
||||
const currentPeriodTotal = periodTotalsMap.get(period) ?? 0;
|
||||
periodTotalsMap.set(period, currentPeriodTotal + amount);
|
||||
}
|
||||
|
||||
// Calculate percentage changes (compare with previous period)
|
||||
for (const categoryItem of categoryMap.values()) {
|
||||
const sortedPeriods = Array.from(categoryItem.monthlyData.keys()).sort();
|
||||
|
||||
for (let i = 0; i < sortedPeriods.length; i++) {
|
||||
const period = sortedPeriods[i];
|
||||
const monthlyData = categoryItem.monthlyData.get(period)!;
|
||||
|
||||
if (i > 0) {
|
||||
// Get previous period data
|
||||
const prevPeriod = sortedPeriods[i - 1];
|
||||
const prevMonthlyData = categoryItem.monthlyData.get(prevPeriod);
|
||||
const previousAmount = prevMonthlyData?.amount ?? 0;
|
||||
|
||||
// Update with previous amount and calculate percentage
|
||||
monthlyData.previousAmount = previousAmount;
|
||||
monthlyData.percentageChange = calculatePercentageChange(
|
||||
monthlyData.amount,
|
||||
previousAmount
|
||||
);
|
||||
} else {
|
||||
// First period - no comparison
|
||||
monthlyData.previousAmount = 0;
|
||||
monthlyData.percentageChange = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill in missing periods with zero values
|
||||
for (const categoryItem of categoryMap.values()) {
|
||||
for (const period of periods) {
|
||||
if (!categoryItem.monthlyData.has(period)) {
|
||||
// Find previous period data for percentage calculation
|
||||
const periodIndex = periods.indexOf(period);
|
||||
let previousAmount = 0;
|
||||
|
||||
if (periodIndex > 0) {
|
||||
const prevPeriod = periods[periodIndex - 1];
|
||||
const prevData = categoryItem.monthlyData.get(prevPeriod);
|
||||
previousAmount = prevData?.amount ?? 0;
|
||||
}
|
||||
|
||||
categoryItem.monthlyData.set(period, {
|
||||
period,
|
||||
amount: 0,
|
||||
previousAmount,
|
||||
percentageChange: calculatePercentageChange(0, previousAmount),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort
|
||||
const categories = Array.from(categoryMap.values());
|
||||
|
||||
// Sort: despesas first (by total desc), then receitas (by total desc)
|
||||
categories.sort((a, b) => {
|
||||
// First by type: despesa comes before receita
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "despesa" ? -1 : 1;
|
||||
}
|
||||
// Then by total (descending)
|
||||
return b.total - a.total;
|
||||
});
|
||||
|
||||
// Calculate grand total
|
||||
let grandTotal = 0;
|
||||
for (const categoryItem of categories) {
|
||||
grandTotal += categoryItem.total;
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
periods,
|
||||
totals: periodTotalsMap,
|
||||
grandTotal,
|
||||
};
|
||||
}
|
||||
52
lib/relatorios/types.ts
Normal file
52
lib/relatorios/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Types for Category Report feature
|
||||
*/
|
||||
|
||||
/**
|
||||
* Monthly data for a specific category in a specific period
|
||||
*/
|
||||
export type MonthlyData = {
|
||||
period: string; // Format: "YYYY-MM"
|
||||
amount: number; // Total amount for this category in this period
|
||||
previousAmount: number; // Amount from previous period (for comparison)
|
||||
percentageChange: number | null; // Percentage change from previous period
|
||||
};
|
||||
|
||||
/**
|
||||
* Single category item in the report
|
||||
*/
|
||||
export type CategoryReportItem = {
|
||||
categoryId: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "despesa" | "receita";
|
||||
monthlyData: Map<string, MonthlyData>; // Key: period (YYYY-MM)
|
||||
total: number; // Total across all periods
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete category report data structure
|
||||
*/
|
||||
export type CategoryReportData = {
|
||||
categories: CategoryReportItem[]; // All categories with their data
|
||||
periods: string[]; // All periods in the report (sorted chronologically)
|
||||
totals: Map<string, number>; // Total per period across all categories
|
||||
grandTotal: number; // Total of all categories and all periods
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters for category report query
|
||||
*/
|
||||
export type CategoryReportFilters = {
|
||||
startPeriod: string; // Format: "YYYY-MM"
|
||||
endPeriod: string; // Format: "YYYY-MM"
|
||||
categoryIds?: string[]; // Optional: filter by specific categories
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation result for date range
|
||||
*/
|
||||
export type DateRangeValidation = {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
};
|
||||
131
lib/relatorios/utils.ts
Normal file
131
lib/relatorios/utils.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Utility functions for Category Report feature
|
||||
*/
|
||||
|
||||
import { buildPeriodRange, MONTH_NAMES, parsePeriod } from "@/lib/utils/period";
|
||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
||||
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
||||
import type { DateRangeValidation } from "./types";
|
||||
|
||||
// Re-export for convenience
|
||||
export { calculatePercentageChange };
|
||||
|
||||
/**
|
||||
* Formats period string from "YYYY-MM" to "MMM/YYYY" format
|
||||
* Example: "2025-01" -> "Jan/2025"
|
||||
*
|
||||
* @param period - Period in YYYY-MM format
|
||||
* @returns Formatted period string
|
||||
*/
|
||||
export function formatPeriodLabel(period: string): string {
|
||||
try {
|
||||
const { year, month } = parsePeriod(period);
|
||||
const monthName = MONTH_NAMES[month - 1];
|
||||
|
||||
// Capitalize first letter and take first 3 chars
|
||||
const shortMonth =
|
||||
monthName.charAt(0).toUpperCase() + monthName.slice(1, 3);
|
||||
|
||||
return `${shortMonth}/${year}`;
|
||||
} catch {
|
||||
return period; // Return original if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an array of periods between start and end (inclusive)
|
||||
* Alias for buildPeriodRange from period utils
|
||||
*
|
||||
* @param startPeriod - Start period in YYYY-MM format
|
||||
* @param endPeriod - End period in YYYY-MM format
|
||||
* @returns Array of period strings in chronological order
|
||||
*/
|
||||
export function generatePeriodRange(
|
||||
startPeriod: string,
|
||||
endPeriod: string
|
||||
): string[] {
|
||||
return buildPeriodRange(startPeriod, endPeriod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that end date is >= start date and period is within limits
|
||||
* Maximum allowed period: 24 months
|
||||
*
|
||||
* @param startPeriod - Start period in YYYY-MM format
|
||||
* @param endPeriod - End period in YYYY-MM format
|
||||
* @returns Validation result with error message if invalid
|
||||
*/
|
||||
export function validateDateRange(
|
||||
startPeriod: string,
|
||||
endPeriod: string
|
||||
): DateRangeValidation {
|
||||
try {
|
||||
// Parse periods to validate format
|
||||
const start = parsePeriod(startPeriod);
|
||||
const end = parsePeriod(endPeriod);
|
||||
|
||||
// Check if end is before start
|
||||
if (
|
||||
end.year < start.year ||
|
||||
(end.year === start.year && end.month < start.month)
|
||||
) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "A data final deve ser maior ou igual à data inicial",
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate number of months between periods
|
||||
const monthsDiff =
|
||||
(end.year - start.year) * 12 + (end.month - start.month) + 1;
|
||||
|
||||
// Check if period exceeds 24 months
|
||||
if (monthsDiff > 24) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "O período máximo permitido é de 24 meses",
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Formato de período inválido. Use YYYY-MM",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number as Brazilian currency (R$ X.XXX,XX)
|
||||
* Uses the shared currencyFormatter from formatting-helpers
|
||||
*
|
||||
* @param value - Numeric value to format
|
||||
* @returns Formatted currency string
|
||||
*/
|
||||
export function formatCurrency(value: number): string {
|
||||
return currencyFormatter.format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats percentage change for display
|
||||
* Format: "±X%" or "±X.X%" (one decimal if < 10%)
|
||||
*
|
||||
* @param change - Percentage change value
|
||||
* @returns Formatted percentage string
|
||||
*/
|
||||
export function formatPercentageChange(change: number | null): string {
|
||||
if (change === null) return "-";
|
||||
|
||||
const absChange = Math.abs(change);
|
||||
const sign = change >= 0 ? "+" : "-";
|
||||
|
||||
// Use one decimal place if less than 10%
|
||||
const formatted =
|
||||
absChange < 10 ? absChange.toFixed(1) : Math.round(absChange).toString();
|
||||
|
||||
return `${sign}${formatted}%`;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type PeriodPreferences = {
|
||||
monthsBefore: number;
|
||||
monthsAfter: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches period preferences for a user
|
||||
* @param userId - User ID
|
||||
* @returns Period preferences with defaults if not found
|
||||
*/
|
||||
export async function fetchUserPeriodPreferences(
|
||||
userId: string
|
||||
): Promise<PeriodPreferences> {
|
||||
const result = await db
|
||||
.select({
|
||||
periodMonthsBefore: schema.userPreferences.periodMonthsBefore,
|
||||
periodMonthsAfter: schema.userPreferences.periodMonthsAfter,
|
||||
})
|
||||
.from(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const preferences = result[0];
|
||||
|
||||
return {
|
||||
monthsBefore: preferences?.periodMonthsBefore ?? 3,
|
||||
monthsAfter: preferences?.periodMonthsAfter ?? 3,
|
||||
};
|
||||
}
|
||||
@@ -355,48 +355,3 @@ export function derivePeriodFromDate(value?: string | null): string {
|
||||
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SELECT OPTIONS GENERATION
|
||||
// ============================================================================
|
||||
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates month options for a select dropdown, centered around current month
|
||||
* @param currentValue - Current period value to ensure it's included in options
|
||||
* @param monthsBefore - Number of months before current month (default: 3)
|
||||
* @param monthsAfter - Number of months after current month (default: same as monthsBefore)
|
||||
* @returns Array of select options with formatted labels
|
||||
* @example
|
||||
* createMonthOptions() // -3 to +3
|
||||
* createMonthOptions(undefined, 3) // -3 to +3
|
||||
* createMonthOptions(undefined, 3, 6) // -3 to +6
|
||||
*/
|
||||
export function createMonthOptions(
|
||||
currentValue?: string,
|
||||
monthsBefore: number = 3,
|
||||
monthsAfter?: number
|
||||
): SelectOption[] {
|
||||
const now = new Date();
|
||||
const options: SelectOption[] = [];
|
||||
const after = monthsAfter ?? monthsBefore; // If not specified, use same as before
|
||||
|
||||
for (let offset = -monthsBefore; offset <= after; offset += 1) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
||||
const value = formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||
options.push({ value, label: displayPeriod(value) });
|
||||
}
|
||||
|
||||
// Include current value if not already in options
|
||||
if (currentValue && !options.some((option) => option.value === currentValue)) {
|
||||
options.push({
|
||||
value: currentValue,
|
||||
label: displayPeriod(currentValue),
|
||||
});
|
||||
}
|
||||
|
||||
return options.sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user