feat: adição de novos ícones SVG e configuração do ambiente

- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter
- Implementados ícones para modos claro e escuro do ChatGPT
- Criado script de inicialização para PostgreSQL com extensão pgcrypto
- Adicionado script de configuração de ambiente que faz backup do .env
- Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
Felipe Coutinho
2025-11-15 15:49:36 -03:00
commit ea0b8618e0
441 changed files with 53569 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiCalendarEventLine } from "@remixicon/react";
export const metadata = {
title: "Calendário | OpenSheets",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiCalendarEventLine />}
title="Calendário"
subtitle="Visualize lançamentos, vencimentos de cartões e boletos em um só lugar. Clique em uma data para detalhar ou criar um novo lançamento."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,59 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state para a página de calendário
* Layout: MonthPicker + Grade mensal 7x5/6 com dias e eventos
*/
export default function CalendarioLoading() {
return (
<main className="flex flex-col gap-3">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Calendar Container */}
<div className="rounded-2xl border p-4 space-y-4">
{/* Cabeçalho com dias da semana */}
<div className="grid grid-cols-7 gap-2 mb-4">
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
<div key={day} className="text-center">
<Skeleton className="h-4 w-12 mx-auto rounded-2xl bg-foreground/10" />
</div>
))}
</div>
{/* Grade de dias (6 semanas) */}
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 42 }).map((_, i) => (
<div
key={i}
className="min-h-[100px] rounded-2xl border p-2 space-y-2"
>
{/* Número do dia */}
<Skeleton className="h-5 w-6 rounded-2xl bg-foreground/10" />
{/* Indicadores de eventos (aleatório entre 0-3) */}
{i % 3 === 0 && (
<div className="space-y-1">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
{i % 5 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
</div>
)}
</div>
))}
</div>
{/* Legenda */}
<div className="flex flex-wrap items-center gap-4 pt-4 border-t">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="size-3 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,47 @@
import MonthPicker from "@/components/month-picker/month-picker";
import { getUserId } from "@/lib/auth/server";
import {
getSingleParam,
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period";
import { MonthlyCalendar } from "@/components/calendario/monthly-calendar";
import { fetchCalendarData } from "./data";
import type { CalendarPeriod } from "@/components/calendario/types";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
const resolvedParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedParams, "periodo");
const { period, monthName, year } = parsePeriodParam(periodoParam);
const calendarData = await fetchCalendarData({
userId,
period,
});
const calendarPeriod: CalendarPeriod = {
period,
monthName,
year,
};
return (
<main className="flex flex-col gap-3">
<MonthPicker />
<MonthlyCalendar
period={calendarPeriod}
events={calendarData.events}
formOptions={calendarData.formOptions}
/>
</main>
);
}