From 4237062bde6a3a874de3cf614a2748bd92948a96 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sun, 4 Jan 2026 03:03:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implementar=20relat=C3=B3rios=20de=20ca?= =?UTF-8?q?tegorias=20e=20substituir=20sele=C3=A7=C3=A3o=20de=20per=C3=ADo?= =?UTF-8?q?do=20por=20picker=20visual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 10 +- app/(dashboard)/ajustes/actions.ts | 14 - app/(dashboard)/ajustes/page.tsx | 4 - app/(dashboard)/calendario/data.ts | 5 +- app/(dashboard)/calendario/page.tsx | 4 +- .../cartoes/[cartaoId]/fatura/page.tsx | 8 +- .../categorias/[categoryId]/page.tsx | 18 +- .../contas/[contaId]/extrato/page.tsx | 8 +- app/(dashboard)/dashboard/page.tsx | 9 +- app/(dashboard)/insights/page.tsx | 4 +- app/(dashboard)/lancamentos/page.tsx | 11 +- app/(dashboard)/orcamentos/page.tsx | 12 +- .../pagadores/[pagadorId]/page.tsx | 23 +- .../relatorios/categorias/layout.tsx | 23 + .../relatorios/categorias/loading.tsx | 9 + .../relatorios/categorias/page.tsx | 118 +++++ components/ajustes/preferences-form.tsx | 60 --- components/calendario/monthly-calendar.tsx | 1 - components/calendario/types.ts | 2 - .../anticipate-installments-dialog.tsx | 33 +- .../basic-fields-section.tsx | 29 +- .../lancamento-dialog-types.ts | 3 - .../lancamento-dialog/lancamento-dialog.tsx | 12 - .../lancamentos/dialogs/mass-add-dialog.tsx | 33 +- .../lancamentos/page/lancamentos-page.tsx | 8 - ...{month-picker.tsx => month-navigation.tsx} | 2 +- components/orcamentos/budget-dialog.tsx | 33 +- components/orcamentos/budgets-page.tsx | 5 - components/period-picker.tsx | 91 ++++ components/relatorios/category-cell.tsx | 46 ++ .../relatorios/category-report-cards.tsx | 63 +++ .../relatorios/category-report-chart.tsx | 213 +++++++++ .../relatorios/category-report-export.tsx | 348 ++++++++++++++ .../relatorios/category-report-filters.tsx | 309 +++++++++++++ .../relatorios/category-report-page.tsx | 194 ++++++++ .../relatorios/category-report-table.tsx | 108 +++++ components/relatorios/index.ts | 8 + components/relatorios/types.ts | 34 ++ components/sidebar/nav-link.tsx | 20 +- .../skeletons/category-report-skeleton.tsx | 193 ++++++++ components/skeletons/index.ts | 1 + components/type-badge.tsx | 4 +- components/ui/button.tsx | 26 +- components/ui/monthpicker.tsx | 219 +++++++++ components/ui/sidebar.tsx | 2 +- db/schema.ts | 2 - lib/relatorios/fetch-category-chart-data.ts | 180 ++++++++ lib/relatorios/fetch-category-report.ts | 198 ++++++++ lib/relatorios/types.ts | 52 +++ lib/relatorios/utils.ts | 131 ++++++ lib/user-preferences/period.ts | 32 -- lib/utils/period/index.ts | 45 -- package.json | 15 +- pnpm-lock.yaml | 424 ++++++++++++++---- 54 files changed, 2987 insertions(+), 472 deletions(-) create mode 100644 app/(dashboard)/relatorios/categorias/layout.tsx create mode 100644 app/(dashboard)/relatorios/categorias/loading.tsx create mode 100644 app/(dashboard)/relatorios/categorias/page.tsx rename components/month-picker/{month-picker.tsx => month-navigation.tsx} (98%) create mode 100644 components/period-picker.tsx create mode 100644 components/relatorios/category-cell.tsx create mode 100644 components/relatorios/category-report-cards.tsx create mode 100644 components/relatorios/category-report-chart.tsx create mode 100644 components/relatorios/category-report-export.tsx create mode 100644 components/relatorios/category-report-filters.tsx create mode 100644 components/relatorios/category-report-page.tsx create mode 100644 components/relatorios/category-report-table.tsx create mode 100644 components/relatorios/index.ts create mode 100644 components/relatorios/types.ts create mode 100644 components/skeletons/category-report-skeleton.tsx create mode 100644 components/ui/monthpicker.tsx create mode 100644 lib/relatorios/fetch-category-chart-data.ts create mode 100644 lib/relatorios/fetch-category-report.ts create mode 100644 lib/relatorios/types.ts create mode 100644 lib/relatorios/utils.ts delete mode 100644 lib/user-preferences/period.ts diff --git a/README.md b/README.md index ab09e06..6500ed8 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ O projeto é open source, seus dados ficam no seu controle (pode rodar localment - **UI Library:** React 19 - **Styling:** Tailwind CSS v4 - **Components:** shadcn/ui (Radix UI) -- **Icons:** Lucide React, Remixicon +- **Icons:** Remixicon - **Animations:** Framer Motion ### Backend @@ -649,10 +649,10 @@ pnpm env:setup ### Escolhendo entre Local e Remoto -| Modo | Quando usar | Como configurar | -| ---------- | ------------------------------------- | --------------------------------------------- | -| **Local** | Desenvolvimento, testes, prototipagem | `DATABASE_URL` com host "db" ou "localhost" | -| **Remoto** | Produção, deploy, banco gerenciado | `DATABASE_URL` com URL completa do provider | +| Modo | Quando usar | Como configurar | +| ---------- | ------------------------------------- | ------------------------------------------- | +| **Local** | Desenvolvimento, testes, prototipagem | `DATABASE_URL` com host "db" ou "localhost" | +| **Remoto** | Produção, deploy, banco gerenciado | `DATABASE_URL` com URL completa do provider | ### Drizzle ORM diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts index 6685938..b741c8f 100644 --- a/app/(dashboard)/ajustes/actions.ts +++ b/app/(dashboard)/ajustes/actions.ts @@ -50,16 +50,6 @@ const deleteAccountSchema = z.object({ const updatePreferencesSchema = z.object({ disableMagnetlines: z.boolean(), - periodMonthsBefore: z - .number() - .int("Deve ser um número inteiro") - .min(1, "Mínimo de 1 mês") - .max(24, "Máximo de 24 meses"), - periodMonthsAfter: z - .number() - .int("Deve ser um número inteiro") - .min(1, "Mínimo de 1 mês") - .max(24, "Máximo de 24 meses"), }); // Actions @@ -374,8 +364,6 @@ export async function updatePreferencesAction( .update(schema.userPreferences) .set({ disableMagnetlines: validated.disableMagnetlines, - periodMonthsBefore: validated.periodMonthsBefore, - periodMonthsAfter: validated.periodMonthsAfter, updatedAt: new Date(), }) .where(eq(schema.userPreferences.userId, session.user.id)); @@ -384,8 +372,6 @@ export async function updatePreferencesAction( await db.insert(schema.userPreferences).values({ userId: session.user.id, disableMagnetlines: validated.disableMagnetlines, - periodMonthsBefore: validated.periodMonthsBefore, - periodMonthsAfter: validated.periodMonthsAfter, }); } diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index 5ed1925..a72b52f 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -32,8 +32,6 @@ export default async function Page() { const userPreferencesResult = await db .select({ disableMagnetlines: schema.userPreferences.disableMagnetlines, - periodMonthsBefore: schema.userPreferences.periodMonthsBefore, - periodMonthsAfter: schema.userPreferences.periodMonthsAfter, }) .from(schema.userPreferences) .where(eq(schema.userPreferences.userId, session.user.id)) @@ -71,8 +69,6 @@ export default async function Page() { disableMagnetlines={ userPreferences?.disableMagnetlines ?? false } - periodMonthsBefore={userPreferences?.periodMonthsBefore ?? 3} - periodMonthsAfter={userPreferences?.periodMonthsAfter ?? 3} /> diff --git a/app/(dashboard)/calendario/data.ts b/app/(dashboard)/calendario/data.ts index 88b532f..fb812b8 100644 --- a/app/(dashboard)/calendario/data.ts +++ b/app/(dashboard)/calendario/data.ts @@ -7,7 +7,6 @@ import { fetchLancamentoFilterSources, mapLancamentosData, } from "@/lib/lancamentos/page-helpers"; -import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { and, eq, gte, lte, ne, or } from "drizzle-orm"; @@ -60,7 +59,7 @@ export const fetchCalendarData = async ({ const rangeStartKey = toDateKey(rangeStart); const rangeEndKey = toDateKey(rangeEnd); - const [lancamentoRows, cardRows, filterSources, periodPreferences] = + const [lancamentoRows, cardRows, filterSources] = await Promise.all([ db.query.lancamentos.findMany({ where: and( @@ -96,7 +95,6 @@ export const fetchCalendarData = async ({ where: eq(cartoes.userId, userId), }), fetchLancamentoFilterSources(userId), - fetchUserPeriodPreferences(userId), ]); const lancamentosData = mapLancamentosData(lancamentoRows); @@ -217,7 +215,6 @@ export const fetchCalendarData = async ({ cartaoOptions: optionSets.cartaoOptions, categoriaOptions: optionSets.categoriaOptions, estabelecimentos, - periodPreferences, }, }; }; diff --git a/app/(dashboard)/calendario/page.tsx b/app/(dashboard)/calendario/page.tsx index fa24c4b..35c914b 100644 --- a/app/(dashboard)/calendario/page.tsx +++ b/app/(dashboard)/calendario/page.tsx @@ -1,4 +1,4 @@ -import MonthPicker from "@/components/month-picker/month-picker"; +import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUserId } from "@/lib/auth/server"; import { getSingleParam, @@ -36,7 +36,7 @@ export default async function Page({ searchParams }: PageProps) { return (
- + - +
- +
diff --git a/app/(dashboard)/contas/[contaId]/extrato/page.tsx b/app/(dashboard)/contas/[contaId]/extrato/page.tsx index 5bde654..1a64fb8 100644 --- a/app/(dashboard)/contas/[contaId]/extrato/page.tsx +++ b/app/(dashboard)/contas/[contaId]/extrato/page.tsx @@ -3,7 +3,7 @@ import { AccountDialog } from "@/components/contas/account-dialog"; import { AccountStatementCard } from "@/components/contas/account-statement-card"; import type { Account } from "@/components/contas/types"; import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; -import MonthPicker from "@/components/month-picker/month-picker"; +import MonthNavigation from "@/components/month-picker/month-navigation"; import { Button } from "@/components/ui/button"; import { lancamentos } from "@/db/schema"; import { db } from "@/lib/db"; @@ -20,7 +20,6 @@ import { type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; import { loadLogoOptions } from "@/lib/logo/options"; -import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { parsePeriodParam } from "@/lib/utils/period"; import { RiPencilLine } from "@remixicon/react"; import { and, desc, eq } from "drizzle-orm"; @@ -62,13 +61,11 @@ export default async function Page({ params, searchParams }: PageProps) { logoOptions, accountSummary, estabelecimentos, - periodPreferences, ] = await Promise.all([ fetchLancamentoFilterSources(userId), loadLogoOptions(), fetchAccountSummary(userId, contaId, selectedPeriod), getRecentEstablishmentsAction(), - fetchUserPeriodPreferences(userId), ]); const sluggedFilters = buildSluggedFilters(filterSources); const slugMaps = buildSlugMaps(sluggedFilters); @@ -130,7 +127,7 @@ export default async function Page({ params, searchParams }: PageProps) { return (
- + diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 5d36a35..9388929 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,7 +1,7 @@ import { DashboardGrid } from "@/components/dashboard/dashboard-grid"; import { DashboardWelcome } from "@/components/dashboard/dashboard-welcome"; import { SectionCards } from "@/components/dashboard/section-cards"; -import MonthPicker from "@/components/month-picker/month-picker"; +import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUser } from "@/lib/auth/server"; import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; import { parsePeriodParam } from "@/lib/utils/period"; @@ -44,8 +44,11 @@ export default async function Page({ searchParams }: PageProps) { return (
- - + +
diff --git a/app/(dashboard)/insights/page.tsx b/app/(dashboard)/insights/page.tsx index c8ad780..ddee7d5 100644 --- a/app/(dashboard)/insights/page.tsx +++ b/app/(dashboard)/insights/page.tsx @@ -1,5 +1,5 @@ import { InsightsPage } from "@/components/insights/insights-page"; -import MonthPicker from "@/components/month-picker/month-picker"; +import MonthNavigation from "@/components/month-picker/month-navigation"; import { parsePeriodParam } from "@/lib/utils/period"; type PageSearchParams = Promise>; @@ -24,7 +24,7 @@ export default async function Page({ searchParams }: PageProps) { return (
- +
); diff --git a/app/(dashboard)/lancamentos/page.tsx b/app/(dashboard)/lancamentos/page.tsx index 00a6db9..509933a 100644 --- a/app/(dashboard)/lancamentos/page.tsx +++ b/app/(dashboard)/lancamentos/page.tsx @@ -1,4 +1,4 @@ -import MonthPicker from "@/components/month-picker/month-picker"; +import MonthNavigation from "@/components/month-picker/month-navigation"; import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page"; import { getUserId } from "@/lib/auth/server"; import { @@ -12,7 +12,6 @@ import { mapLancamentosData, type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; -import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { parsePeriodParam } from "@/lib/utils/period"; import { fetchLancamentos } from "./data"; import { getRecentEstablishmentsAction } from "./actions"; @@ -32,10 +31,7 @@ export default async function Page({ searchParams }: PageProps) { const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - const [filterSources, periodPreferences] = await Promise.all([ - fetchLancamentoFilterSources(userId), - fetchUserPeriodPreferences(userId), - ]); + const filterSources = await fetchLancamentoFilterSources(userId); const sluggedFilters = buildSluggedFilters(filterSources); const slugMaps = buildSlugMaps(sluggedFilters); @@ -69,7 +65,7 @@ export default async function Page({ searchParams }: PageProps) { return (
- +
); diff --git a/app/(dashboard)/orcamentos/page.tsx b/app/(dashboard)/orcamentos/page.tsx index 7466c1c..fa846ac 100644 --- a/app/(dashboard)/orcamentos/page.tsx +++ b/app/(dashboard)/orcamentos/page.tsx @@ -1,7 +1,6 @@ -import MonthPicker from "@/components/month-picker/month-picker"; +import MonthNavigation from "@/components/month-picker/month-navigation"; import { BudgetsPage } from "@/components/orcamentos/budgets-page"; import { getUserId } from "@/lib/auth/server"; -import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; import { parsePeriodParam } from "@/lib/utils/period"; import { fetchBudgetsForUser } from "./data"; @@ -36,22 +35,17 @@ export default async function Page({ searchParams }: PageProps) { const periodLabel = `${capitalize(rawMonthName)} ${year}`; - const [{ budgets, categoriesOptions }, periodPreferences] = await Promise.all([ - fetchBudgetsForUser(userId, selectedPeriod), - fetchUserPeriodPreferences(userId), - ]); + const { budgets, categoriesOptions } = await fetchBudgetsForUser(userId, selectedPeriod); return (
- +
); } - diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx index 7e3b03a..42a23f7 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/page.tsx +++ b/app/(dashboard)/pagadores/[pagadorId]/page.tsx @@ -12,7 +12,7 @@ import type { LancamentoItem, SelectOption, } from "@/components/lancamentos/types"; -import MonthPicker from "@/components/month-picker/month-picker"; +import MonthNavigation from "@/components/month-picker/month-navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { pagadores } from "@/db/schema"; import { getUserId } from "@/lib/auth/server"; @@ -30,9 +30,8 @@ import { type SlugMaps, type SluggedFilters, } from "@/lib/lancamentos/page-helpers"; -import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period"; -import { parsePeriodParam } from "@/lib/utils/period"; import { getPagadorAccess } from "@/lib/pagadores/access"; +import { parsePeriodParam } from "@/lib/utils/period"; import { fetchPagadorBoletoStats, fetchPagadorCardUsage, @@ -137,7 +136,6 @@ export default async function Page({ params, searchParams }: PageProps) { boletoStats, shareRows, estabelecimentos, - periodPreferences, ] = await Promise.all([ fetchPagadorLancamentos(filters), fetchPagadorMonthlyBreakdown({ @@ -162,7 +160,6 @@ export default async function Page({ params, searchParams }: PageProps) { }), sharesPromise, getRecentEstablishmentsAction(), - fetchUserPeriodPreferences(dataOwnerId), ]); const mappedLancamentos = mapLancamentosData(lancamentoRows); @@ -183,7 +180,12 @@ export default async function Page({ params, searchParams }: PageProps) { } else { effectiveSluggedFilters = { pagadorFiltersRaw: [ - { id: pagador.id, label: pagador.name, slug: pagador.id, role: pagador.role }, + { + id: pagador.id, + label: pagador.name, + slug: pagador.id, + role: pagador.role, + }, ], categoriaFiltersRaw: [], contaFiltersRaw: [], @@ -240,7 +242,7 @@ export default async function Page({ params, searchParams }: PageProps) { return (
- + @@ -296,7 +298,6 @@ export default async function Page({ params, searchParams }: PageProps) { contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions} selectedPeriod={selectedPeriod} estabelecimentos={estabelecimentos} - periodPreferences={periodPreferences} allowCreate={canEdit} /> @@ -306,8 +307,10 @@ export default async function Page({ params, searchParams }: PageProps) { ); } -const normalizeOptionLabel = (value: string | null | undefined, fallback: string) => - value?.trim().length ? value.trim() : fallback; +const normalizeOptionLabel = ( + value: string | null | undefined, + fallback: string +) => (value?.trim().length ? value.trim() : fallback); function buildReadOnlyOptionSets( items: LancamentoItem[], diff --git a/app/(dashboard)/relatorios/categorias/layout.tsx b/app/(dashboard)/relatorios/categorias/layout.tsx new file mode 100644 index 0000000..959f5e3 --- /dev/null +++ b/app/(dashboard)/relatorios/categorias/layout.tsx @@ -0,0 +1,23 @@ +import PageDescription from "@/components/page-description"; +import { RiFileChartLine } from "@remixicon/react"; + +export const metadata = { + title: "Relatórios | Opensheets", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Relatórios de Categorias" + subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/relatorios/categorias/loading.tsx b/app/(dashboard)/relatorios/categorias/loading.tsx new file mode 100644 index 0000000..e832b3f --- /dev/null +++ b/app/(dashboard)/relatorios/categorias/loading.tsx @@ -0,0 +1,9 @@ +import { CategoryReportSkeleton } from "@/components/skeletons/category-report-skeleton"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/relatorios/categorias/page.tsx b/app/(dashboard)/relatorios/categorias/page.tsx new file mode 100644 index 0000000..4cbe29d --- /dev/null +++ b/app/(dashboard)/relatorios/categorias/page.tsx @@ -0,0 +1,118 @@ +import { CategoryReportPage } from "@/components/relatorios/category-report-page"; +import { getUserId } from "@/lib/auth/server"; +import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period"; +import { validateDateRange } from "@/lib/relatorios/utils"; +import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report"; +import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data"; +import type { CategoryReportFilters } from "@/lib/relatorios/types"; +import type { + CategoryOption, + FilterState, +} from "@/components/relatorios/types"; +import { db } from "@/lib/db"; +import { categorias, type Categoria } from "@/db/schema"; +import { eq, asc } from "drizzle-orm"; +import { redirect } from "next/navigation"; + +type PageSearchParams = Promise>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | undefined, + key: string +): string | null => { + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? value[0] ?? null : value; +}; + +export default async function Page({ searchParams }: PageProps) { + // Get authenticated user + const userId = await getUserId(); + + // Resolve search params + const resolvedSearchParams = searchParams ? await searchParams : undefined; + + // Extract query params + const inicioParam = getSingleParam(resolvedSearchParams, "inicio"); + const fimParam = getSingleParam(resolvedSearchParams, "fim"); + const categoriasParam = getSingleParam(resolvedSearchParams, "categorias"); + + // Calculate default period (last 6 months) + const currentPeriod = getCurrentPeriod(); + const defaultStartPeriod = addMonthsToPeriod(currentPeriod, -5); // 6 months including current + + // Use params or defaults + const startPeriod = inicioParam ?? defaultStartPeriod; + const endPeriod = fimParam ?? currentPeriod; + + // Parse selected categories + const selectedCategoryIds = categoriasParam + ? categoriasParam.split(",").filter(Boolean) + : []; + + // Validate date range + const validation = validateDateRange(startPeriod, endPeriod); + if (!validation.isValid) { + // Redirect to default if validation fails + redirect( + `/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}` + ); + } + + // Fetch all categories for the user + const categoriaRows = await db.query.categorias.findMany({ + where: eq(categorias.userId, userId), + orderBy: [asc(categorias.name)], + }); + + // Map to CategoryOption format + const categoryOptions: CategoryOption[] = categoriaRows.map( + (cat: Categoria): CategoryOption => ({ + id: cat.id, + name: cat.name, + icon: cat.icon, + type: cat.type as "despesa" | "receita", + }) + ); + + // Build filters for data fetching + const filters: CategoryReportFilters = { + startPeriod, + endPeriod, + categoryIds: + selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined, + }; + + // Fetch report data + const reportData = await fetchCategoryReport(userId, filters); + + // Fetch chart data with same filters + const chartData = await fetchCategoryChartData( + userId, + startPeriod, + endPeriod, + selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined + ); + + // Build initial filter state for client component + const initialFilters: FilterState = { + selectedCategories: selectedCategoryIds, + startPeriod, + endPeriod, + }; + + return ( +
+ +
+ ); +} diff --git a/components/ajustes/preferences-form.tsx b/components/ajustes/preferences-form.tsx index 5755fd9..9cec038 100644 --- a/components/ajustes/preferences-form.tsx +++ b/components/ajustes/preferences-form.tsx @@ -11,21 +11,15 @@ import { toast } from "sonner"; interface PreferencesFormProps { disableMagnetlines: boolean; - periodMonthsBefore: number; - periodMonthsAfter: number; } export function PreferencesForm({ disableMagnetlines, - periodMonthsBefore, - periodMonthsAfter, }: PreferencesFormProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [magnetlinesDisabled, setMagnetlinesDisabled] = useState(disableMagnetlines); - const [monthsBefore, setMonthsBefore] = useState(periodMonthsBefore); - const [monthsAfter, setMonthsAfter] = useState(periodMonthsAfter); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -33,8 +27,6 @@ export function PreferencesForm({ startTransition(async () => { const result = await updatePreferencesAction({ disableMagnetlines: magnetlinesDisabled, - periodMonthsBefore: monthsBefore, - periodMonthsAfter: monthsAfter, }); if (result.success) { @@ -74,58 +66,6 @@ export function PreferencesForm({ disabled={isPending} /> - -
-
-

- Seleção de Período -

-

- Configure quantos meses antes e depois do mês atual serão exibidos - nos seletores de período. -

-
- -
-
- - setMonthsBefore(Number(e.target.value))} - disabled={isPending} - className="w-full" - /> -

- 1 a 24 meses -

-
- -
- - setMonthsAfter(Number(e.target.value))} - disabled={isPending} - className="w-full" - /> -

- 1 a 24 meses -

-
-
-
diff --git a/components/calendario/monthly-calendar.tsx b/components/calendario/monthly-calendar.tsx index 2704050..92b019f 100644 --- a/components/calendario/monthly-calendar.tsx +++ b/components/calendario/monthly-calendar.tsx @@ -118,7 +118,6 @@ export function MonthlyCalendar({ cartaoOptions={formOptions.cartaoOptions} categoriaOptions={formOptions.categoriaOptions} estabelecimentos={formOptions.estabelecimentos} - periodPreferences={formOptions.periodPreferences} defaultPeriod={period.period} defaultPurchaseDate={createDate ?? undefined} /> diff --git a/components/calendario/types.ts b/components/calendario/types.ts index fad5292..c164071 100644 --- a/components/calendario/types.ts +++ b/components/calendario/types.ts @@ -1,5 +1,4 @@ import type { LancamentoItem, SelectOption } from "@/components/lancamentos/types"; -import type { PeriodPreferences } from "@/lib/user-preferences/period"; export type CalendarEventType = "lancamento" | "boleto" | "cartao"; @@ -54,7 +53,6 @@ export type CalendarFormOptions = { cartaoOptions: SelectOption[]; categoriaOptions: SelectOption[]; estabelecimentos: string[]; - periodPreferences: PeriodPreferences; }; export type CalendarData = { diff --git a/components/lancamentos/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx b/components/lancamentos/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx index 41ce4d8..a5c4bc7 100644 --- a/components/lancamentos/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx +++ b/components/lancamentos/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx @@ -32,11 +32,10 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; +import { PeriodPicker } from "@/components/period-picker"; import { useControlledState } from "@/hooks/use-controlled-state"; import { useFormState } from "@/hooks/use-form-state"; import type { EligibleInstallment } from "@/lib/installments/anticipation-types"; -import type { PeriodPreferences } from "@/lib/user-preferences/period"; -import { createMonthOptions } from "@/lib/utils/period"; import { RiLoader4Line } from "@remixicon/react"; import { useCallback, @@ -55,7 +54,6 @@ interface AnticipateInstallmentsDialogProps { categorias: Array<{ id: string; name: string; icon: string | null }>; pagadores: Array<{ id: string; name: string }>; defaultPeriod: string; - periodPreferences: PeriodPreferences; open?: boolean; onOpenChange?: (open: boolean) => void; } @@ -75,7 +73,6 @@ export function AnticipateInstallmentsDialog({ categorias, pagadores, defaultPeriod, - periodPreferences, open, onOpenChange, }: AnticipateInstallmentsDialogProps) { @@ -104,16 +101,6 @@ export function AnticipateInstallmentsDialog({ note: "", }); - const periodOptions = useMemo( - () => - createMonthOptions( - formState.anticipationPeriod, - periodPreferences.monthsBefore, - periodPreferences.monthsAfter - ), - [formState.anticipationPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter] - ); - // Buscar parcelas elegíveis ao abrir o dialog useEffect(() => { if (dialogOpen) { @@ -262,24 +249,14 @@ export function AnticipateInstallmentsDialog({ Período - + className="w-full" + /> diff --git a/components/lancamentos/dialogs/lancamento-dialog/basic-fields-section.tsx b/components/lancamentos/dialogs/lancamento-dialog/basic-fields-section.tsx index b0da06d..26e2db9 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/basic-fields-section.tsx +++ b/components/lancamentos/dialogs/lancamento-dialog/basic-fields-section.tsx @@ -2,13 +2,7 @@ import { Label } from "@/components/ui/label"; import { DatePicker } from "@/components/ui/date-picker"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { PeriodPicker } from "@/components/period-picker"; import { CurrencyInput } from "@/components/ui/currency-input"; import { CalculatorDialogButton } from "@/components/calculadora/calculator-dialog"; import { RiCalculatorLine } from "@remixicon/react"; @@ -19,8 +13,7 @@ export function BasicFieldsSection({ formState, onFieldChange, estabelecimentos, - monthOptions, -}: BasicFieldsSectionProps) { +}: Omit) { return ( <>
@@ -37,21 +30,11 @@ export function BasicFieldsSection({
- + onChange={(value) => onFieldChange("period", value)} + className="w-full" + />
diff --git a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts index bba7035..f3399fb 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts +++ b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog-types.ts @@ -1,5 +1,4 @@ import type { LancamentoFormState } from "@/lib/lancamentos/form-helpers"; -import type { PeriodPreferences } from "@/lib/user-preferences/period"; import type { LancamentoItem, SelectOption } from "../../types"; export type FormState = LancamentoFormState; @@ -18,7 +17,6 @@ export interface LancamentoDialogProps { estabelecimentos: string[]; lancamento?: LancamentoItem; defaultPeriod?: string; - periodPreferences: PeriodPreferences; defaultCartaoId?: string | null; defaultPaymentMethod?: string | null; defaultPurchaseDate?: string | null; @@ -48,7 +46,6 @@ export interface BaseFieldSectionProps { export interface BasicFieldsSectionProps extends BaseFieldSectionProps { estabelecimentos: string[]; - monthOptions: Array<{ value: string; label: string }>; } export interface CategorySectionProps extends BaseFieldSectionProps { diff --git a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx index a7b4eaa..fe5f78d 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx +++ b/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog.tsx @@ -22,7 +22,6 @@ import { applyFieldDependencies, buildLancamentoInitialState, } from "@/lib/lancamentos/form-helpers"; -import { createMonthOptions } from "@/lib/utils/period"; import { useCallback, useEffect, @@ -58,7 +57,6 @@ export function LancamentoDialog({ estabelecimentos, lancamento, defaultPeriod, - periodPreferences, defaultCartaoId, defaultPaymentMethod, defaultPurchaseDate, @@ -125,15 +123,6 @@ export function LancamentoDialog({ return groupAndSortCategorias(filtered); }, [categoriaOptions, formState.transactionType]); - const monthOptions = useMemo( - () => - createMonthOptions( - formState.period, - periodPreferences.monthsBefore, - periodPreferences.monthsAfter - ), - [formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter] - ); const handleFieldChange = useCallback( (key: Key, value: FormState[Key]) => { @@ -352,7 +341,6 @@ export function LancamentoDialog({ formState={formState} onFieldChange={handleFieldChange} estabelecimentos={estabelecimentos} - monthOptions={monthOptions} /> - createMonthOptions( - selectedPeriod, - periodPreferences.monthsBefore, - periodPreferences.monthsAfter - ), - [selectedPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter] - ); - // Categorias agrupadas e filtradas por tipo de transação const groupedCategorias = useMemo(() => { const filtered = categoriaOptions.filter( @@ -336,18 +322,11 @@ export function MassAddDialog({ {/* Period */}
- +
{/* Conta/Cartao */} diff --git a/components/lancamentos/page/lancamentos-page.tsx b/components/lancamentos/page/lancamentos-page.tsx index 8a5e4d8..d894803 100644 --- a/components/lancamentos/page/lancamentos-page.tsx +++ b/components/lancamentos/page/lancamentos-page.tsx @@ -25,7 +25,6 @@ import type { LancamentoItem, SelectOption, } from "../types"; -import type { PeriodPreferences } from "@/lib/user-preferences/period"; interface LancamentosPageProps { lancamentos: LancamentoItem[]; @@ -40,7 +39,6 @@ interface LancamentosPageProps { contaCartaoFilterOptions: ContaCartaoFilterOption[]; selectedPeriod: string; estabelecimentos: string[]; - periodPreferences: PeriodPreferences; allowCreate?: boolean; defaultCartaoId?: string | null; defaultPaymentMethod?: string | null; @@ -61,7 +59,6 @@ export function LancamentosPage({ contaCartaoFilterOptions, selectedPeriod, estabelecimentos, - periodPreferences, allowCreate = true, defaultCartaoId, defaultPaymentMethod, @@ -357,7 +354,6 @@ export function LancamentosPage({ categoriaOptions={categoriaOptions} estabelecimentos={estabelecimentos} defaultPeriod={selectedPeriod} - periodPreferences={periodPreferences} defaultCartaoId={defaultCartaoId} defaultPaymentMethod={defaultPaymentMethod} lockCartaoSelection={lockCartaoSelection} @@ -383,7 +379,6 @@ export function LancamentosPage({ estabelecimentos={estabelecimentos} lancamento={lancamentoToCopy ?? undefined} defaultPeriod={selectedPeriod} - periodPreferences={periodPreferences} /> @@ -479,7 +473,6 @@ export function LancamentosPage({ categoriaOptions={categoriaOptions} estabelecimentos={estabelecimentos} selectedPeriod={selectedPeriod} - periodPreferences={periodPreferences} defaultPagadorId={defaultPagadorId} /> ) : null} @@ -515,7 +508,6 @@ export function LancamentosPage({ name: p.label, }))} defaultPeriod={selectedPeriod} - periodPreferences={periodPreferences} /> )} diff --git a/components/month-picker/month-picker.tsx b/components/month-picker/month-navigation.tsx similarity index 98% rename from components/month-picker/month-picker.tsx rename to components/month-picker/month-navigation.tsx index 3006713..e79cb7e 100644 --- a/components/month-picker/month-picker.tsx +++ b/components/month-picker/month-navigation.tsx @@ -8,7 +8,7 @@ import LoadingSpinner from "./loading-spinner"; import NavigationButton from "./nav-button"; import ReturnButton from "./return-button"; -export default function MonthPicker() { +export default function MonthNavigation() { const { monthNames, currentMonth, diff --git a/components/orcamentos/budget-dialog.tsx b/components/orcamentos/budget-dialog.tsx index c799da7..8de4842 100644 --- a/components/orcamentos/budget-dialog.tsx +++ b/components/orcamentos/budget-dialog.tsx @@ -24,10 +24,9 @@ import { SelectValue, } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; +import { PeriodPicker } from "@/components/period-picker"; import { useControlledState } from "@/hooks/use-controlled-state"; import { useFormState } from "@/hooks/use-form-state"; -import type { PeriodPreferences } from "@/lib/user-preferences/period"; -import { createMonthOptions } from "@/lib/utils/period"; import { useCallback, useEffect, @@ -45,7 +44,6 @@ interface BudgetDialogProps { budget?: Budget; categories: BudgetCategory[]; defaultPeriod: string; - periodPreferences: PeriodPreferences; open?: boolean; onOpenChange?: (open: boolean) => void; } @@ -68,7 +66,6 @@ export function BudgetDialog({ budget, categories, defaultPeriod, - periodPreferences, open, onOpenChange, }: BudgetDialogProps) { @@ -110,16 +107,6 @@ export function BudgetDialog({ } }, [dialogOpen]); - const periodOptions = useMemo( - () => - createMonthOptions( - formState.period, - periodPreferences.monthsBefore, - periodPreferences.monthsAfter - ), - [formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter] - ); - const handleSubmit = useCallback( (event: React.FormEvent) => { event.preventDefault(); @@ -244,21 +231,11 @@ export function BudgetDialog({
- + onChange={(value) => updateField("period", value)} + className="w-full" + />
diff --git a/components/orcamentos/budgets-page.tsx b/components/orcamentos/budgets-page.tsx index 2fd00a0..06b7e69 100644 --- a/components/orcamentos/budgets-page.tsx +++ b/components/orcamentos/budgets-page.tsx @@ -4,7 +4,6 @@ import { deleteBudgetAction } from "@/app/(dashboard)/orcamentos/actions"; import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { EmptyState } from "@/components/empty-state"; import { Button } from "@/components/ui/button"; -import type { PeriodPreferences } from "@/lib/user-preferences/period"; import { RiAddCircleLine, RiFundsLine } from "@remixicon/react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; @@ -18,7 +17,6 @@ interface BudgetsPageProps { categories: BudgetCategory[]; selectedPeriod: string; periodLabel: string; - periodPreferences: PeriodPreferences; } export function BudgetsPage({ @@ -26,7 +24,6 @@ export function BudgetsPage({ categories, selectedPeriod, periodLabel, - periodPreferences, }: BudgetsPageProps) { const [editOpen, setEditOpen] = useState(false); const [selectedBudget, setSelectedBudget] = useState(null); @@ -94,7 +91,6 @@ export function BudgetsPage({ mode="create" categories={categories} defaultPeriod={selectedPeriod} - periodPreferences={periodPreferences} trigger={ + + + + + + ); +} diff --git a/components/relatorios/category-cell.tsx b/components/relatorios/category-cell.tsx new file mode 100644 index 0000000..957e850 --- /dev/null +++ b/components/relatorios/category-cell.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { formatCurrency, formatPercentageChange } from "@/lib/relatorios/utils"; +import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react"; +import { cn } from "@/lib/utils/ui"; + +interface CategoryCellProps { + value: number; + previousValue: number; + categoryType: "despesa" | "receita"; + isFirstMonth: boolean; +} + +export function CategoryCell({ + value, + previousValue, + categoryType, + isFirstMonth, +}: CategoryCellProps) { + const percentageChange = + !isFirstMonth && previousValue !== 0 + ? ((value - previousValue) / previousValue) * 100 + : null; + + const isIncrease = percentageChange !== null && percentageChange > 0; + const isDecrease = percentageChange !== null && percentageChange < 0; + + return ( +
+ {formatCurrency(value)} + {!isFirstMonth && percentageChange !== null && ( +
+ {isIncrease && } + {isDecrease && } + {formatPercentageChange(percentageChange)} +
+ )} +
+ ); +} diff --git a/components/relatorios/category-report-cards.tsx b/components/relatorios/category-report-cards.tsx new file mode 100644 index 0000000..5fea952 --- /dev/null +++ b/components/relatorios/category-report-cards.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { TypeBadge } from "@/components/type-badge"; +import { getIconComponent } from "@/lib/utils/icons"; +import { formatPeriodLabel, formatCurrency } from "@/lib/relatorios/utils"; +import type { CategoryReportData } from "@/lib/relatorios/types"; +import { CategoryCell } from "./category-cell"; + +interface CategoryReportCardsProps { + data: CategoryReportData; +} + +export function CategoryReportCards({ data }: CategoryReportCardsProps) { + const { categories, periods } = data; + + return ( +
+ {categories.map((category) => { + const Icon = category.icon ? getIconComponent(category.icon) : null; + + return ( + + + + {Icon && } + {category.name} + + + + + {periods.map((period, periodIndex) => { + const monthData = category.monthlyData.get(period); + const isFirstMonth = periodIndex === 0; + + return ( +
+ + {formatPeriodLabel(period)} + + +
+ ); + })} +
+ Total + {formatCurrency(category.total)} +
+
+
+ ); + })} +
+ ); +} diff --git a/components/relatorios/category-report-chart.tsx b/components/relatorios/category-report-chart.tsx new file mode 100644 index 0000000..4ce687a --- /dev/null +++ b/components/relatorios/category-report-chart.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/empty-state"; +import { RiPieChartLine } from "@remixicon/react"; +import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data"; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers"; +import { useMemo } from "react"; + +interface CategoryReportChartProps { + data: CategoryChartData; +} + +const CHART_COLORS = [ + "#ef4444", // red-500 + "#3b82f6", // blue-500 + "#10b981", // emerald-500 + "#f59e0b", // amber-500 + "#8b5cf6", // violet-500 + "#ec4899", // pink-500 + "#14b8a6", // teal-500 + "#f97316", // orange-500 + "#06b6d4", // cyan-500 + "#84cc16", // lime-500 +]; + +const MAX_CATEGORIES_IN_CHART = 15; + +export function CategoryReportChart({ data }: CategoryReportChartProps) { + const { chartData, categories } = data; + + // Check if there's no data + if (categories.length === 0 || chartData.length === 0) { + return ( + } + mediaVariant="icon" + /> + ); + } + + // Get top 10 categories by total spending + const { topCategories, filteredChartData } = useMemo(() => { + // Calculate total for each category across all periods + const categoriesWithTotal = categories.map((category) => { + const total = chartData.reduce((sum, dataPoint) => { + const value = dataPoint[category.name]; + return sum + (typeof value === "number" ? value : 0); + }, 0); + + return { ...category, total }; + }); + + // Sort by total (descending) and take top 10 + const sorted = categoriesWithTotal + .sort((a, b) => b.total - a.total) + .slice(0, MAX_CATEGORIES_IN_CHART); + + // Filter chartData to include only top categories + const topCategoryNames = new Set(sorted.map((cat) => cat.name)); + const filtered = chartData.map((dataPoint) => { + const filteredPoint: { month: string; [key: string]: number | string } = { + month: dataPoint.month, + }; + + // Only include data for top categories + for (const cat of sorted) { + if (dataPoint[cat.name] !== undefined) { + filteredPoint[cat.name] = dataPoint[cat.name]; + } + } + + return filteredPoint; + }); + + return { topCategories: sorted, filteredChartData: filtered }; + }, [categories, chartData]); + + return ( + + + Evolução por Categoria - Top {topCategories.length} + + +
+ + + + {topCategories.map((category, index) => { + const color = CHART_COLORS[index % CHART_COLORS.length]; + return ( + + + + + ); + })} + + + + { + if (value >= 1000) { + return `${(value / 1000).toFixed(0)}k`; + } + return value.toString(); + }} + /> + { + if (!active || !payload || payload.length === 0) { + return null; + } + + return ( +
+
+ {payload[0]?.payload?.month} +
+
+ {payload.map((entry, index) => { + if (entry.dataKey === "month") return null; + + return ( +
+
+
+ + {entry.name} + +
+ + {currencyFormatter.format( + Number(entry.value) || 0 + )} + +
+ ); + })} +
+
+ ); + }} + /> + {topCategories.map((category, index) => { + const color = CHART_COLORS[index % CHART_COLORS.length]; + return ( + + ); + })} + + +
+ + {/* Legend */} +
+ {topCategories.map((category, index) => { + const color = CHART_COLORS[index % CHART_COLORS.length]; + return ( +
+
+ + {category.name} + +
+ ); + })} +
+ + + ); +} diff --git a/components/relatorios/category-report-export.tsx b/components/relatorios/category-report-export.tsx new file mode 100644 index 0000000..52755f1 --- /dev/null +++ b/components/relatorios/category-report-export.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { CategoryReportData } from "@/lib/relatorios/types"; +import type { FilterState } from "./types"; +import { + formatPeriodLabel, + formatCurrency, + formatPercentageChange, +} from "@/lib/relatorios/utils"; +import { + RiDownloadLine, + RiFileExcelLine, + RiFilePdfLine, + RiFileTextLine, +} from "@remixicon/react"; +import { toast } from "sonner"; +import { useState } from "react"; +import jsPDF from "jspdf"; +import autoTable from "jspdf-autotable"; +import * as XLSX from "xlsx"; + +interface CategoryReportExportProps { + data: CategoryReportData; + filters: FilterState; +} + +export function CategoryReportExport({ + data, + filters, +}: CategoryReportExportProps) { + const [isExporting, setIsExporting] = useState(false); + + const getFileName = (extension: string) => { + const start = filters.startPeriod; + const end = filters.endPeriod; + return `relatorio-categorias-${start}-${end}.${extension}`; + }; + + const exportToCSV = () => { + try { + setIsExporting(true); + + // Build CSV content + const headers = [ + "Categoria", + ...data.periods.map(formatPeriodLabel), + "Total", + ]; + const rows: string[][] = []; + + // Add category rows + data.categories.forEach((category) => { + const row: string[] = [category.name]; + + data.periods.forEach((period, periodIndex) => { + const monthData = category.monthlyData.get(period); + const value = monthData?.amount ?? 0; + const percentageChange = monthData?.percentageChange; + const isFirstMonth = periodIndex === 0; + + let cellValue = formatCurrency(value); + + // Add indicator as text + if (!isFirstMonth && percentageChange != null) { + const arrow = percentageChange > 0 ? "↑" : "↓"; + cellValue += ` (${arrow}${formatPercentageChange( + percentageChange + )})`; + } + + row.push(cellValue); + }); + + row.push(formatCurrency(category.total)); + rows.push(row); + }); + + // Add totals row + const totalsRow = ["Total Geral"]; + data.periods.forEach((period) => { + totalsRow.push(formatCurrency(data.totals.get(period) ?? 0)); + }); + totalsRow.push(formatCurrency(data.grandTotal)); + rows.push(totalsRow); + + // Generate CSV string + const csvContent = [ + headers.join(","), + ...rows.map((row) => row.map((cell) => `"${cell}"`).join(",")), + ].join("\n"); + + // Create blob and download + const blob = new Blob(["\uFEFF" + csvContent], { + type: "text/csv;charset=utf-8;", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = getFileName("csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success("Relatório exportado em CSV com sucesso!"); + } catch (error) { + console.error("Error exporting to CSV:", error); + toast.error("Erro ao exportar relatório em CSV"); + } finally { + setIsExporting(false); + } + }; + + const exportToExcel = () => { + try { + setIsExporting(true); + + // Build data array + const headers = [ + "Categoria", + ...data.periods.map(formatPeriodLabel), + "Total", + ]; + const rows: (string | number)[][] = []; + + // Add category rows + data.categories.forEach((category) => { + const row: (string | number)[] = [category.name]; + + data.periods.forEach((period, periodIndex) => { + const monthData = category.monthlyData.get(period); + const value = monthData?.amount ?? 0; + const percentageChange = monthData?.percentageChange; + const isFirstMonth = periodIndex === 0; + + let cellValue: string = formatCurrency(value); + + // Add indicator as text + if (!isFirstMonth && percentageChange != null) { + const arrow = percentageChange > 0 ? "↑" : "↓"; + cellValue += ` (${arrow}${formatPercentageChange( + percentageChange + )})`; + } + + row.push(cellValue); + }); + + row.push(formatCurrency(category.total)); + rows.push(row); + }); + + // Add totals row + const totalsRow: (string | number)[] = ["Total Geral"]; + data.periods.forEach((period) => { + totalsRow.push(formatCurrency(data.totals.get(period) ?? 0)); + }); + totalsRow.push(formatCurrency(data.grandTotal)); + rows.push(totalsRow); + + // Create worksheet + const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]); + + // Set column widths + ws["!cols"] = [ + { wch: 20 }, // Categoria + ...data.periods.map(() => ({ wch: 15 })), // Periods + { wch: 15 }, // Total + ]; + + // Create workbook and download + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Relatório de Categorias"); + XLSX.writeFile(wb, getFileName("xlsx")); + + toast.success("Relatório exportado em Excel com sucesso!"); + } catch (error) { + console.error("Error exporting to Excel:", error); + toast.error("Erro ao exportar relatório em Excel"); + } finally { + setIsExporting(false); + } + }; + + const exportToPDF = () => { + try { + setIsExporting(true); + + // Create PDF + const doc = new jsPDF({ orientation: "landscape" }); + + // Add header + doc.setFontSize(16); + doc.text("Relatório de Categorias por Período", 14, 15); + + doc.setFontSize(10); + doc.text( + `Período: ${formatPeriodLabel( + filters.startPeriod + )} - ${formatPeriodLabel(filters.endPeriod)}`, + 14, + 22 + ); + doc.text(`Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, 14, 27); + + // Build table data + const headers = [ + ["Categoria", ...data.periods.map(formatPeriodLabel), "Total"], + ]; + const body: string[][] = []; + + // Add category rows + data.categories.forEach((category) => { + const row: string[] = [category.name]; + + data.periods.forEach((period, periodIndex) => { + const monthData = category.monthlyData.get(period); + const value = monthData?.amount ?? 0; + const percentageChange = monthData?.percentageChange; + const isFirstMonth = periodIndex === 0; + + let cellValue = formatCurrency(value); + + // Add indicator as text + if (!isFirstMonth && percentageChange != null) { + const arrow = percentageChange > 0 ? "↑" : "↓"; + cellValue += `\n(${arrow}${formatPercentageChange( + percentageChange + )})`; + } + + row.push(cellValue); + }); + + row.push(formatCurrency(category.total)); + body.push(row); + }); + + // Add totals row + const totalsRow = ["Total Geral"]; + data.periods.forEach((period) => { + totalsRow.push(formatCurrency(data.totals.get(period) ?? 0)); + }); + totalsRow.push(formatCurrency(data.grandTotal)); + body.push(totalsRow); + + // Generate table with autoTable + autoTable(doc, { + head: headers, + body: body, + startY: 32, + styles: { + fontSize: 8, + cellPadding: 2, + }, + headStyles: { + fillColor: [59, 130, 246], // Blue + textColor: 255, + fontStyle: "bold", + }, + footStyles: { + fillColor: [229, 231, 235], // Gray + textColor: 0, + fontStyle: "bold", + }, + columnStyles: { + 0: { cellWidth: 35 }, // Categoria column wider + }, + didParseCell: (cellData) => { + // Style totals row + if ( + cellData.row.index === body.length - 1 && + cellData.section === "body" + ) { + cellData.cell.styles.fillColor = [243, 244, 246]; + cellData.cell.styles.fontStyle = "bold"; + } + + // Color coding for category rows (despesa/receita) + if ( + cellData.section === "body" && + cellData.row.index < body.length - 1 + ) { + const categoryIndex = cellData.row.index; + const category = data.categories[categoryIndex]; + + if (category && cellData.column.index > 0) { + // Apply subtle background colors + if (category.type === "despesa") { + cellData.cell.styles.textColor = [220, 38, 38]; // Red text + } else if (category.type === "receita") { + cellData.cell.styles.textColor = [22, 163, 74]; // Green text + } + } + } + }, + margin: { top: 32 }, + }); + + // Save PDF + doc.save(getFileName("pdf")); + + toast.success("Relatório exportado em PDF com sucesso!"); + } catch (error) { + console.error("Error exporting to PDF:", error); + toast.error("Erro ao exportar relatório em PDF"); + } finally { + setIsExporting(false); + } + }; + + return ( + + + + + + + + + + + + + + ); +} diff --git a/components/relatorios/category-report-filters.tsx b/components/relatorios/category-report-filters.tsx new file mode 100644 index 0000000..03edb22 --- /dev/null +++ b/components/relatorios/category-report-filters.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { MonthPicker } from "@/components/ui/monthpicker"; +import { validateDateRange } from "@/lib/relatorios/utils"; +import { getIconComponent } from "@/lib/utils/icons"; +import { cn } from "@/lib/utils/ui"; +import { RiCheckLine, RiExpandUpDownLine, RiCalendarLine } from "@remixicon/react"; +import { useMemo, useState } from "react"; +import { format } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import type { CategoryReportFiltersProps, FilterState } from "./types"; +import type { ReactNode } from "react"; + +/** + * Category Report Filters Component + * Provides filters for categories selection and date range + */ +export function CategoryReportFilters({ + categories, + filters, + onFiltersChange, + isLoading = false, + exportButton, +}: CategoryReportFiltersProps & { exportButton?: ReactNode }) { + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const [startMonthOpen, setStartMonthOpen] = useState(false); + const [endMonthOpen, setEndMonthOpen] = useState(false); + + // Convert period string (YYYY-MM) to Date object + const periodToDate = (period: string): Date => { + const [year, month] = period.split("-").map(Number); + return new Date(year, month - 1, 1); + }; + + // Convert Date object to period string (YYYY-MM) + const dateToPeriod = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; + }; + + // Format date for display + const formatMonthYear = (period: string): string => { + const date = periodToDate(period); + return format(date, "MMM/yyyy", { locale: ptBR }); + }; + + // Filter categories by search + const filteredCategories = useMemo(() => { + if (!searchValue) return categories; + const search = searchValue.toLowerCase(); + return categories.filter((cat) => + cat.name.toLowerCase().includes(search) + ); + }, [categories, searchValue]); + + // Get selected categories for display + const selectedCategories = useMemo(() => { + if (filters.selectedCategories.length === 0) return []; + return categories.filter((cat) => + filters.selectedCategories.includes(cat.id) + ); + }, [categories, filters.selectedCategories]); + + // Handle category toggle + const handleCategoryToggle = (categoryId: string) => { + const newSelected = filters.selectedCategories.includes(categoryId) + ? filters.selectedCategories.filter((id) => id !== categoryId) + : [...filters.selectedCategories, categoryId]; + + onFiltersChange({ + ...filters, + selectedCategories: newSelected, + }); + }; + + // Handle select all + const handleSelectAll = () => { + onFiltersChange({ + ...filters, + selectedCategories: categories.map((cat) => cat.id), + }); + setOpen(false); + }; + + // Handle clear all + const handleClearAll = () => { + onFiltersChange({ + ...filters, + selectedCategories: [], + }); + setOpen(false); + }; + + // Handle date change from MonthPicker + const handleDateChange = (field: "startPeriod" | "endPeriod", date: Date) => { + const period = dateToPeriod(date); + onFiltersChange({ + ...filters, + [field]: period, + }); + + // Close the popover after selection + if (field === "startPeriod") { + setStartMonthOpen(false); + } else { + setEndMonthOpen(false); + } + }; + + // Handle reset all filters + const handleReset = () => { + const currentPeriod = new Date().toISOString().slice(0, 7); + const defaultStartPeriod = new Date(); + defaultStartPeriod.setMonth(defaultStartPeriod.getMonth() - 5); + const startPeriod = defaultStartPeriod.toISOString().slice(0, 7); + + onFiltersChange({ + selectedCategories: [], + startPeriod, + endPeriod: currentPeriod, + }); + }; + + // Validate date range + const validation = useMemo(() => { + if (!filters.startPeriod || !filters.endPeriod) { + return { isValid: true }; + } + return validateDateRange(filters.startPeriod, filters.endPeriod); + }, [filters.startPeriod, filters.endPeriod]); + + // Display text for selected categories + const selectedText = useMemo(() => { + if (selectedCategories.length === 0) { + return "Categoria"; + } + if (selectedCategories.length === categories.length) { + return "Todas"; + } + if (selectedCategories.length === 1) { + return selectedCategories[0].name; + } + return `${selectedCategories.length} selecionadas`; + }, [selectedCategories, categories.length]); + + return ( +
+
+
+ {/* Category Multi-Select */} + + + + + + + + + Nenhuma categoria encontrada. + + {/* Select All / Clear All */} +
+ + +
+ + {/* Category List */} + {filteredCategories.map((category) => { + const isSelected = filters.selectedCategories.includes( + category.id + ); + const IconComponent = category.icon + ? getIconComponent(category.icon) + : null; + + return ( + handleCategoryToggle(category.id)} + className="cursor-pointer" + > +
+ {IconComponent && ( +
+ {isSelected && ( +
+ ); + })} +
+
+
+
+
+ + {/* Start Period Picker */} + + + + + + handleDateChange("startPeriod", date)} + /> + + + + {/* End Period Picker */} + + + + + + handleDateChange("endPeriod", date)} + /> + + + + {/* Reset Button */} + +
+ + {/* Export Button */} + {exportButton} +
+ + {/* Validation Message */} + {!validation.isValid && validation.error && ( +
+ {validation.error} +
+ )} +
+ ); +} diff --git a/components/relatorios/category-report-page.tsx b/components/relatorios/category-report-page.tsx new file mode 100644 index 0000000..dfe4aef --- /dev/null +++ b/components/relatorios/category-report-page.tsx @@ -0,0 +1,194 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useMemo, useState, useTransition } from "react"; +import type { CategoryReportData } from "@/lib/relatorios/types"; +import type { CategoryOption, FilterState } from "./types"; +import { CategoryReportFilters } from "./category-report-filters"; +import { CategoryReportTable } from "./category-report-table"; +import { CategoryReportCards } from "./category-report-cards"; +import { CategoryReportExport } from "./category-report-export"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/empty-state"; +import { + RiFilter3Line, + RiPieChartLine, + RiTable2, + RiLineChartLine, +} from "@remixicon/react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CategoryReportChart } from "./category-report-chart"; +import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data"; +import { CategoryReportSkeleton } from "@/components/skeletons/category-report-skeleton"; + +interface CategoryReportPageProps { + initialData: CategoryReportData; + categories: CategoryOption[]; + initialFilters: FilterState; + chartData: CategoryChartData; +} + +export function CategoryReportPage({ + initialData, + categories, + initialFilters, + chartData, +}: CategoryReportPageProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + const [filters, setFilters] = useState(initialFilters); + const [data, setData] = useState(initialData); + + // Get active tab from URL or default to "table" + const activeTab = searchParams.get("aba") || "table"; + + // Debounce timer + const [debounceTimer, setDebounceTimer] = useState( + null + ); + + const handleFiltersChange = useCallback( + (newFilters: FilterState) => { + setFilters(newFilters); + + // Clear existing timer + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + // Set new debounced timer (300ms) + const timer = setTimeout(() => { + startTransition(() => { + // Build new URL with query params + const params = new URLSearchParams(searchParams.toString()); + + params.set("inicio", newFilters.startPeriod); + params.set("fim", newFilters.endPeriod); + + if (newFilters.selectedCategories.length > 0) { + params.set("categorias", newFilters.selectedCategories.join(",")); + } else { + params.delete("categorias"); + } + + // Preserve current tab + const currentTab = searchParams.get("aba"); + if (currentTab) { + params.set("aba", currentTab); + } + + // Navigate with new params (this will trigger server component re-render) + router.push(`?${params.toString()}`, { scroll: false }); + }); + }, 300); + + setDebounceTimer(timer); + }, + [debounceTimer, router, searchParams] + ); + + // Handle tab change + const handleTabChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("aba", value); + router.push(`?${params.toString()}`, { scroll: false }); + }, + [router, searchParams] + ); + + // Update data when initialData changes (from server) + useMemo(() => { + setData(initialData); + }, [initialData]); + + // Check if no categories are available + const hasNoCategories = categories.length === 0; + + // Check if no data in period + const hasNoData = data.categories.length === 0 && !hasNoCategories; + + return ( +
+ {/* Filters */} + } + /> + + {/* Loading State */} + {isPending && } + + {/* Empty States */} + {!isPending && hasNoCategories && ( + } + mediaVariant="icon" + /> + )} + + {!isPending && + !hasNoCategories && + hasNoData && + filters.selectedCategories.length === 0 && ( + } + mediaVariant="icon" + /> + )} + + {!isPending && + !hasNoCategories && + hasNoData && + filters.selectedCategories.length > 0 && ( + } + mediaVariant="icon" + /> + )} + + {/* Tabs: Table and Chart */} + {!isPending && !hasNoCategories && !hasNoData && ( + + + + + Tabela + + + + Gráfico + + + + + {/* Desktop Table */} +
+ +
+ + {/* Mobile Cards */} + +
+ + + + +
+ )} +
+ ); +} diff --git a/components/relatorios/category-report-table.tsx b/components/relatorios/category-report-table.tsx new file mode 100644 index 0000000..0a9ee21 --- /dev/null +++ b/components/relatorios/category-report-table.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { getIconComponent } from "@/lib/utils/icons"; +import { formatPeriodLabel } from "@/lib/relatorios/utils"; +import type { CategoryReportData } from "@/lib/relatorios/types"; +import { CategoryCell } from "./category-cell"; +import { formatCurrency } from "@/lib/relatorios/utils"; +import { Card } from "../ui/card"; +import DotIcon from "../dot-icon"; + +interface CategoryReportTableProps { + data: CategoryReportData; +} + +export function CategoryReportTable({ data }: CategoryReportTableProps) { + const { categories, periods, totals, grandTotal } = data; + + return ( + + + + + + Categoria + + {periods.map((period) => ( + + {formatPeriodLabel(period)} + + ))} + + Total + + + + + + {categories.map((category) => { + const Icon = category.icon ? getIconComponent(category.icon) : null; + const isReceita = category.type.toLowerCase() === "receita"; + const dotColor = isReceita + ? "bg-green-600 dark:bg-green-400" + : "bg-red-600 dark:bg-red-400"; + + return ( + + +
+ + {Icon && } + {category.name} +
+
+ {periods.map((period, periodIndex) => { + const monthData = category.monthlyData.get(period); + const isFirstMonth = periodIndex === 0; + + return ( + + + + ); + })} + + {formatCurrency(category.total)} + +
+ ); + })} +
+ + + + Total Geral + {periods.map((period) => { + const periodTotal = totals.get(period) ?? 0; + return ( + + {formatCurrency(periodTotal)} + + ); + })} + + {formatCurrency(grandTotal)} + + + +
+
+ ); +} diff --git a/components/relatorios/index.ts b/components/relatorios/index.ts new file mode 100644 index 0000000..b01cefb --- /dev/null +++ b/components/relatorios/index.ts @@ -0,0 +1,8 @@ +export { CategoryReportPage } from "./category-report-page"; +export { CategoryReportTable } from "./category-report-table"; +export { CategoryReportCards } from "./category-report-cards"; +export { CategoryReportFilters } from "./category-report-filters"; +export { CategoryReportExport } from "./category-report-export"; +export { CategoryReportChart } from "./category-report-chart"; +export { CategoryCell } from "./category-cell"; +export type { CategoryOption, FilterState, CategoryReportFiltersProps } from "./types"; diff --git a/components/relatorios/types.ts b/components/relatorios/types.ts new file mode 100644 index 0000000..63ed448 --- /dev/null +++ b/components/relatorios/types.ts @@ -0,0 +1,34 @@ +/** + * UI types for Category Report components + */ + +/** + * Category option for report filters + * Includes type field for filtering despesas/receitas + */ +export interface CategoryOption { + id: string; + name: string; + icon: string | null; + type: "despesa" | "receita"; +} + +/** + * Filter state for category report + * Manages selected categories and date range + */ +export interface FilterState { + selectedCategories: string[]; // Array of category IDs + startPeriod: string; // Format: "YYYY-MM" + endPeriod: string; // Format: "YYYY-MM" +} + +/** + * Props for CategoryReportFilters component + */ +export interface CategoryReportFiltersProps { + categories: CategoryOption[]; + filters: FilterState; + onFiltersChange: (filters: FilterState) => void; + isLoading?: boolean; +} diff --git a/components/sidebar/nav-link.tsx b/components/sidebar/nav-link.tsx index b4a909e..daba35b 100644 --- a/components/sidebar/nav-link.tsx +++ b/components/sidebar/nav-link.tsx @@ -5,9 +5,9 @@ import { RiBankLine, RiCalendarEventLine, RiDashboardLine, + RiFileChartLine, RiFundsLine, RiGroupLine, - RiLineChartLine, RiPriceTag3Line, RiSettingsLine, RiSparklingLine, @@ -125,14 +125,6 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData { title: "Categorias", url: "/categorias", icon: RiPriceTag3Line, - items: [ - { - title: "Histórico", - url: "/categorias/historico", - key: "historico-categorias", - icon: RiLineChartLine, - }, - ], }, ], }, @@ -159,6 +151,16 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData { }, ], }, + { + title: "Relatórios", + items: [ + { + title: "Categorias", + url: "/relatorios/categorias", + icon: RiFileChartLine, + }, + ], + }, ], navSecondary: [ // { diff --git a/components/skeletons/category-report-skeleton.tsx b/components/skeletons/category-report-skeleton.tsx new file mode 100644 index 0000000..b2ebfe3 --- /dev/null +++ b/components/skeletons/category-report-skeleton.tsx @@ -0,0 +1,193 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + +/** + * Skeleton para a página de relatórios de categorias + * Mantém a mesma estrutura de filtros, tabs e conteúdo + */ +export function CategoryReportSkeleton() { + return ( +
+ {/* Filters Skeleton */} +
+
+
+ {/* Category MultiSelect */} + + {/* Start Period */} + + {/* End Period */} + + {/* Clear Button */} + +
+ {/* Export Button */} + +
+
+ + {/* Tabs Skeleton */} + + +
+ + +
+
+ + + {/* Desktop Table Skeleton */} +
+ +
+ + {/* Mobile Cards Skeleton */} +
+ {Array.from({ length: 5 }).map((_, i) => ( + +
+ {/* Category name with icon */} +
+ + +
+ {/* Type badge */} + + {/* Values */} +
+ {Array.from({ length: 3 }).map((_, j) => ( +
+ + +
+ ))} +
+
+
+ ))} +
+
+ + + {/* Chart Skeleton */} + +
+ {/* Chart title area */} +
+ + +
+ {/* Chart area */} + + {/* Legend */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+
+
+
+ ); +} + +/** + * Skeleton para a tabela de relatórios de categorias + * Mantém a estrutura de colunas: Categoria, Tipo, múltiplos períodos, Total + */ +function CategoryReportTableSkeleton() { + // Simula 6 períodos (colunas) + const periodColumns = 6; + + return ( + + + + + {/* Categoria */} + + + + {/* Period columns */} + {Array.from({ length: periodColumns }).map((_, i) => ( + + + + ))} + {/* Total */} + + + + + + + + {Array.from({ length: 8 }).map((_, rowIndex) => ( + + {/* Category name with dot and icon */} + +
+ + + +
+
+ {/* Period values */} + {Array.from({ length: periodColumns }).map((_, colIndex) => ( + +
+ + {colIndex > 0 && ( + + )} +
+
+ ))} + {/* Total */} + + + +
+ ))} +
+ + + + {/* Total label */} + + + + {/* Period totals */} + {Array.from({ length: periodColumns }).map((_, i) => ( + + + + ))} + {/* Grand total */} + + + + + +
+
+ ); +} diff --git a/components/skeletons/index.ts b/components/skeletons/index.ts index ce6fde8..76c498b 100644 --- a/components/skeletons/index.ts +++ b/components/skeletons/index.ts @@ -3,6 +3,7 @@ * Facilita a importação em outros componentes */ export { AccountStatementCardSkeleton } from "./account-statement-card-skeleton"; +export { CategoryReportSkeleton } from "./category-report-skeleton"; export { DashboardGridSkeleton } from "./dashboard-grid-skeleton"; export { FilterSkeleton } from "./filter-skeleton"; export { InvoiceSummaryCardSkeleton } from "./invoice-summary-card-skeleton"; diff --git a/components/type-badge.tsx b/components/type-badge.tsx index 71d779d..ea19dde 100644 --- a/components/type-badge.tsx +++ b/components/type-badge.tsx @@ -37,13 +37,13 @@ export function TypeBadge({ type, className }: TypeBadgeProps) { const colorClass = isTransferencia ? "text-blue-700 dark:text-blue-400" - : (isReceita || isSaldoInicial) + : isReceita || isSaldoInicial ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"; const dotColor = isTransferencia ? "bg-blue-700 dark:bg-blue-400" - : (isReceita || isSaldoInicial) + : isReceita || isSaldoInicial ? "bg-green-600 dark:bg-green-400" : "bg-red-600 dark:bg-red-400"; diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 2a3ca09..37a7d4b 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,11 +1,11 @@ -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils/ui"; +import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:scale-105 transition-transform", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { @@ -34,27 +34,29 @@ const buttonVariants = cva( size: "default", }, } -); +) function Button({ className, - variant, - size, + variant = "default", + size = "default", asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean; + asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot : "button" return ( - ); + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/components/ui/monthpicker.tsx b/components/ui/monthpicker.tsx new file mode 100644 index 0000000..1d4d5c9 --- /dev/null +++ b/components/ui/monthpicker.tsx @@ -0,0 +1,219 @@ +"use client"; +import * as React from "react"; +import { RiArrowLeftSFill, RiArrowRightSFill } from "@remixicon/react"; +import { buttonVariants } from "./button"; +import { cn } from "@/lib/utils/ui"; + +type Month = { + number: number; + name: string; +}; + +const MONTHS: Month[][] = [ + [ + { number: 0, name: "Jan" }, + { number: 1, name: "Fev" }, + { number: 2, name: "Mar" }, + { number: 3, name: "Abr" }, + ], + [ + { number: 4, name: "Mai" }, + { number: 5, name: "Jun" }, + { number: 6, name: "Jul" }, + { number: 7, name: "Ago" }, + ], + [ + { number: 8, name: "Set" }, + { number: 9, name: "Out" }, + { number: 10, name: "Nov" }, + { number: 11, name: "Dez" }, + ], +]; + +type MonthCalProps = { + selectedMonth?: Date; + onMonthSelect?: (date: Date) => void; + onYearForward?: () => void; + onYearBackward?: () => void; + callbacks?: { + yearLabel?: (year: number) => string; + monthLabel?: (month: Month) => string; + }; + variant?: { + calendar?: { + main?: ButtonVariant; + selected?: ButtonVariant; + }; + chevrons?: ButtonVariant; + }; + minDate?: Date; + maxDate?: Date; + disabledDates?: Date[]; +}; + +type ButtonVariant = + | "default" + | "outline" + | "ghost" + | "link" + | "destructive" + | "secondary" + | null + | undefined; + +function MonthPicker({ + onMonthSelect, + selectedMonth, + minDate, + maxDate, + disabledDates, + callbacks, + onYearBackward, + onYearForward, + variant, + className, + ...props +}: React.HTMLAttributes & MonthCalProps) { + return ( +
+
+
+ +
+
+
+ ); +} + +function MonthCal({ + selectedMonth, + onMonthSelect, + callbacks, + variant, + minDate, + maxDate, + disabledDates, + onYearBackward, + onYearForward, +}: MonthCalProps) { + const [year, setYear] = React.useState( + selectedMonth?.getFullYear() ?? new Date().getFullYear() + ); + const [month, setMonth] = React.useState( + selectedMonth?.getMonth() ?? new Date().getMonth() + ); + const [menuYear, setMenuYear] = React.useState(year); + + if (minDate && maxDate && minDate > maxDate) minDate = maxDate; + + const disabledDatesMapped = disabledDates?.map((d) => { + return { year: d.getFullYear(), month: d.getMonth() }; + }); + + return ( + <> +
+
+ {callbacks?.yearLabel ? callbacks?.yearLabel(menuYear) : menuYear} +
+
+ + +
+
+ + + {MONTHS.map((monthRow, a) => { + return ( + + {monthRow.map((m) => { + return ( + + ); + })} + + ); + })} + +
+ +
+ + ); +} + +MonthPicker.displayName = "MonthPicker"; + +export { MonthPicker }; diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx index b6c21b6..b16c206 100644 --- a/components/ui/sidebar.tsx +++ b/components/ui/sidebar.tsx @@ -386,7 +386,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
); diff --git a/db/schema.ts b/db/schema.ts index 3563cc8..420b324 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -109,8 +109,6 @@ export const userPreferences = pgTable("user_preferences", { .unique() .references(() => user.id, { onDelete: "cascade" }), disableMagnetlines: boolean("disable_magnetlines").notNull().default(false), - periodMonthsBefore: integer("period_months_before").notNull().default(3), - periodMonthsAfter: integer("period_months_after").notNull().default(3), createdAt: timestamp("created_at", { mode: "date", withTimezone: true, diff --git a/lib/relatorios/fetch-category-chart-data.ts b/lib/relatorios/fetch-category-chart-data.ts new file mode 100644 index 0000000..b418594 --- /dev/null +++ b/lib/relatorios/fetch-category-chart-data.ts @@ -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 { + // 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`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; + } + >(); + + // 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, + }; +} diff --git a/lib/relatorios/fetch-category-report.ts b/lib/relatorios/fetch-category-report.ts new file mode 100644 index 0000000..a02533c --- /dev/null +++ b/lib/relatorios/fetch-category-report.ts @@ -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 { + 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`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(); + const periodTotalsMap = new Map(); + + // 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(), + 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, + }; +} diff --git a/lib/relatorios/types.ts b/lib/relatorios/types.ts new file mode 100644 index 0000000..831de35 --- /dev/null +++ b/lib/relatorios/types.ts @@ -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; // 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; // 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; +}; diff --git a/lib/relatorios/utils.ts b/lib/relatorios/utils.ts new file mode 100644 index 0000000..fa2410a --- /dev/null +++ b/lib/relatorios/utils.ts @@ -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}%`; +} diff --git a/lib/user-preferences/period.ts b/lib/user-preferences/period.ts deleted file mode 100644 index 45fee22..0000000 --- a/lib/user-preferences/period.ts +++ /dev/null @@ -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 { - 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, - }; -} diff --git a/lib/utils/period/index.ts b/lib/utils/period/index.ts index 06c3382..422c6c1 100644 --- a/lib/utils/period/index.ts +++ b/lib/utils/period/index.ts @@ -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)); -} diff --git a/package.json b/package.json index 53ddbfe..f6046fb 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,9 @@ "docker:rebuild": "docker compose up --build --force-recreate" }, "dependencies": { - "@ai-sdk/anthropic": "^3.0.1", - "@ai-sdk/google": "^3.0.1", - "@ai-sdk/openai": "^3.0.1", + "@ai-sdk/anthropic": "^3.0.2", + "@ai-sdk/google": "^3.0.2", + "@ai-sdk/openai": "^3.0.2", "@openrouter/ai-sdk-provider": "^1.5.4", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", @@ -56,14 +56,16 @@ "@tanstack/react-table": "8.21.3", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", - "ai": "^6.0.3", + "ai": "^6.0.6", "babel-plugin-react-compiler": "^1.0.0", - "better-auth": "1.4.9", + "better-auth": "1.4.10", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "drizzle-orm": "0.45.1", + "jspdf": "^4.0.0", + "jspdf-autotable": "^5.0.2", "motion": "^12.23.26", "next": "16.1.1", "next-themes": "0.4.6", @@ -76,7 +78,8 @@ "sonner": "2.0.7", "tailwind-merge": "3.4.0", "vaul": "1.1.2", - "zod": "4.2.1" + "xlsx": "^0.18.5", + "zod": "4.3.4" }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe0afe5..4fcfe33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@ai-sdk/anthropic': - specifier: ^3.0.1 - version: 3.0.1(zod@4.2.1) + specifier: ^3.0.2 + version: 3.0.2(zod@4.3.4) '@ai-sdk/google': - specifier: ^3.0.1 - version: 3.0.1(zod@4.2.1) + specifier: ^3.0.2 + version: 3.0.2(zod@4.3.4) '@ai-sdk/openai': - specifier: ^3.0.1 - version: 3.0.1(zod@4.2.1) + specifier: ^3.0.2 + version: 3.0.2(zod@4.3.4) '@openrouter/ai-sdk-provider': specifier: ^1.5.4 - version: 1.5.4(ai@6.0.3(zod@4.2.1))(zod@4.2.1) + version: 1.5.4(ai@6.0.6(zod@4.3.4))(zod@4.3.4) '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -96,14 +96,14 @@ importers: specifier: ^1.3.1 version: 1.3.1(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) ai: - specifier: ^6.0.3 - version: 6.0.3(zod@4.2.1) + specifier: ^6.0.6 + version: 6.0.6(zod@4.3.4) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 better-auth: - specifier: 1.4.9 - version: 1.4.9(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 1.4.10 + version: 1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -119,6 +119,12 @@ importers: drizzle-orm: specifier: 0.45.1 version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) + jspdf: + specifier: ^4.0.0 + version: 4.0.0 + jspdf-autotable: + specifier: ^5.0.2 + version: 5.0.2(jspdf@4.0.0) motion: specifier: ^12.23.26 version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -155,9 +161,12 @@ importers: vaul: specifier: 1.1.2 version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zod: - specifier: 4.2.1 - version: 4.2.1 + specifier: 4.3.4 + version: 4.3.4 devDependencies: '@tailwindcss/postcss': specifier: 4.1.18 @@ -207,38 +216,38 @@ importers: packages: - '@ai-sdk/anthropic@3.0.1': - resolution: {integrity: sha512-MOiwKs76ilEmau/WRMnGWlheTUoB+cbvXCse+SAtpW5ATLreInsuYlspLABn12Dxu3w1Xzke1dT+tmEnxhy9SA==} + '@ai-sdk/anthropic@3.0.2': + resolution: {integrity: sha512-D6iSsrOYryBSPsFtOiEDv54jnjVCU/flIuXdjuRY7LdikB0KGjpazN8Dt4ONXzL+ux69ds2nzFNKke/w/fgLAA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@3.0.2': - resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==} + '@ai-sdk/gateway@3.0.5': + resolution: {integrity: sha512-AtxA1wcoKTHr9uFoC5KZEXqJP4SMW4j3VbcliUECUYssbWbePJ9+b3AaCny1lxf1xhDK9EIyAgBOKhXoQSr9nA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/google@3.0.1': - resolution: {integrity: sha512-gh7i4lEvd1CElmefkq7+RoUhNkhP2OTshzVxSt7/Vh2AV5wTPLhduKJMg1c7SFwErytqffO3el/M/LlfCsqzEw==} + '@ai-sdk/google@3.0.2': + resolution: {integrity: sha512-KyV4AR8fBKVCABfav3zGn/PY7cMDMt9m7yYhH+FJ7jLfBrEVdjT4sM0ojPFRHYUelXHl42oOAgpy3GWkeG6vtw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@3.0.1': - resolution: {integrity: sha512-P+qxz2diOrh8OrpqLRg+E+XIFVIKM3z2kFjABcCJGHjGbXBK88AJqmuKAi87qLTvTe/xn1fhZBjklZg9bTyigw==} + '@ai-sdk/openai@3.0.2': + resolution: {integrity: sha512-GONwavgSWtcWO+t9+GpGK8l7nIYh+zNtCL/NYDSeHxHiw6ksQS9XMRWrZyE5NpJ0EXNxSAWCHIDmb1WvTqhq9Q==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@4.0.1': - resolution: {integrity: sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ==} + '@ai-sdk/provider-utils@4.0.2': + resolution: {integrity: sha512-KaykkuRBdF/ffpI5bwpL4aSCmO/99p8/ci+VeHwJO8tmvXtiVAb99QeyvvvXmL61e9Zrvv4GBGoajW19xdjkVQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@3.0.0': - resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==} + '@ai-sdk/provider@3.0.1': + resolution: {integrity: sha512-2lR4w7mr9XrydzxBSjir4N6YMGdXD+Np1Sh0RXABh7tWdNFFwIeRI1Q+SaYZMbfL8Pg8RRLcrxQm51yxTLhokg==} engines: {node: '>=18'} '@alloc/quick-lru@5.2.0': @@ -300,6 +309,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -312,8 +325,8 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.4.9': - resolution: {integrity: sha512-JT2q4NDkQzN22KclUEoZ7qU6tl9HUTfK1ctg2oWlT87SEagkwJcnrUwS9VznL+u9ziOIfY27P0f7/jSnmvLcoQ==} + '@better-auth/core@1.4.10': + resolution: {integrity: sha512-AThrfb6CpG80wqkanfrbN2/fGOYzhGladHFf3JhaWt/3/Vtf4h084T6PJLrDE7M/vCCGYvDI1DkvP3P1OB2HAg==} peerDependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -322,10 +335,10 @@ packages: kysely: ^0.28.5 nanostores: ^1.0.1 - '@better-auth/telemetry@1.4.9': - resolution: {integrity: sha512-Tthy1/Gmx+pYlbvRQPBTKfVei8+pJwvH1NZp+5SbhwA6K2EXIaoonx/K6N/AXYs2aKUpyR4/gzqDesDjL7zd6A==} + '@better-auth/telemetry@1.4.10': + resolution: {integrity: sha512-Dq4XJX6EKsUu0h3jpRagX739p/VMOTcnJYWRrLtDYkqtZFg+sFiFsSWVcfapZoWpRSUGYX9iKwl6nDHn6Ju2oQ==} peerDependencies: - '@better-auth/core': 1.4.9 + '@better-auth/core': 1.4.10 '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} @@ -1873,12 +1886,18 @@ packages: '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} '@types/pg@8.16.0': resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1887,6 +1906,9 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -2122,8 +2144,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ai@6.0.3: - resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==} + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + + ai@6.0.6: + resolution: {integrity: sha512-LM0eAMWVn3RTj+0X5O1m/8g+7QiTeWG5aN5FsDbdmCkAQHVg93XxLbljFOLzi0NMjuJgf7fKLKmWoPsrdMyqfw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -2222,12 +2248,16 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + baseline-browser-mapping@2.9.11: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true - better-auth@1.4.9: - resolution: {integrity: sha512-usSdjuyTzZwIvM8fjF8YGhPncxV3MAg3dHUO9uPUnf0yklXUSYISiH1+imk6/Z+UBqsscyyPRnbIyjyK97p7YA==} + better-auth@1.4.10: + resolution: {integrity: sha512-0kqwEBJLe8eyFzbUspRG/htOriCf9uMLlnpe34dlIJGdmDfPuQISd4shShvUrvIVhPxsY1dSTXdXPLpqISYOYg==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -2337,6 +2367,14 @@ packages: caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2360,6 +2398,10 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2373,14 +2415,25 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2506,6 +2559,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -2844,6 +2900,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} @@ -2859,6 +2918,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2886,6 +2948,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + framer-motion@12.23.26: resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} peerDependencies: @@ -3018,6 +3084,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3051,6 +3121,9 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3222,6 +3295,14 @@ packages: engines: {node: '>=6'} hasBin: true + jspdf-autotable@5.0.2: + resolution: {integrity: sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==} + peerDependencies: + jspdf: ^2 || ^3 + + jspdf@4.0.0: + resolution: {integrity: sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3485,6 +3566,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3512,6 +3596,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} @@ -3605,6 +3692,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + react-day-picker@9.13.0: resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==} engines: {node: '>=18'} @@ -3689,6 +3779,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -3743,6 +3836,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -3843,9 +3940,17 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3910,6 +4015,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + svix@1.76.1: resolution: {integrity: sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==} @@ -3923,6 +4032,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4033,6 +4145,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true @@ -4071,14 +4186,27 @@ packages: engines: {node: '>= 8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4112,44 +4240,44 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.2.1: - resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zod@4.3.4: + resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} snapshots: - '@ai-sdk/anthropic@3.0.1(zod@4.2.1)': + '@ai-sdk/anthropic@3.0.2(zod@4.3.4)': dependencies: - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.1(zod@4.2.1) - zod: 4.2.1 + '@ai-sdk/provider': 3.0.1 + '@ai-sdk/provider-utils': 4.0.2(zod@4.3.4) + zod: 4.3.4 - '@ai-sdk/gateway@3.0.2(zod@4.2.1)': + '@ai-sdk/gateway@3.0.5(zod@4.3.4)': dependencies: - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.1(zod@4.2.1) + '@ai-sdk/provider': 3.0.1 + '@ai-sdk/provider-utils': 4.0.2(zod@4.3.4) '@vercel/oidc': 3.0.5 - zod: 4.2.1 + zod: 4.3.4 - '@ai-sdk/google@3.0.1(zod@4.2.1)': + '@ai-sdk/google@3.0.2(zod@4.3.4)': dependencies: - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.1(zod@4.2.1) - zod: 4.2.1 + '@ai-sdk/provider': 3.0.1 + '@ai-sdk/provider-utils': 4.0.2(zod@4.3.4) + zod: 4.3.4 - '@ai-sdk/openai@3.0.1(zod@4.2.1)': + '@ai-sdk/openai@3.0.2(zod@4.3.4)': dependencies: - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.1(zod@4.2.1) - zod: 4.2.1 + '@ai-sdk/provider': 3.0.1 + '@ai-sdk/provider-utils': 4.0.2(zod@4.3.4) + zod: 4.3.4 - '@ai-sdk/provider-utils@4.0.1(zod@4.2.1)': + '@ai-sdk/provider-utils@4.0.2(zod@4.3.4)': dependencies: - '@ai-sdk/provider': 3.0.0 + '@ai-sdk/provider': 3.0.1 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.2.1 + zod: 4.3.4 - '@ai-sdk/provider@3.0.0': + '@ai-sdk/provider@3.0.1': dependencies: json-schema: 0.4.0 @@ -4232,6 +4360,8 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -4255,20 +4385,20 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': + '@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.4))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@standard-schema/spec': 1.1.0 - better-call: 1.1.7(zod@4.2.1) + better-call: 1.1.7(zod@4.3.4) jose: 6.1.3 kysely: 0.28.9 nanostores: 1.1.0 - zod: 4.2.1 + zod: 4.3.4 - '@better-auth/telemetry@1.4.9(@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': + '@better-auth/telemetry@1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.4))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': dependencies: - '@better-auth/core': 1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.4))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -4773,15 +4903,15 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@openrouter/ai-sdk-provider@1.5.4(ai@6.0.3(zod@4.2.1))(zod@4.2.1)': + '@openrouter/ai-sdk-provider@1.5.4(ai@6.0.6(zod@4.3.4))(zod@4.3.4)': dependencies: '@openrouter/sdk': 0.1.27 - ai: 6.0.3(zod@4.2.1) - zod: 4.2.1 + ai: 6.0.6(zod@4.3.4) + zod: 4.3.4 '@openrouter/sdk@0.1.27': dependencies: - zod: 4.2.1 + zod: 4.3.4 '@opentelemetry/api@1.9.0': {} @@ -5528,6 +5658,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pako@2.0.4': {} + '@types/parse-json@4.0.2': {} '@types/pg@8.16.0': @@ -5536,6 +5668,9 @@ snapshots: pg-protocol: 1.10.3 pg-types: 2.2.0 + '@types/raf@3.4.3': + optional: true + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -5544,6 +5679,9 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': + optional: true + '@types/use-sync-external-store@0.0.6': {} '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': @@ -5746,13 +5884,15 @@ snapshots: acorn@8.15.0: {} - ai@6.0.3(zod@4.2.1): + adler-32@1.3.1: {} + + ai@6.0.6(zod@4.3.4): dependencies: - '@ai-sdk/gateway': 3.0.2(zod@4.2.1) - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.1(zod@4.2.1) + '@ai-sdk/gateway': 3.0.5(zod@4.3.4) + '@ai-sdk/provider': 3.0.1 + '@ai-sdk/provider-utils': 4.0.2(zod@4.3.4) '@opentelemetry/api': 1.9.0 - zod: 4.2.1 + zod: 4.3.4 ajv@6.12.6: dependencies: @@ -5870,22 +6010,25 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: + optional: true + baseline-browser-mapping@2.9.11: {} - better-auth@1.4.9(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + better-auth@1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@better-auth/core': 1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.9(@better-auth/core@1.4.9(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) + '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.4))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.4))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.1.7(zod@4.2.1) + better-call: 1.1.7(zod@4.3.4) defu: 6.1.4 jose: 6.1.3 kysely: 0.28.9 nanostores: 1.1.0 - zod: 4.2.1 + zod: 4.3.4 optionalDependencies: drizzle-kit: 0.31.8 drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) @@ -5894,14 +6037,14 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - better-call@1.1.7(zod@4.2.1): + better-call@1.1.7(zod@4.3.4): dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 set-cookie-parser: 2.7.2 optionalDependencies: - zod: 4.2.1 + zod: 4.3.4 brace-expansion@1.1.12: dependencies: @@ -5951,6 +6094,23 @@ snapshots: caniuse-lite@1.0.30001761: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.28.4 + '@types/raf': 3.4.3 + core-js: 3.47.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5982,6 +6142,8 @@ snapshots: - '@types/react' - '@types/react-dom' + codepage@1.15.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5992,6 +6154,9 @@ snapshots: convert-source-map@2.0.0: {} + core-js@3.47.0: + optional: true + cosmiconfig@7.1.0: dependencies: '@types/parse-json': 4.0.2 @@ -6000,12 +6165,19 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + csstype@3.2.3: {} d3-array@3.2.4: @@ -6136,6 +6308,11 @@ snapshots: dependencies: esutils: 2.0.3 + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + dotenv@17.2.3: {} drizzle-kit@0.31.8: @@ -6484,8 +6661,8 @@ snapshots: '@babel/parser': 7.28.5 eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.2.1 - zod-validation-error: 4.0.2(zod@4.2.1) + zod: 4.3.4 + zod-validation-error: 4.0.2(zod@4.3.4) transitivePeerDependencies: - supports-color @@ -6605,6 +6782,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fast-sha256@1.3.0: {} fastq@1.20.1: @@ -6615,6 +6798,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6646,6 +6831,8 @@ snapshots: dependencies: is-callable: 1.2.7 + frac@1.1.2: {} + framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion-dom: 12.23.23 @@ -6774,6 +6961,12 @@ snapshots: dependencies: parse-passwd: 1.0.0 + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + ignore@5.3.2: {} ignore@7.0.5: {} @@ -6799,6 +6992,8 @@ snapshots: internmap@2.0.3: {} + iobuffer@5.4.0: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -6963,6 +7158,21 @@ snapshots: json5@2.2.3: {} + jspdf-autotable@5.0.2(jspdf@4.0.0): + dependencies: + jspdf: 4.0.0 + + jspdf@4.0.0: + dependencies: + '@babel/runtime': 7.28.4 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.47.0 + dompurify: 3.3.1 + html2canvas: 1.4.1 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -7211,6 +7421,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7232,6 +7444,9 @@ snapshots: path-type@4.0.0: {} + performance-now@2.1.0: + optional: true + pg-cloudflare@1.2.7: optional: true @@ -7315,6 +7530,11 @@ snapshots: queue-microtask@1.2.3: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + react-day-picker@9.13.0(react@19.2.3): dependencies: '@date-fns/tz': 1.4.1 @@ -7408,6 +7628,9 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.13.11: + optional: true + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -7454,6 +7677,9 @@ snapshots: reusify@1.1.0: {} + rgbcolor@1.0.1: + optional: true + rou3@0.7.12: {} run-parallel@1.2.0: @@ -7595,8 +7821,15 @@ snapshots: sprintf-js@1.0.3: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stable-hash@0.0.5: {} + stackblur-canvas@2.7.0: + optional: true + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -7679,6 +7912,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-pathdata@6.0.3: + optional: true + svix@1.76.1: dependencies: '@stablelib/base64': 1.0.1 @@ -7694,6 +7930,11 @@ snapshots: tapable@2.3.0: {} + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + tiny-invariant@1.3.3: {} tinyglobby@0.2.15: @@ -7844,6 +8085,11 @@ snapshots: dependencies: react: 19.2.3 + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + uuid@10.0.0: {} vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -7921,14 +8167,28 @@ snapshots: dependencies: isexe: 2.0.0 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + xtend@4.0.2: {} y18n@5.0.8: {} @@ -7951,8 +8211,8 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.2.1): + zod-validation-error@4.0.2(zod@4.3.4): dependencies: - zod: 4.2.1 + zod: 4.3.4 - zod@4.2.1: {} + zod@4.3.4: {}