diff --git a/app/(dashboard)/anotacoes/actions.ts b/app/(dashboard)/anotacoes/actions.ts index 2ab92d5..69e68ac 100644 --- a/app/(dashboard)/anotacoes/actions.ts +++ b/app/(dashboard)/anotacoes/actions.ts @@ -4,7 +4,7 @@ import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { anotacoes } from "@/db/schema"; import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; -import type { ActionResult } from "@/lib/actions/types"; +import type { ActionResult } from "@/lib/types/actions"; import { getUser } from "@/lib/auth/server"; import { db } from "@/lib/db"; import { uuidSchema } from "@/lib/schemas/common"; diff --git a/app/(dashboard)/calendario/data.ts b/app/(dashboard)/calendario/data.ts index adbd215..4196efa 100644 --- a/app/(dashboard)/calendario/data.ts +++ b/app/(dashboard)/calendario/data.ts @@ -1,9 +1,5 @@ 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"; import { cartoes, lancamentos } from "@/db/schema"; import { db } from "@/lib/db"; import { @@ -13,24 +9,13 @@ import { mapLancamentosData, } from "@/lib/lancamentos/page-helpers"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import type { CalendarData, CalendarEvent } from "@/lib/types/calendario"; +import { formatDateKey } from "@/lib/utils/calendario"; +import { parsePeriod } from "@/lib/utils/period"; 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; @@ -52,11 +37,12 @@ export const fetchCalendarData = async ({ userId, period, }: FetchCalendarDataParams): Promise => { - const { year, monthIndex } = parsePeriod(period); + const { year, month } = parsePeriod(period); + const monthIndex = month - 1; 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 rangeStartKey = formatDateKey(rangeStart); + const rangeEndKey = formatDateKey(rangeEnd); const [lancamentoRows, cardRows, filterSources] = await Promise.all([ db.query.lancamentos.findMany({ @@ -161,7 +147,7 @@ export const fetchCalendarData = async ({ } const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber); - const dueDateKey = toDateKey( + const dueDateKey = formatDateKey( new Date(Date.UTC(year, monthIndex, normalizedDay)), ); diff --git a/app/(dashboard)/calendario/page.tsx b/app/(dashboard)/calendario/page.tsx index 4bd0e12..c332fdd 100644 --- a/app/(dashboard)/calendario/page.tsx +++ b/app/(dashboard)/calendario/page.tsx @@ -1,11 +1,11 @@ import { MonthlyCalendar } from "@/components/calendario/monthly-calendar"; -import type { CalendarPeriod } from "@/components/calendario/types"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUserId } from "@/lib/auth/server"; import { getSingleParam, type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; +import type { CalendarPeriod } from "@/lib/types/calendario"; import { parsePeriodParam } from "@/lib/utils/period"; import { fetchCalendarData } from "./data"; diff --git a/app/(dashboard)/relatorios/tendencias/page.tsx b/app/(dashboard)/relatorios/tendencias/page.tsx index 5d7f5dd..882264c 100644 --- a/app/(dashboard)/relatorios/tendencias/page.tsx +++ b/app/(dashboard)/relatorios/tendencias/page.tsx @@ -8,7 +8,7 @@ import type { Categoria } from "@/db/schema"; import { getUserId } from "@/lib/auth/server"; import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data"; import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report"; -import type { CategoryReportFilters } from "@/lib/relatorios/types"; +import type { CategoryReportFilters } from "@/lib/types/relatorios"; import { validateDateRange } from "@/lib/relatorios/utils"; import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period"; import { fetchUserCategories } from "./data"; diff --git a/components/anotacoes/note-card.tsx b/components/anotacoes/note-card.tsx index 77ac5ec..cde103b 100644 --- a/components/anotacoes/note-card.tsx +++ b/components/anotacoes/note-card.tsx @@ -8,15 +8,11 @@ import { RiInboxUnarchiveLine, RiPencilLine, } from "@remixicon/react"; -import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { buildNoteDisplayTitle } from "@/lib/notes/formatters"; import { type Note, sortTasksByStatus } from "./types"; -const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { - dateStyle: "medium", -}); - interface NoteCardProps { note: Note; onEdit?: (note: Note) => void; @@ -34,20 +30,10 @@ export function NoteCard({ onArquivar, isArquivadas = false, }: NoteCardProps) { - const { displayTitle } = useMemo(() => { - const resolvedTitle = note.title.trim().length - ? note.title - : "Anotação sem título"; - - return { - displayTitle: resolvedTitle, - formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)), - }; - }, [note.createdAt, note.title]); - + const displayTitle = buildNoteDisplayTitle(note.title); const isTask = note.type === "tarefa"; const tasks = note.tasks || []; - const sortedTasks = useMemo(() => sortTasksByStatus(tasks), [tasks]); + const sortedTasks = sortTasksByStatus(tasks); const completedCount = tasks.filter((t) => t.completed).length; const totalCount = tasks.length; diff --git a/components/anotacoes/note-details-dialog.tsx b/components/anotacoes/note-details-dialog.tsx index ba6b12f..82b8f6d 100644 --- a/components/anotacoes/note-details-dialog.tsx +++ b/components/anotacoes/note-details-dialog.tsx @@ -1,7 +1,6 @@ "use client"; import { RiCheckLine } from "@remixicon/react"; -import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -13,14 +12,13 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + buildNoteDisplayTitle, + formatNoteCreatedAtLong, +} from "@/lib/notes/formatters"; import { Card } from "../ui/card"; import { type Note, sortTasksByStatus } from "./types"; -const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { - dateStyle: "long", - timeStyle: "short", -}); - interface NoteDetailsDialogProps { note: Note | null; open: boolean; @@ -32,26 +30,14 @@ export function NoteDetailsDialog({ open, onOpenChange, }: NoteDetailsDialogProps) { - const { formattedDate, displayTitle } = useMemo(() => { - if (!note) { - return { formattedDate: "", displayTitle: "" }; - } - - const title = note.title.trim().length ? note.title : "Anotação sem título"; - - return { - formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)), - displayTitle: title, - }; - }, [note]); - - const tasks = note?.tasks || []; - const sortedTasks = useMemo(() => sortTasksByStatus(tasks), [tasks]); - if (!note) { return null; } + const formattedDate = formatNoteCreatedAtLong(note.createdAt) ?? ""; + const displayTitle = buildNoteDisplayTitle(note.title); + const tasks = note.tasks || []; + const sortedTasks = sortTasksByStatus(tasks); const isTask = note.type === "tarefa"; const completedCount = tasks.filter((t) => t.completed).length; const totalCount = tasks.length; diff --git a/components/anotacoes/notes-page.tsx b/components/anotacoes/notes-page.tsx index e4cde9e..d4b610e 100644 --- a/components/anotacoes/notes-page.tsx +++ b/components/anotacoes/notes-page.tsx @@ -1,13 +1,13 @@ "use client"; import { RiAddCircleLine, RiTodoLine } from "@remixicon/react"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import { arquivarAnotacaoAction, deleteNoteAction, } from "@/app/(dashboard)/anotacoes/actions"; -import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; +import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog"; import { EmptyState } from "@/components/shared/empty-state"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -34,76 +34,78 @@ export function NotesPage({ notes, archivedNotes }: NotesPageProps) { const [arquivarOpen, setArquivarOpen] = useState(false); const [noteToArquivar, setNoteToArquivar] = useState(null); - const sortNotes = useCallback( - (list: Note[]) => - [...list].sort( + const sortedNotes = useMemo( + () => + [...notes].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ), - [], + [notes], ); - - const sortedNotes = useMemo(() => sortNotes(notes), [notes, sortNotes]); const sortedArchivedNotes = useMemo( - () => sortNotes(archivedNotes), - [archivedNotes, sortNotes], + () => + [...archivedNotes].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ), + [archivedNotes], ); const isArquivadas = activeTab === "arquivadas"; - const handleCreateOpenChange = useCallback((open: boolean) => { + const handleCreateOpenChange = (open: boolean) => { setCreateOpen(open); - }, []); + }; - const handleEditOpenChange = useCallback((open: boolean) => { + const handleEditOpenChange = (open: boolean) => { setEditOpen(open); if (!open) { setNoteToEdit(null); } - }, []); + }; - const handleDetailsOpenChange = useCallback((open: boolean) => { + const handleDetailsOpenChange = (open: boolean) => { setDetailsOpen(open); if (!open) { setNoteDetails(null); } - }, []); + }; - const handleRemoveOpenChange = useCallback((open: boolean) => { + const handleRemoveOpenChange = (open: boolean) => { setRemoveOpen(open); if (!open) { setNoteToRemove(null); } - }, []); + }; - const handleArquivarOpenChange = useCallback((open: boolean) => { + const handleArquivarOpenChange = (open: boolean) => { setArquivarOpen(open); if (!open) { setNoteToArquivar(null); } - }, []); + }; - const handleEditRequest = useCallback((note: Note) => { + const handleEditRequest = (note: Note) => { setNoteToEdit(note); setEditOpen(true); - }, []); + }; - const handleDetailsRequest = useCallback((note: Note) => { + const handleDetailsRequest = (note: Note) => { setNoteDetails(note); setDetailsOpen(true); - }, []); + }; - const handleRemoveRequest = useCallback((note: Note) => { + const handleRemoveRequest = (note: Note) => { setNoteToRemove(note); setRemoveOpen(true); - }, []); + }; - const handleArquivarRequest = useCallback((note: Note) => { + const handleArquivarRequest = (note: Note) => { setNoteToArquivar(note); setArquivarOpen(true); - }, []); + }; - const handleArquivarConfirm = useCallback(async () => { + const handleArquivarConfirm = async () => { if (!noteToArquivar) { return; } @@ -120,9 +122,9 @@ export function NotesPage({ notes, archivedNotes }: NotesPageProps) { toast.error(result.error); throw new Error(result.error); - }, [noteToArquivar, isArquivadas]); + }; - const handleRemoveConfirm = useCallback(async () => { + const handleRemoveConfirm = async () => { if (!noteToRemove) { return; } @@ -136,7 +138,7 @@ export function NotesPage({ notes, archivedNotes }: NotesPageProps) { toast.error(result.error); throw new Error(result.error); - }, [noteToRemove]); + }; const removeTitle = noteToRemove ? noteToRemove.title.trim().length diff --git a/components/calendario/calendar-grid.tsx b/components/calendario/calendar-grid.tsx index 894ad4c..93deb4c 100644 --- a/components/calendario/calendar-grid.tsx +++ b/components/calendario/calendar-grid.tsx @@ -2,8 +2,8 @@ import { DayCell } from "@/components/calendario/day-cell"; -import type { CalendarDay } from "@/components/calendario/types"; -import { WEEK_DAYS_SHORT } from "@/components/calendario/utils"; +import type { CalendarDay } from "@/lib/types/calendario"; +import { WEEK_DAYS_SHORT } from "@/lib/utils/calendario"; import { cn } from "@/lib/utils/ui"; type CalendarGridProps = { @@ -18,10 +18,10 @@ export function CalendarGrid({ onCreateDay, }: CalendarGridProps) { return ( -
+
{WEEK_DAYS_SHORT.map((dayName) => ( - + {dayName} ))} diff --git a/components/calendario/calendar-legend.tsx b/components/calendario/calendar-legend.tsx index 9b31829..fe0298a 100644 --- a/components/calendario/calendar-legend.tsx +++ b/components/calendario/calendar-legend.tsx @@ -1,8 +1,9 @@ "use client"; import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell"; -import type { CalendarEvent } from "@/components/calendario/types"; -import { cn } from "@/lib/utils/ui"; +import type { CalendarEvent } from "@/lib/types/calendario"; +import StatusDot from "../shared/status-dot"; +import { Card } from "../ui/card"; const LEGEND_ITEMS: Array<{ type?: CalendarEvent["type"]; @@ -17,17 +18,17 @@ const LEGEND_ITEMS: Array<{ export function CalendarLegend() { return ( -
+ {LEGEND_ITEMS.map((item, index) => { const dotColor = item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : ""); return ( - + {item.label} ); })} -
+ ); } diff --git a/components/calendario/day-cell.tsx b/components/calendario/day-cell.tsx index 52a55d3..1ec945e 100644 --- a/components/calendario/day-cell.tsx +++ b/components/calendario/day-cell.tsx @@ -2,7 +2,7 @@ import { RiAddLine } from "@remixicon/react"; import type { KeyboardEvent, MouseEvent } from "react"; -import type { CalendarDay, CalendarEvent } from "@/components/calendario/types"; +import type { CalendarDay, CalendarEvent } from "@/lib/types/calendario"; import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers"; import { cn } from "@/lib/utils/ui"; diff --git a/components/calendario/event-modal.tsx b/components/calendario/event-modal.tsx index f281c8b..a85e5bc 100644 --- a/components/calendario/event-modal.tsx +++ b/components/calendario/event-modal.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell"; -import type { CalendarDay, CalendarEvent } from "@/components/calendario/types"; +import MoneyValues from "@/components/shared/money-values"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -12,9 +12,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import type { CalendarDay, CalendarEvent } from "@/lib/types/calendario"; import { friendlyDate, parseLocalDateString } from "@/lib/utils/date"; +import { formatFinancialDateLabel } from "@/lib/utils/financial-dates"; import { cn } from "@/lib/utils/ui"; -import MoneyValues from "../money-values"; import { Badge } from "../ui/badge"; import { Card } from "../ui/card"; @@ -93,9 +94,11 @@ const renderLancamento = ( const renderBoleto = (event: Extract) => { const isPaid = Boolean(event.lancamento.isSettled); const dueDate = event.lancamento.dueDate; - const formattedDueDate = dueDate - ? new Intl.DateTimeFormat("pt-BR").format(new Date(dueDate)) - : null; + const dueDateLabel = formatFinancialDateLabel(dueDate, "Vence em", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); return ( @@ -106,9 +109,9 @@ const renderBoleto = (event: Extract) => { {event.lancamento.name}
- {formattedDueDate && ( + {dueDateLabel && ( - Vence em {formattedDueDate} + {dueDateLabel} )}
diff --git a/components/calendario/monthly-calendar.tsx b/components/calendario/monthly-calendar.tsx index beaee87..84da79b 100644 --- a/components/calendario/monthly-calendar.tsx +++ b/components/calendario/monthly-calendar.tsx @@ -1,17 +1,18 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { CalendarGrid } from "@/components/calendario/calendar-grid"; import { CalendarLegend } from "@/components/calendario/calendar-legend"; import { EventModal } from "@/components/calendario/event-modal"; +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 { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog"; +} from "@/lib/types/calendario"; +import { buildCalendarDays } from "@/lib/utils/calendario"; +import { parsePeriod } from "@/lib/utils/period"; type MonthlyCalendarProps = { period: CalendarPeriod; @@ -19,23 +20,13 @@ type MonthlyCalendarProps = { 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 { year, month } = parsePeriod(period.period); + const monthIndex = month - 1; const eventsByDay = useMemo(() => { const map = new Map(); @@ -57,35 +48,32 @@ export function MonthlyCalendar({ const [createOpen, setCreateOpen] = useState(false); const [createDate, setCreateDate] = useState(null); - const handleOpenCreate = useCallback((date: string) => { + const handleOpenCreate = (date: string) => { setCreateDate(date); setModalOpen(false); setCreateOpen(true); - }, []); + }; - const handleDaySelect = useCallback((day: CalendarDay) => { + const handleDaySelect = (day: CalendarDay) => { setSelectedDay(day); setModalOpen(true); - }, []); + }; - const handleCreateFromCell = useCallback( - (day: CalendarDay) => { - handleOpenCreate(day.date); - }, - [handleOpenCreate], - ); + const handleCreateFromCell = (day: CalendarDay) => { + handleOpenCreate(day.date); + }; - const handleModalClose = useCallback(() => { + const handleModalClose = () => { setModalOpen(false); setSelectedDay(null); - }, []); + }; - const handleCreateDialogChange = useCallback((open: boolean) => { + const handleCreateDialogChange = (open: boolean) => { setCreateOpen(open); if (!open) { setCreateDate(null); } - }, []); + }; return ( <> diff --git a/components/calendario/types.ts b/components/calendario/types.ts deleted file mode 100644 index cc65454..0000000 --- a/components/calendario/types.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { - LancamentoItem, - SelectOption, -} from "@/components/lancamentos/types"; - -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; -}; diff --git a/components/insights/insights-grid.tsx b/components/insights/insights-grid.tsx index c3543a7..b67ec06 100644 --- a/components/insights/insights-grid.tsx +++ b/components/insights/insights-grid.tsx @@ -6,14 +6,13 @@ import { RiLightbulbLine, RiRocketLine, } from "@remixicon/react"; -import { format } from "date-fns"; -import { ptBR } from "date-fns/locale"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { InsightCategoryId, InsightsResponse, } from "@/lib/schemas/insights"; import { INSIGHT_CATEGORIES } from "@/lib/schemas/insights"; +import { displayPeriod } from "@/lib/utils/period"; import { cn } from "@/lib/utils/ui"; interface InsightsGridProps { @@ -50,12 +49,7 @@ const CATEGORY_COLORS: Record< }; export function InsightsGrid({ insights }: InsightsGridProps) { - // Formatar o período para exibição - const [year, month] = insights.month.split("-"); - const periodDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1); - const formattedPeriod = format(periodDate, "MMMM 'de' yyyy", { - locale: ptBR, - }); + const formattedPeriod = displayPeriod(insights.month); return (
diff --git a/components/relatorios/cartoes/card-category-breakdown.tsx b/components/relatorios/cartoes/card-category-breakdown.tsx index fc037e2..b9e26ff 100644 --- a/components/relatorios/cartoes/card-category-breakdown.tsx +++ b/components/relatorios/cartoes/card-category-breakdown.tsx @@ -2,10 +2,10 @@ import { RiPieChartLine } from "@remixicon/react"; import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; type CardCategoryBreakdownProps = { diff --git a/components/relatorios/cartoes/card-invoice-status.tsx b/components/relatorios/cartoes/card-invoice-status.tsx index 12486f7..2a28aa9 100644 --- a/components/relatorios/cartoes/card-invoice-status.tsx +++ b/components/relatorios/cartoes/card-invoice-status.tsx @@ -10,36 +10,14 @@ import { } from "@/components/ui/tooltip"; import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; import { cn } from "@/lib/utils"; +import { formatCurrency } from "@/lib/utils/currency"; +import { formatPeriodMonthShort } from "@/lib/utils/period"; type CardInvoiceStatusProps = { data: CardDetailData["invoiceStatus"]; }; -const monthLabels = [ - "Jan", - "Fev", - "Mar", - "Abr", - "Mai", - "Jun", - "Jul", - "Ago", - "Set", - "Out", - "Nov", - "Dez", -]; - export function CardInvoiceStatus({ data }: CardInvoiceStatusProps) { - const formatCurrency = (value: number) => { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); - }; - const getStatusColor = (status: string | null) => { switch (status) { case "pago": @@ -66,11 +44,6 @@ export function CardInvoiceStatus({ data }: CardInvoiceStatusProps) { } }; - const formatPeriodShort = (period: string) => { - const [, month] = period.split("-"); - return monthLabels[parseInt(month, 10) - 1]; - }; - return ( @@ -93,13 +66,16 @@ export function CardInvoiceStatus({ data }: CardInvoiceStatusProps) { )} /> - {formatPeriodShort(invoice.period)} + {formatPeriodMonthShort(invoice.period)}

- {formatCurrency(invoice.amount)} + {formatCurrency(invoice.amount, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + })}

{getStatusLabel(invoice.status)}

diff --git a/components/relatorios/cartoes/card-top-expenses.tsx b/components/relatorios/cartoes/card-top-expenses.tsx index 71045fd..0593e8c 100644 --- a/components/relatorios/cartoes/card-top-expenses.tsx +++ b/components/relatorios/cartoes/card-top-expenses.tsx @@ -1,11 +1,11 @@ "use client"; import { RiShoppingBag3Line } from "@remixicon/react"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; type CardTopExpensesProps = { diff --git a/components/relatorios/cartoes/card-usage-chart.tsx b/components/relatorios/cartoes/card-usage-chart.tsx index f5d3206..aa89606 100644 --- a/components/relatorios/cartoes/card-usage-chart.tsx +++ b/components/relatorios/cartoes/card-usage-chart.tsx @@ -16,7 +16,10 @@ import { ChartContainer, ChartTooltip, } from "@/components/ui/chart"; +import { resolveLogoSrc } from "@/lib/logo"; import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; +import { formatCurrency, formatCurrencyCompact } from "@/lib/utils/currency"; +import { formatPercentage } from "@/lib/utils/percentage"; type CardUsageChartProps = { data: CardDetailData["monthlyUsage"]; @@ -34,48 +37,14 @@ const chartConfig = { }, } satisfies ChartConfig; -const resolveLogoPath = (logo: string | null) => { - if (!logo) return null; - if ( - logo.startsWith("http://") || - logo.startsWith("https://") || - logo.startsWith("data:") - ) { - return logo; - } - return logo.startsWith("/") ? logo : `/logos/${logo}`; -}; - export function CardUsageChart({ data, limit, card }: CardUsageChartProps) { - const formatCurrency = (value: number) => { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); - }; - - const formatCurrencyCompact = (value: number) => { - if (Math.abs(value) >= 1000) { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - notation: "compact", - }).format(value); - } - return formatCurrency(value); - }; - // Always show last 12 months const chartData = data.slice(-12).map((item) => ({ month: item.periodLabel, amount: item.amount, })); - const logoPath = resolveLogoPath(card.logo); + const logoPath = resolveLogoSrc(card.logo); return ( @@ -124,7 +93,17 @@ export function CardUsageChart({ data, limit, card }: CardUsageChartProps) { axisLine={false} tickMargin={8} className="text-xs" - tickFormatter={formatCurrencyCompact} + tickFormatter={(value) => + Math.abs(Number(value)) >= 1000 + ? formatCurrencyCompact(Number(value), { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + }) + : formatCurrency(Number(value), { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + }) + } /> {limit > 0 && ( - {formatCurrency(value)} + {formatCurrency(value, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + })}
{limit > 0 && ( @@ -168,7 +150,10 @@ export function CardUsageChart({ data, limit, card }: CardUsageChartProps) { % do Limite - {usagePercent.toFixed(0)}% + {formatPercentage(usagePercent, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + })}
)} diff --git a/components/relatorios/cartoes/cards-overview.tsx b/components/relatorios/cartoes/cards-overview.tsx index b83bd79..b3eb1b6 100644 --- a/components/relatorios/cartoes/cards-overview.tsx +++ b/components/relatorios/cartoes/cards-overview.tsx @@ -4,59 +4,24 @@ import { RiBankCard2Line } from "@remixicon/react"; import Image from "next/image"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Card, CardContent } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; +import { resolveCardBrandAsset } from "@/lib/cartoes/brand-assets"; +import { resolveLogoSrc } from "@/lib/logo"; import type { CartoesReportData } from "@/lib/relatorios/cartoes-report"; import { cn } from "@/lib/utils"; +import { formatCurrency } from "@/lib/utils/currency"; +import { formatPercentage } from "@/lib/utils/percentage"; type CardsOverviewProps = { data: CartoesReportData; }; -const BRAND_ASSETS: Record = { - visa: "/bandeiras/visa.svg", - mastercard: "/bandeiras/mastercard.svg", - amex: "/bandeiras/amex.svg", - american: "/bandeiras/amex.svg", - elo: "/bandeiras/elo.svg", - hipercard: "/bandeiras/hipercard.svg", - hiper: "/bandeiras/hipercard.svg", -}; - -const resolveBrandAsset = (brand: string | null) => { - if (!brand) return null; - const normalized = brand.trim().toLowerCase(); - const match = ( - Object.keys(BRAND_ASSETS) as Array - ).find((entry) => normalized.includes(entry)); - return match ? BRAND_ASSETS[match] : null; -}; - -const resolveLogoPath = (logo: string | null) => { - if (!logo) return null; - if ( - logo.startsWith("http://") || - logo.startsWith("https://") || - logo.startsWith("data:") - ) { - return logo; - } - return logo.startsWith("/") ? logo : `/logos/${logo}`; -}; - export function CardsOverview({ data }: CardsOverviewProps) { const searchParams = useSearchParams(); const periodoParam = searchParams.get("periodo"); - const formatCurrency = (value: number) => - new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); - const getUsageColor = (percent: number) => { if (percent < 50) return "bg-success"; if (percent < 80) return "bg-warning"; @@ -107,7 +72,10 @@ export function CardsOverview({ data }: CardsOverviewProps) { /> ) : (

- {card.value.toFixed(0)}% + {formatPercentage(card.value, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + })}

)} @@ -120,8 +88,8 @@ export function CardsOverview({ data }: CardsOverviewProps) { {/* Cards list */}
{data.cards.map((card) => { - const logoPath = resolveLogoPath(card.logo); - const brandAsset = resolveBrandAsset(card.brand); + const logoPath = resolveLogoSrc(card.logo); + const brandAsset = resolveCardBrandAsset(card.brand); const isSelected = data.selectedCard?.card.id === card.id; return ( @@ -174,7 +142,10 @@ export function CardsOverview({ data }: CardsOverviewProps) { )} /> - {card.usagePercent.toFixed(0)}% + {formatPercentage(card.usagePercent, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + })}
diff --git a/components/relatorios/category-report-cards.tsx b/components/relatorios/category-report-cards.tsx index b2de966..f359f63 100644 --- a/components/relatorios/category-report-cards.tsx +++ b/components/relatorios/category-report-cards.tsx @@ -8,7 +8,7 @@ import { formatCurrency } from "@/lib/lancamentos/formatting-helpers"; import type { CategoryReportData, CategoryReportItem, -} from "@/lib/relatorios/types"; +} from "@/lib/types/relatorios"; import { formatPeriodLabel } from "@/lib/relatorios/utils"; import { formatPeriodForUrl } from "@/lib/utils/period"; import { CategoryCell } from "./category-cell"; @@ -20,11 +20,18 @@ interface CategoryReportCardsProps { interface CategoryCardProps { category: CategoryReportItem; periods: string[]; + periodCount: number; colorIndex: number; } -function CategoryCard({ category, periods, colorIndex }: CategoryCardProps) { +function CategoryCard({ + category, + periods, + periodCount, + colorIndex, +}: CategoryCardProps) { const periodParam = formatPeriodForUrl(periods[periods.length - 1]); + const averageMonthlyTotal = category.total / periodCount; return ( @@ -65,6 +72,10 @@ function CategoryCard({ category, periods, colorIndex }: CategoryCardProps) { ); })} +
+ Média mensal + {formatCurrency(averageMonthlyTotal)} +
Total {formatCurrency(category.total)} @@ -78,6 +89,7 @@ interface SectionProps { title: string; categories: CategoryReportItem[]; periods: string[]; + periodCount: number; colorIndexOffset: number; total: number; } @@ -86,6 +98,7 @@ function Section({ title, categories, periods, + periodCount, colorIndexOffset, total, }: SectionProps) { @@ -93,21 +106,29 @@ function Section({ return null; } + const averageMonthlyTotal = total / periodCount; + return (
{title} - - {formatCurrency(total)} - +
+ + {formatCurrency(total)} + + + Média: {formatCurrency(averageMonthlyTotal)} + +
{categories.map((category, index) => ( ))} @@ -117,6 +138,7 @@ function Section({ export function CategoryReportCards({ data }: CategoryReportCardsProps) { const { categories, periods } = data; + const periodCount = Math.max(periods.length, 1); // Separate categories by type and calculate totals const { receitas, despesas, receitasTotal, despesasTotal } = useMemo(() => { @@ -145,6 +167,7 @@ export function CategoryReportCards({ data }: CategoryReportCardsProps) { title="Despesas" categories={despesas} periods={periods} + periodCount={periodCount} colorIndexOffset={0} total={despesasTotal} /> @@ -154,6 +177,7 @@ export function CategoryReportCards({ data }: CategoryReportCardsProps) { title="Receitas" categories={receitas} periods={periods} + periodCount={periodCount} colorIndexOffset={despesas.length} total={receitasTotal} /> diff --git a/components/relatorios/category-report-export.tsx b/components/relatorios/category-report-export.tsx index 38b26ae..9e9a697 100644 --- a/components/relatorios/category-report-export.tsx +++ b/components/relatorios/category-report-export.tsx @@ -19,11 +19,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { formatCurrency } from "@/lib/lancamentos/formatting-helpers"; -import type { CategoryReportData } from "@/lib/relatorios/types"; import { formatPercentageChange, formatPeriodLabel, } from "@/lib/relatorios/utils"; +import type { CategoryReportData } from "@/lib/types/relatorios"; +import { formatDateTime } from "@/lib/utils/date"; import { getPrimaryPdfColor, loadExportLogoDataUrl, @@ -201,8 +202,8 @@ export function CategoryReportExport({ const doc = new jsPDF({ orientation: "landscape" }); const primaryColor = getPrimaryPdfColor(); const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([ - loadExportLogoDataUrl("/logo_small.png"), - loadExportLogoDataUrl("/logo_text.png"), + loadExportLogoDataUrl("/imagens/logo_small.png"), + loadExportLogoDataUrl("/imagens/logo_text.png"), ]); let brandingEndX = 14; @@ -232,7 +233,13 @@ export function CategoryReportExport({ 22, ); doc.text( - `Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, + `Gerado em: ${ + formatDateTime(new Date(), { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) ?? "—" + }`, titleX, 27, ); diff --git a/components/relatorios/category-report-filters.tsx b/components/relatorios/category-report-filters.tsx index e4c9e3e..ae2d3d9 100644 --- a/components/relatorios/category-report-filters.tsx +++ b/components/relatorios/category-report-filters.tsx @@ -5,8 +5,6 @@ import { RiCheckLine, RiExpandUpDownLine, } from "@remixicon/react"; -import { format } from "date-fns"; -import { ptBR } from "date-fns/locale"; import type { ReactNode } from "react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -26,6 +24,13 @@ import { } from "@/components/ui/popover"; import { validateDateRange } from "@/lib/relatorios/utils"; import { getIconComponent } from "@/lib/utils/icons"; +import { + addMonthsToPeriod, + dateToPeriod, + formatShortPeriodLabel, + getCurrentPeriod, + periodToDate, +} from "@/lib/utils/period"; import type { CategoryReportFiltersProps } from "./types"; /** @@ -44,25 +49,6 @@ export function CategoryReportFilters({ 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; @@ -126,10 +112,8 @@ export function CategoryReportFilters({ // 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); + const currentPeriod = getCurrentPeriod(); + const startPeriod = addMonthsToPeriod(currentPeriod, -5); onFiltersChange({ selectedCategories: [], @@ -138,27 +122,19 @@ export function CategoryReportFilters({ }); }; - // Validate date range - const validation = useMemo(() => { - if (!filters.startPeriod || !filters.endPeriod) { - return { isValid: true }; - } - return validateDateRange(filters.startPeriod, filters.endPeriod); - }, [filters.startPeriod, filters.endPeriod]); + const validation = + !filters.startPeriod || !filters.endPeriod + ? { isValid: true } + : validateDateRange(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]); + const selectedText = + selectedCategories.length === 0 + ? "Categoria" + : selectedCategories.length === categories.length + ? "Todas" + : selectedCategories.length === 1 + ? selectedCategories[0].name + : `${selectedCategories.length} selecionadas`; return (
@@ -261,7 +237,7 @@ export function CategoryReportFilters({ disabled={isLoading} > - {formatMonthYear(filters.startPeriod)} + {formatShortPeriodLabel(filters.startPeriod)} @@ -281,7 +257,7 @@ export function CategoryReportFilters({ disabled={isLoading} > - {formatMonthYear(filters.endPeriod)} + {formatShortPeriodLabel(filters.endPeriod)} diff --git a/components/relatorios/category-report-page.tsx b/components/relatorios/category-report-page.tsx index 6033146..36df08b 100644 --- a/components/relatorios/category-report-page.tsx +++ b/components/relatorios/category-report-page.tsx @@ -6,13 +6,13 @@ import { RiTable2, } from "@remixicon/react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useMemo, useState, useTransition } from "react"; +import { useEffect, useRef, useState, useTransition } from "react"; import { EmptyState } from "@/components/shared/empty-state"; import { CategoryReportSkeleton } from "@/components/shared/skeletons/category-report-skeleton"; import { Card } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data"; -import type { CategoryReportData } from "@/lib/relatorios/types"; +import type { CategoryReportData } from "@/lib/types/relatorios"; import { CategoryReportCards } from "./category-report-cards"; import { CategoryReportChart } from "./category-report-chart"; import { CategoryReportExport } from "./category-report-export"; @@ -38,76 +38,62 @@ export function CategoryReportPage({ 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 debounceTimerRef = useRef | null>(null); - const handleFiltersChange = useCallback( - (newFilters: FilterState) => { - setFilters(newFilters); - - // Clear existing timer - if (debounceTimer) { - clearTimeout(debounceTimer); + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); } + }; + }, []); - // Set new debounced timer (300ms) - const timer = setTimeout(() => { - startTransition(() => { - // Build new URL with query params - const params = new URLSearchParams(searchParams.toString()); + const handleFiltersChange = (newFilters: FilterState) => { + setFilters(newFilters); - params.set("inicio", newFilters.startPeriod); - params.set("fim", newFilters.endPeriod); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } - if (newFilters.selectedCategories.length > 0) { - params.set("categorias", newFilters.selectedCategories.join(",")); - } else { - params.delete("categorias"); - } + debounceTimerRef.current = setTimeout(() => { + startTransition(() => { + const params = new URLSearchParams(searchParams.toString()); - // Preserve current tab - const currentTab = searchParams.get("aba"); - if (currentTab) { - params.set("aba", currentTab); - } + params.set("inicio", newFilters.startPeriod); + params.set("fim", newFilters.endPeriod); - // Navigate with new params (this will trigger server component re-render) - router.push(`?${params.toString()}`, { scroll: false }); - }); - }, 300); + if (newFilters.selectedCategories.length > 0) { + params.set("categorias", newFilters.selectedCategories.join(",")); + } else { + params.delete("categorias"); + } - setDebounceTimer(timer); - }, - [debounceTimer, router, searchParams], - ); + const currentTab = searchParams.get("aba"); + if (currentTab) { + params.set("aba", currentTab); + } - // 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], - ); + router.push(`?${params.toString()}`, { scroll: false }); + }); + }, 300); + }; - // Update data when initialData changes (from server) - useMemo(() => { - setData(initialData); - }, [initialData]); + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("aba", value); + router.push(`?${params.toString()}`, { scroll: false }); + }; // Check if no categories are available const hasNoCategories = categories.length === 0; // Check if no data in period - const hasNoData = data.categories.length === 0 && !hasNoCategories; + const hasNoData = initialData.categories.length === 0 && !hasNoCategories; return (
@@ -116,7 +102,9 @@ export function CategoryReportPage({ categories={categories} filters={filters} onFiltersChange={handleFiltersChange} - exportButton={} + exportButton={ + + } /> {/* Loading State */} @@ -180,11 +168,11 @@ export function CategoryReportPage({ {/* Desktop Table */}
- +
{/* Mobile Cards */} - +
diff --git a/components/relatorios/category-report-table.tsx b/components/relatorios/category-report-table.tsx index 807b0fe..c72a1b3 100644 --- a/components/relatorios/category-report-table.tsx +++ b/components/relatorios/category-report-table.tsx @@ -4,7 +4,7 @@ import { useMemo } from "react"; import type { CategoryReportData, CategoryReportItem, -} from "@/lib/relatorios/types"; +} from "@/lib/types/relatorios"; import { CategoryTable } from "./category-table"; interface CategoryReportTableProps { diff --git a/components/relatorios/category-table.tsx b/components/relatorios/category-table.tsx index b270082..dfd9e7a 100644 --- a/components/relatorios/category-table.tsx +++ b/components/relatorios/category-table.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useMemo } from "react"; import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; +import StatusDot from "@/components/shared/status-dot"; import { Table, TableBody, @@ -13,10 +14,9 @@ import { TableRow, } from "@/components/ui/table"; import { formatCurrency } from "@/lib/lancamentos/formatting-helpers"; -import type { CategoryReportItem } from "@/lib/relatorios/types"; import { formatPeriodLabel } from "@/lib/relatorios/utils"; +import type { CategoryReportItem } from "@/lib/types/relatorios"; import { formatPeriodForUrl } from "@/lib/utils/period"; -import DotIcon from "../dot-icon"; import { Card } from "../ui/card"; import { CategoryCell } from "./category-cell"; @@ -37,6 +37,7 @@ export function CategoryTable({ const sectionTotals = useMemo(() => { const totalsMap = new Map(); let grandTotal = 0; + const periodCount = Math.max(periods.length, 1); for (const category of categories) { grandTotal += category.total; @@ -47,7 +48,11 @@ export function CategoryTable({ } } - return { totalsMap, grandTotal }; + return { + totalsMap, + grandTotal, + averageMonthlyTotal: grandTotal / periodCount, + }; }, [categories, periods]); if (categories.length === 0) { @@ -59,7 +64,7 @@ export function CategoryTable({ - + Categoria {periods.map((period) => ( @@ -70,6 +75,9 @@ export function CategoryTable({ {formatPeriodLabel(period)} ))} + + Média + Total @@ -85,7 +93,7 @@ export function CategoryTable({
- ); })} + + {formatCurrency(category.total / Math.max(periods.length, 1))} + {formatCurrency(category.total)} @@ -140,6 +151,9 @@ export function CategoryTable({ ); })} + + {formatCurrency(sectionTotals.averageMonthlyTotal)} + {formatCurrency(sectionTotals.grandTotal)} diff --git a/components/relatorios/estabelecimentos/establishments-list.tsx b/components/relatorios/estabelecimentos/establishments-list.tsx index dc2d5a1..af5c189 100644 --- a/components/relatorios/estabelecimentos/establishments-list.tsx +++ b/components/relatorios/estabelecimentos/establishments-list.tsx @@ -1,11 +1,11 @@ "use client"; import { RiStore2Line } from "@remixicon/react"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; import type { TopEstabelecimentosData } from "@/lib/relatorios/estabelecimentos/fetch-data"; type EstablishmentsListProps = { diff --git a/components/relatorios/estabelecimentos/summary-cards.tsx b/components/relatorios/estabelecimentos/summary-cards.tsx index b35f231..e124321 100644 --- a/components/relatorios/estabelecimentos/summary-cards.tsx +++ b/components/relatorios/estabelecimentos/summary-cards.tsx @@ -6,7 +6,7 @@ import { RiRepeatLine, RiStore2Line, } from "@remixicon/react"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Card, CardContent } from "@/components/ui/card"; import type { TopEstabelecimentosData } from "@/lib/relatorios/estabelecimentos/fetch-data"; diff --git a/components/relatorios/estabelecimentos/top-categories.tsx b/components/relatorios/estabelecimentos/top-categories.tsx index fc1cdd2..d61ba37 100644 --- a/components/relatorios/estabelecimentos/top-categories.tsx +++ b/components/relatorios/estabelecimentos/top-categories.tsx @@ -2,10 +2,10 @@ import { RiPriceTag3Line } from "@remixicon/react"; import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; +import { WidgetEmptyState } from "@/components/shared/widget-empty-state"; import type { TopEstabelecimentosData } from "@/lib/relatorios/estabelecimentos/fetch-data"; type TopCategoriesProps = { diff --git a/lib/notes/formatters.ts b/lib/notes/formatters.ts new file mode 100644 index 0000000..3cedd95 --- /dev/null +++ b/lib/notes/formatters.ts @@ -0,0 +1,51 @@ +type NoteTasksSummaryInput = { + type: string; + tasks?: Array<{ completed: boolean }> | null; +}; + +const NOTE_CREATED_AT_FORMATTER = new Intl.DateTimeFormat("pt-BR", { + dateStyle: "medium", +}); + +const NOTE_CREATED_AT_LONG_FORMATTER = new Intl.DateTimeFormat("pt-BR", { + dateStyle: "long", + timeStyle: "short", +}); + +const parseNoteDate = (value: string | Date | null | undefined) => { + if (!value) { + return null; + } + + const parsed = value instanceof Date ? value : new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +export const buildNoteDisplayTitle = (value: string | null | undefined) => { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : "Anotação sem título"; +}; + +export const getNoteTasksSummary = (note: NoteTasksSummaryInput) => { + if (note.type !== "tarefa") { + return "Nota"; + } + + const tasks = note.tasks ?? []; + const completed = tasks.filter((task) => task.completed).length; + return `${completed}/${tasks.length} concluídas`; +}; + +export const formatNoteCreatedAt = ( + value: string | Date | null | undefined, +) => { + const parsed = parseNoteDate(value); + return parsed ? NOTE_CREATED_AT_FORMATTER.format(parsed) : null; +}; + +export const formatNoteCreatedAtLong = ( + value: string | Date | null | undefined, +) => { + const parsed = parseNoteDate(value); + return parsed ? NOTE_CREATED_AT_LONG_FORMATTER.format(parsed) : null; +}; diff --git a/lib/relatorios/cartoes-report.ts b/lib/relatorios/cartoes-report.ts index 329e6cd..7984f55 100644 --- a/lib/relatorios/cartoes-report.ts +++ b/lib/relatorios/cartoes-report.ts @@ -20,8 +20,13 @@ import { } from "@/db/schema"; import { db } from "@/lib/db"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { formatDateOnly } from "@/lib/utils/date"; import { safeToNumber } from "@/lib/utils/number"; -import { getPreviousPeriod } from "@/lib/utils/period"; +import { + buildPeriodWindow, + formatCompactPeriodLabel, + getPreviousPeriod, +} from "@/lib/utils/period"; const DESPESA = "Despesa"; @@ -75,6 +80,49 @@ export type CartoesReportData = { selectedCard: CardDetailData | null; }; +type CardRow = { + id: string; + name: string; + brand: string | null; + logo: string | null; + limit: unknown; + status: string; +}; + +type CardUsageRow = { + cartaoId: string | null; + totalAmount: unknown; +}; + +type MonthlyUsageRow = { + period: string; + totalAmount: unknown; +}; + +type CategoryAmountRow = { + categoriaId: string | null; + totalAmount: unknown; +}; + +type CategoryInfoRow = { + id: string; + name: string; + icon: string | null; +}; + +type TopExpenseRow = { + id: string; + name: string; + amount: unknown; + purchaseDate: Date | string | null; + categoriaId: string | null; +}; + +type InvoiceStatusRow = { + period: string; + status: string | null; +}; + export async function fetchCartoesReportData( userId: string, currentPeriod: string, @@ -83,7 +131,7 @@ export async function fetchCartoesReportData( const previousPeriod = getPreviousPeriod(currentPeriod); // Fetch all active cards (not inactive) - const allCards = await db + const allCards = (await db .select({ id: cartoes.id, name: cartoes.name, @@ -95,7 +143,7 @@ export async function fetchCartoesReportData( .from(cartoes) .where( and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))), - ); + )) as CardRow[]; if (allCards.length === 0) { return { @@ -110,7 +158,7 @@ export async function fetchCartoesReportData( const cardIds = allCards.map((c) => c.id); // Fetch current period usage by card (recorrente só conta quando a data da ocorrência já passou) - const currentUsageData = await db + const currentUsageData = (await db .select({ cartaoId: lancamentos.cartaoId, totalAmount: sum(lancamentos.amount).as("total"), @@ -130,10 +178,10 @@ export async function fetchCartoesReportData( ), ), ) - .groupBy(lancamentos.cartaoId); + .groupBy(lancamentos.cartaoId)) as CardUsageRow[]; // Fetch previous period usage by card - const previousUsageData = await db + const previousUsageData = (await db .select({ cartaoId: lancamentos.cartaoId, totalAmount: sum(lancamentos.amount).as("total"), @@ -149,7 +197,7 @@ export async function fetchCartoesReportData( inArray(lancamentos.cartaoId, cardIds), ), ) - .groupBy(lancamentos.cartaoId); + .groupBy(lancamentos.cartaoId)) as CardUsageRow[]; const currentUsageMap = new Map(); for (const row of currentUsageData) { @@ -246,32 +294,12 @@ async function fetchCardDetail( currentPeriod: string, ): Promise { // Build period range for last 12 months - const periods: string[] = []; - let p = currentPeriod; - for (let i = 0; i < 12; i++) { - periods.unshift(p); - p = getPreviousPeriod(p); - } + const periods = buildPeriodWindow(currentPeriod, 12); const startPeriod = periods[0]; - const monthLabels = [ - "Jan", - "Fev", - "Mar", - "Abr", - "Mai", - "Jun", - "Jul", - "Ago", - "Set", - "Out", - "Nov", - "Dez", - ]; - // Fetch monthly usage - const monthlyData = await db + const monthlyData = (await db .select({ period: lancamentos.period, totalAmount: sum(lancamentos.amount).as("total"), @@ -289,20 +317,19 @@ async function fetchCardDetail( ), ) .groupBy(lancamentos.period) - .orderBy(lancamentos.period); + .orderBy(lancamentos.period)) as MonthlyUsageRow[]; const monthlyUsage = periods.map((period) => { const data = monthlyData.find((d) => d.period === period); - const [year, month] = period.split("-"); return { period, - periodLabel: `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`, + periodLabel: formatCompactPeriodLabel(period), amount: Math.abs(safeToNumber(data?.totalAmount)), }; }); // Fetch category breakdown for current period - const categoryData = await db + const categoryData = (await db .select({ categoriaId: lancamentos.categoriaId, totalAmount: sum(lancamentos.amount).as("total"), @@ -318,7 +345,7 @@ async function fetchCardDetail( eq(lancamentos.transactionType, DESPESA), ), ) - .groupBy(lancamentos.categoriaId); + .groupBy(lancamentos.categoriaId)) as CategoryAmountRow[]; // Fetch category names const categoryIds = categoryData @@ -327,15 +354,15 @@ async function fetchCardDetail( const categoryNames = categoryIds.length > 0 - ? await db + ? ((await db .select({ id: categorias.id, name: categorias.name, icon: categorias.icon, }) .from(categorias) - .where(inArray(categorias.id, categoryIds)) - : []; + .where(inArray(categorias.id, categoryIds))) as CategoryInfoRow[]) + : ([] as CategoryInfoRow[]); const categoryNameMap = new Map(categoryNames.map((c) => [c.id, c])); @@ -363,7 +390,7 @@ async function fetchCardDetail( .slice(0, 10); // Fetch top expenses for current period - const topExpensesData = await db + const topExpensesData = (await db .select({ id: lancamentos.id, name: lancamentos.name, @@ -383,7 +410,7 @@ async function fetchCardDetail( ), ) .orderBy(lancamentos.amount) - .limit(10); + .limit(10)) as TopExpenseRow[]; const topExpenses = topExpensesData.map((expense) => { const catInfo = expense.categoriaId @@ -393,15 +420,18 @@ async function fetchCardDetail( id: expense.id, name: expense.name, amount: Math.abs(safeToNumber(expense.amount)), - date: expense.purchaseDate - ? new Date(expense.purchaseDate).toLocaleDateString("pt-BR") - : "", + date: + formatDateOnly(expense.purchaseDate, { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) ?? "", category: catInfo?.name || null, }; }); // Fetch invoice status for last 6 months - const invoiceData = await db + const invoiceData = (await db .select({ period: faturas.period, status: faturas.paymentStatus, @@ -415,7 +445,7 @@ async function fetchCardDetail( lte(faturas.period, currentPeriod), ), ) - .orderBy(faturas.period); + .orderBy(faturas.period)) as InvoiceStatusRow[]; const invoiceStatus = periods.map((period) => { const invoice = invoiceData.find((i) => i.period === period); diff --git a/lib/relatorios/fetch-category-chart-data.ts b/lib/relatorios/fetch-category-chart-data.ts index f368e10..ba89091 100644 --- a/lib/relatorios/fetch-category-chart-data.ts +++ b/lib/relatorios/fetch-category-chart-data.ts @@ -1,11 +1,10 @@ -import { format } from "date-fns"; -import { ptBR } from "date-fns/locale"; import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { categorias, lancamentos } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; +import { formatPeriodMonthShort } from "@/lib/utils/period"; import { generatePeriodRange } from "./utils"; export type CategoryChartData = { @@ -127,13 +126,7 @@ export async function fetchCategoryChartData( } const chartData = periods.map((period) => { - const [year, month] = period.split("-"); - const date = new Date( - Number.parseInt(year, 10), - Number.parseInt(month, 10) - 1, - 1, - ); - const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase(); + const monthLabel = formatPeriodMonthShort(period).toUpperCase(); const dataPoint: { month: string; [key: string]: number | string } = { month: monthLabel, @@ -146,15 +139,9 @@ export async function fetchCategoryChartData( return dataPoint; }); - const months = periods.map((period) => { - const [year, month] = period.split("-"); - const date = new Date( - Number.parseInt(year, 10), - Number.parseInt(month, 10) - 1, - 1, - ); - return format(date, "MMM", { locale: ptBR }).toUpperCase(); - }); + const months = periods.map((period) => + formatPeriodMonthShort(period).toUpperCase(), + ); const categories = Array.from(categoryMap.values()).map((cat) => ({ id: cat.id, diff --git a/lib/relatorios/fetch-category-report.ts b/lib/relatorios/fetch-category-report.ts index a2335a3..7097339 100644 --- a/lib/relatorios/fetch-category-report.ts +++ b/lib/relatorios/fetch-category-report.ts @@ -1,9 +1,9 @@ import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { categorias, lancamentos } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; -import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; +import { safeToNumber as toNumber } from "@/lib/utils/number"; import type { CategoryReportData, CategoryReportFilters, diff --git a/lib/relatorios/types.ts b/lib/relatorios/types.ts index 1c2b71f..b59f437 100644 --- a/lib/relatorios/types.ts +++ b/lib/relatorios/types.ts @@ -1,52 +1,7 @@ -/** - * 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; -}; +export type { + CategoryReportData, + CategoryReportFilters, + CategoryReportItem, + DateRangeValidation, + MonthlyData, +} from "@/lib/types/relatorios"; diff --git a/lib/relatorios/utils.ts b/lib/relatorios/utils.ts index 9dae8d8..661e8c6 100644 --- a/lib/relatorios/utils.ts +++ b/lib/relatorios/utils.ts @@ -1,6 +1,11 @@ +import type { DateRangeValidation } from "@/lib/types/relatorios"; import { calculatePercentageChange } from "@/lib/utils/math"; -import { buildPeriodRange, MONTH_NAMES, parsePeriod } from "@/lib/utils/period"; -import type { DateRangeValidation } from "./types"; +import { formatPercentageChange as formatPercentageChangeValue } from "@/lib/utils/percentage"; +import { + buildPeriodRange, + formatShortPeriodLabel, + parsePeriod, +} from "@/lib/utils/period"; // Re-export for convenience export { calculatePercentageChange }; @@ -14,14 +19,8 @@ export { calculatePercentageChange }; */ 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}`; + parsePeriod(period); + return formatShortPeriodLabel(period); } catch { return period; // Return original if parsing fails } @@ -102,14 +101,5 @@ export function validateDateRange( * @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}%`; + return formatPercentageChangeValue(change); } diff --git a/components/calendario/utils.ts b/lib/utils/calendario.ts similarity index 87% rename from components/calendario/utils.ts rename to lib/utils/calendario.ts index af706a9..0302f29 100644 --- a/components/calendario/utils.ts +++ b/lib/utils/calendario.ts @@ -1,6 +1,7 @@ -import type { CalendarDay, CalendarEvent } from "@/components/calendario/types"; +import type { CalendarDay, CalendarEvent } from "@/lib/types/calendario"; +import { toDateOnlyString } from "@/lib/utils/date"; -export const formatDateKey = (date: Date) => date.toISOString().slice(0, 10); +export const formatDateKey = (date: Date) => toDateOnlyString(date) ?? ""; const getWeekdayIndex = (date: Date) => { const day = date.getUTCDay(); // 0 (domingo) - 6 (sábado)