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,42 @@
"use client";
import { cn } from "@/lib/utils/ui";
import type { CalendarDay } from "@/components/calendario/types";
import { WEEK_DAYS_SHORT } from "@/components/calendario/utils";
import { DayCell } from "@/components/calendario/day-cell";
type CalendarGridProps = {
days: CalendarDay[];
onSelectDay: (day: CalendarDay) => void;
onCreateDay: (day: CalendarDay) => void;
};
export function CalendarGrid({
days,
onSelectDay,
onCreateDay,
}: CalendarGridProps) {
return (
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs px-2">
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="px-3 py-2 text-center">
{dayName}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-px bg-border/60 px-px pb-px pt-px">
{days.map((day) => (
<div
key={day.date}
className={cn("h-[150px] bg-card p-0.5", !day.isCurrentMonth && "")}
>
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { cn } from "@/lib/utils/ui";
import type { CalendarEvent } from "@/components/calendario/types";
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
const LEGEND_ITEMS: Array<{
type: CalendarEvent["type"];
label: string;
}> = [
{ type: "lancamento", label: "Lançamento financeiro" },
{ type: "boleto", label: "Boleto com vencimento" },
{ type: "cartao", label: "Vencimento de cartão" },
];
export function CalendarLegend() {
return (
<div className="flex flex-wrap gap-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-[11px] font-medium text-muted-foreground">
{LEGEND_ITEMS.map((item) => {
const style = EVENT_TYPE_STYLES[item.type];
return (
<span key={item.type} className="flex items-center gap-2">
<span
className={cn(
"size-2.5 rounded-full border border-black/10 dark:border-white/20",
style.dot
)}
/>
{item.label}
</span>
);
})}
</div>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { cn } from "@/lib/utils/ui";
import { RiAddLine } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
type DayCellProps = {
day: CalendarDay;
onSelect: (day: CalendarDay) => void;
onCreate: (day: CalendarDay) => void;
};
export const EVENT_TYPE_STYLES: Record<
CalendarEvent["type"],
{ wrapper: string; dot: string; accent?: string }
> = {
lancamento: {
wrapper: "bg-cyan-600 text-cyan-50 dark:bg-cyan-500/30 dark:text-cyan-200",
dot: "bg-cyan-600",
},
boleto: {
wrapper: "bg-red-600 text-red-50 dark:bg-red-500/30 dark:text-red-200",
dot: "bg-red-600",
},
cartao: {
wrapper:
"bg-violet-600 text-violet-50 dark:bg-violet-500/30 dark:text-violet-200",
dot: "bg-violet-600",
},
};
const eventStyles = EVENT_TYPE_STYLES;
const formatCurrencyValue = (value: number | null | undefined) =>
currencyFormatter.format(Math.abs(value ?? 0));
const formatAmount = (event: Extract<CalendarEvent, { type: "lancamento" }>) =>
formatCurrencyValue(event.lancamento.amount);
const buildEventLabel = (event: CalendarEvent) => {
switch (event.type) {
case "lancamento": {
return event.lancamento.name;
}
case "boleto": {
return event.lancamento.name;
}
case "cartao": {
return event.card.name;
}
default:
return "";
}
};
const buildEventComplement = (event: CalendarEvent) => {
switch (event.type) {
case "lancamento": {
return formatAmount(event);
}
case "boleto": {
return formatCurrencyValue(event.lancamento.amount);
}
case "cartao": {
if (event.card.totalDue !== null) {
return formatCurrencyValue(event.card.totalDue);
}
return null;
}
default:
return null;
}
};
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
const complement = buildEventComplement(event);
const label = buildEventLabel(event);
const style = eventStyles[event.type];
return (
<div
className={cn(
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs leading-tight",
style.wrapper
)}
>
<div className="flex min-w-0">
<span className="truncate">{label}</span>
</div>
{complement ? (
<span
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
>
{complement}
</span>
) : null}
</div>
);
};
export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
const previewEvents = day.events.slice(0, 3);
const hasOverflow = day.events.length > 3;
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " " || event.key === "Space") {
event.preventDefault();
onSelect(day);
}
};
const handleCreateClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onCreate(day);
};
return (
<div
role="button"
tabIndex={0}
onClick={() => onSelect(day)}
onKeyDown={handleKeyDown}
className={cn(
"flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10",
!day.isCurrentMonth && "opacity-60",
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary"
)}
>
<div className="flex items-start justify-between gap-2">
<span
className={cn(
"text-sm font-semibold leading-none",
day.isToday
? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center"
: "text-foreground/90"
)}
>
{day.label}
</span>
<button
type="button"
onClick={handleCreateClick}
className="flex size-6 items-center justify-center rounded-full border bg-muted text-muted-foreground transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
aria-label={`Criar lançamento em ${day.date}`}
>
<RiAddLine className="size-3.5" />
</button>
</div>
<div className="flex flex-1 flex-col gap-1.5">
{previewEvents.map((event) => (
<DayEventPreview key={event.id} event={event} />
))}
{hasOverflow ? (
<span className="text-xs font-medium text-primary/80">
+ ver mais
</span>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,229 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils/ui";
import { useMemo, type ReactNode } from "react";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { parseDateKey } from "@/components/calendario/utils";
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
type EventModalProps = {
open: boolean;
day: CalendarDay | null;
onClose: () => void;
onCreate: (date: string) => void;
};
const fullDateFormatter = new Intl.DateTimeFormat("pt-BR", {
day: "numeric",
month: "long",
year: "numeric",
});
const capitalize = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
const formatCurrency = (value: number, isReceita: boolean) => {
const formatted = currencyFormatter.format(value ?? 0);
return isReceita ? `+${formatted}` : formatted;
};
const EventCard = ({
children,
type,
}: {
children: ReactNode;
type: CalendarEvent["type"];
}) => {
const style = EVENT_TYPE_STYLES[type];
return (
<div className="flex gap-3 rounded-xl border border-border/60 bg-card/85 p-4">
<span
className={cn("mt-1 size-2.5 shrink-0 rounded-full", style.dot)}
aria-hidden
/>
<div className="flex flex-1 flex-col gap-2">{children}</div>
</div>
);
};
const renderLancamento = (
event: Extract<CalendarEvent, { type: "lancamento" }>
) => {
const isReceita = event.lancamento.transactionType === "Receita";
const subtitleParts = [
event.lancamento.categoriaName,
event.lancamento.paymentMethod,
event.lancamento.pagadorName,
].filter(Boolean);
return (
<EventCard type="lancamento">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-semibold">{event.lancamento.name}</span>
{subtitleParts.length ? (
<span className="text-xs text-muted-foreground">
{subtitleParts.join(" • ")}
</span>
) : null}
</div>
<span
className={cn(
"text-sm font-semibold",
isReceita ? "text-emerald-600" : "text-foreground"
)}
>
{formatCurrency(event.lancamento.amount, isReceita)}
</span>
</div>
<div className="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-0.5">
{capitalize(event.lancamento.transactionType)}
</span>
<span className="rounded-full bg-muted px-2 py-0.5">
{event.lancamento.condition}
</span>
<span className="rounded-full bg-muted px-2 py-0.5">
{event.lancamento.paymentMethod}
</span>
</div>
</EventCard>
);
};
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
const isPaid = Boolean(event.lancamento.isSettled);
const dueDate = event.lancamento.dueDate;
const formattedDueDate = dueDate
? new Intl.DateTimeFormat("pt-BR").format(new Date(dueDate))
: null;
return (
<EventCard type="boleto">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-semibold">{event.lancamento.name}</span>
<span className="text-xs text-muted-foreground">
Boleto{formattedDueDate ? ` • Vence em ${formattedDueDate}` : ""}
</span>
</div>
<span className="text-sm font-semibold text-foreground">
{currencyFormatter.format(event.lancamento.amount ?? 0)}
</span>
</div>
<span
className={cn(
"inline-flex w-fit items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
isPaid
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
: "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200"
)}
>
{isPaid ? "Pago" : "Pendente"}
</span>
</EventCard>
);
};
const renderCard = (event: Extract<CalendarEvent, { type: "cartao" }>) => (
<EventCard type="cartao">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-semibold">Cartão {event.card.name}</span>
<span className="text-xs text-muted-foreground">
Vencimento dia {event.card.dueDay}
</span>
</div>
{event.card.totalDue !== null ? (
<span className="text-sm font-semibold text-foreground">
{currencyFormatter.format(event.card.totalDue)}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-0.5">
Status: {event.card.status ?? "Indefinido"}
</span>
{event.card.closingDay ? (
<span className="rounded-full bg-muted px-2 py-0.5">
Fechamento dia {event.card.closingDay}
</span>
) : null}
</div>
</EventCard>
);
const renderEvent = (event: CalendarEvent) => {
switch (event.type) {
case "lancamento":
return renderLancamento(event);
case "boleto":
return renderBoleto(event);
case "cartao":
return renderCard(event);
default:
return null;
}
};
export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
const formattedDate = useMemo(() => {
if (!day) return "";
const parsed = parseDateKey(day.date);
return capitalize(fullDateFormatter.format(parsed));
}, [day]);
const handleCreate = () => {
if (!day) return;
onClose();
onCreate(day.date);
};
const description = day?.events.length
? "Confira os lançamentos e vencimentos cadastrados para este dia."
: "Nenhum lançamento encontrado para este dia. Você pode criar um novo lançamento agora.";
return (
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{formattedDate}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="max-h-[380px] space-y-3 overflow-y-auto pr-2">
{day?.events.length ? (
day.events.map((event) => (
<div key={event.id}>{renderEvent(event)}</div>
))
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-center text-sm text-muted-foreground">
Nenhum lançamento ou vencimento registrado. Clique em{" "}
<span className="font-medium text-primary">Novo lançamento</span>{" "}
para começar.
</div>
)}
</div>
<DialogFooter className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={!day}>
Novo lançamento
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
import type {
CalendarDay,
CalendarEvent,
CalendarFormOptions,
CalendarPeriod,
} from "@/components/calendario/types";
import { buildCalendarDays } from "@/components/calendario/utils";
import { CalendarGrid } from "@/components/calendario/calendar-grid";
import { CalendarLegend } from "@/components/calendario/calendar-legend";
import { EventModal } from "@/components/calendario/event-modal";
type MonthlyCalendarProps = {
period: CalendarPeriod;
events: CalendarEvent[];
formOptions: CalendarFormOptions;
};
const parsePeriod = (period: string) => {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
return { year, monthIndex: month - 1 };
};
export function MonthlyCalendar({
period,
events,
formOptions,
}: MonthlyCalendarProps) {
const { year, monthIndex } = useMemo(
() => parsePeriod(period.period),
[period.period]
);
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
events.forEach((event) => {
const list = map.get(event.date) ?? [];
list.push(event);
map.set(event.date, list);
});
return map;
}, [events]);
const days = useMemo(
() => buildCalendarDays({ year, monthIndex, events: eventsByDay }),
[eventsByDay, monthIndex, year]
);
const [selectedDay, setSelectedDay] = useState<CalendarDay | null>(null);
const [isModalOpen, setModalOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createDate, setCreateDate] = useState<string | null>(null);
const handleOpenCreate = useCallback((date: string) => {
setCreateDate(date);
setModalOpen(false);
setCreateOpen(true);
}, []);
const handleDaySelect = useCallback((day: CalendarDay) => {
setSelectedDay(day);
setModalOpen(true);
}, []);
const handleCreateFromCell = useCallback(
(day: CalendarDay) => {
handleOpenCreate(day.date);
},
[handleOpenCreate]
);
const handleModalClose = useCallback(() => {
setModalOpen(false);
setSelectedDay(null);
}, []);
const handleCreateDialogChange = useCallback((open: boolean) => {
setCreateOpen(open);
if (!open) {
setCreateDate(null);
}
}, []);
return (
<>
<div className="space-y-3">
<CalendarLegend />
<CalendarGrid
days={days}
onSelectDay={handleDaySelect}
onCreateDay={handleCreateFromCell}
/>
</div>
<EventModal
open={isModalOpen}
day={selectedDay}
onClose={handleModalClose}
onCreate={handleOpenCreate}
/>
<LancamentoDialog
mode="create"
open={createOpen}
onOpenChange={handleCreateDialogChange}
pagadorOptions={formOptions.pagadorOptions}
splitPagadorOptions={formOptions.splitPagadorOptions}
defaultPagadorId={formOptions.defaultPagadorId}
contaOptions={formOptions.contaOptions}
cartaoOptions={formOptions.cartaoOptions}
categoriaOptions={formOptions.categoriaOptions}
estabelecimentos={formOptions.estabelecimentos}
defaultPeriod={period.period}
defaultPurchaseDate={createDate ?? undefined}
/>
</>
);
}

View File

@@ -0,0 +1,61 @@
import type { LancamentoItem, SelectOption } from "@/components/lancamentos/types";
export type CalendarEventType = "lancamento" | "boleto" | "cartao";
export type CalendarEvent =
| {
id: string;
type: "lancamento";
date: string;
lancamento: LancamentoItem;
}
| {
id: string;
type: "boleto";
date: string;
lancamento: LancamentoItem;
}
| {
id: string;
type: "cartao";
date: string;
card: {
id: string;
name: string;
dueDay: string;
closingDay: string;
brand: string | null;
status: string;
logo: string | null;
totalDue: number | null;
};
};
export type CalendarPeriod = {
period: string;
monthName: string;
year: number;
};
export type CalendarDay = {
date: string;
label: string;
isCurrentMonth: boolean;
isToday: boolean;
events: CalendarEvent[];
};
export type CalendarFormOptions = {
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
};
export type CalendarData = {
events: CalendarEvent[];
formOptions: CalendarFormOptions;
};

View File

@@ -0,0 +1,61 @@
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
export const formatDateKey = (date: Date) => date.toISOString().slice(0, 10);
export const parseDateKey = (value: string) => {
const [yearStr, monthStr, dayStr] = value.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
const day = Number.parseInt(dayStr ?? "", 10);
return new Date(Date.UTC(year, (month ?? 1) - 1, day ?? 1));
};
const getWeekdayIndex = (date: Date) => {
const day = date.getUTCDay(); // 0 (domingo) - 6 (sábado)
// Ajusta para segunda-feira como primeiro dia
return day === 0 ? 6 : day - 1;
};
export const buildCalendarDays = ({
year,
monthIndex,
events,
}: {
year: number;
monthIndex: number;
events: Map<string, CalendarEvent[]>;
}): CalendarDay[] => {
const startOfMonth = new Date(Date.UTC(year, monthIndex, 1));
const offset = getWeekdayIndex(startOfMonth);
const startDate = new Date(Date.UTC(year, monthIndex, 1 - offset));
const totalCells = 42; // 6 semanas
const now = new Date();
const todayKey = formatDateKey(
new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()))
);
const days: CalendarDay[] = [];
for (let index = 0; index < totalCells; index += 1) {
const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + index);
const dateKey = formatDateKey(currentDate);
const isCurrentMonth = currentDate.getUTCMonth() === monthIndex;
const dateLabel = currentDate.getUTCDate().toString();
const eventsForDay = events.get(dateKey) ?? [];
days.push({
date: dateKey,
label: dateLabel,
isCurrentMonth,
isToday: dateKey === todayKey,
events: eventsForDay,
});
}
return days;
};
export const WEEK_DAYS_SHORT = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"];