forked from git.gladyson/openmonetis
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
221 lines
6.3 KiB
TypeScript
221 lines
6.3 KiB
TypeScript
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
|
import { cartoes, lancamentos } from "@/db/schema";
|
|
import { db } from "@/lib/db";
|
|
import {
|
|
buildOptionSets,
|
|
buildSluggedFilters,
|
|
fetchLancamentoFilterSources,
|
|
mapLancamentosData,
|
|
} from "@/lib/lancamentos/page-helpers";
|
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
|
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
|
|
|
|
import type {
|
|
CalendarData,
|
|
CalendarEvent,
|
|
} from "@/components/calendario/types";
|
|
|
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
|
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
|
|
|
|
const toDateKey = (date: Date) => date.toISOString().slice(0, 10);
|
|
|
|
const parsePeriod = (period: string) => {
|
|
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, monthIndex: month - 1 };
|
|
};
|
|
|
|
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
|
|
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
|
if (day < 1) return 1;
|
|
if (day > lastDay) return lastDay;
|
|
return day;
|
|
};
|
|
|
|
const isWithinRange = (value: string | null, start: string, end: string) => {
|
|
if (!value) return false;
|
|
return value >= start && value <= end;
|
|
};
|
|
|
|
type FetchCalendarDataParams = {
|
|
userId: string;
|
|
period: string;
|
|
};
|
|
|
|
export const fetchCalendarData = async ({
|
|
userId,
|
|
period,
|
|
}: FetchCalendarDataParams): Promise<CalendarData> => {
|
|
const { year, monthIndex } = parsePeriod(period);
|
|
const rangeStart = new Date(Date.UTC(year, monthIndex, 1));
|
|
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0));
|
|
const rangeStartKey = toDateKey(rangeStart);
|
|
const rangeEndKey = toDateKey(rangeEnd);
|
|
|
|
const [lancamentoRows, cardRows, filterSources] =
|
|
await Promise.all([
|
|
db.query.lancamentos.findMany({
|
|
where: and(
|
|
eq(lancamentos.userId, userId),
|
|
ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA),
|
|
or(
|
|
// Lançamentos cuja data de compra esteja no período do calendário
|
|
and(
|
|
gte(lancamentos.purchaseDate, rangeStart),
|
|
lte(lancamentos.purchaseDate, rangeEnd)
|
|
),
|
|
// Boletos cuja data de vencimento esteja no período do calendário
|
|
and(
|
|
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
|
gte(lancamentos.dueDate, rangeStart),
|
|
lte(lancamentos.dueDate, rangeEnd)
|
|
),
|
|
// Lançamentos de cartão do período (para calcular totais de vencimento)
|
|
and(
|
|
eq(lancamentos.period, period),
|
|
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO)
|
|
)
|
|
)
|
|
),
|
|
with: {
|
|
pagador: true,
|
|
conta: true,
|
|
cartao: true,
|
|
categoria: true,
|
|
},
|
|
}),
|
|
db.query.cartoes.findMany({
|
|
where: eq(cartoes.userId, userId),
|
|
}),
|
|
fetchLancamentoFilterSources(userId),
|
|
]);
|
|
|
|
const lancamentosData = mapLancamentosData(lancamentoRows);
|
|
const events: CalendarEvent[] = [];
|
|
|
|
const cardTotals = new Map<string, number>();
|
|
for (const item of lancamentosData) {
|
|
if (
|
|
!item.cartaoId ||
|
|
item.period !== period ||
|
|
item.pagadorRole !== PAGADOR_ROLE_ADMIN
|
|
) {
|
|
continue;
|
|
}
|
|
const amount = Math.abs(item.amount ?? 0);
|
|
cardTotals.set(
|
|
item.cartaoId,
|
|
(cardTotals.get(item.cartaoId) ?? 0) + amount
|
|
);
|
|
}
|
|
|
|
for (const item of lancamentosData) {
|
|
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
|
|
const isAdminPagador = item.pagadorRole === PAGADOR_ROLE_ADMIN;
|
|
|
|
// Para boletos, exibir apenas na data de vencimento e apenas se for pagador admin
|
|
if (isBoleto) {
|
|
if (
|
|
isAdminPagador &&
|
|
item.dueDate &&
|
|
isWithinRange(item.dueDate, rangeStartKey, rangeEndKey)
|
|
) {
|
|
events.push({
|
|
id: `${item.id}:boleto`,
|
|
type: "boleto",
|
|
date: item.dueDate,
|
|
lancamento: item,
|
|
});
|
|
}
|
|
} else {
|
|
// Para outros tipos de lançamento, exibir na data de compra
|
|
if (!isAdminPagador) {
|
|
continue;
|
|
}
|
|
const purchaseDateKey = item.purchaseDate.slice(0, 10);
|
|
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
|
|
events.push({
|
|
id: item.id,
|
|
type: "lancamento",
|
|
date: purchaseDateKey,
|
|
lancamento: item,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exibir vencimentos apenas de cartões com lançamentos do pagador admin
|
|
for (const card of cardRows) {
|
|
if (!cardTotals.has(card.id)) {
|
|
continue;
|
|
}
|
|
|
|
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
|
|
if (Number.isNaN(dueDayNumber)) {
|
|
continue;
|
|
}
|
|
|
|
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
|
|
const dueDateKey = toDateKey(
|
|
new Date(Date.UTC(year, monthIndex, normalizedDay))
|
|
);
|
|
|
|
events.push({
|
|
id: `${card.id}:cartao`,
|
|
type: "cartao",
|
|
date: dueDateKey,
|
|
card: {
|
|
id: card.id,
|
|
name: card.name,
|
|
dueDay: card.dueDay,
|
|
closingDay: card.closingDay,
|
|
brand: card.brand ?? null,
|
|
status: card.status,
|
|
logo: card.logo ?? null,
|
|
totalDue: cardTotals.get(card.id) ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
const typePriority: Record<CalendarEvent["type"], number> = {
|
|
lancamento: 0,
|
|
boleto: 1,
|
|
cartao: 2,
|
|
};
|
|
|
|
events.sort((a, b) => {
|
|
if (a.date === b.date) {
|
|
return typePriority[a.type] - typePriority[b.type];
|
|
}
|
|
return a.date.localeCompare(b.date);
|
|
});
|
|
|
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
|
const optionSets = buildOptionSets({
|
|
...sluggedFilters,
|
|
pagadorRows: filterSources.pagadorRows,
|
|
});
|
|
|
|
const estabelecimentos = await getRecentEstablishmentsAction();
|
|
|
|
return {
|
|
events,
|
|
formOptions: {
|
|
pagadorOptions: optionSets.pagadorOptions,
|
|
splitPagadorOptions: optionSets.splitPagadorOptions,
|
|
defaultPagadorId: optionSets.defaultPagadorId,
|
|
contaOptions: optionSets.contaOptions,
|
|
cartaoOptions: optionSets.cartaoOptions,
|
|
categoriaOptions: optionSets.categoriaOptions,
|
|
estabelecimentos,
|
|
},
|
|
};
|
|
};
|