mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor(dashboard): reorganiza widgets e remove magnet-lines
This commit is contained in:
@@ -54,7 +54,6 @@ const deleteAccountSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updatePreferencesSchema = z.object({
|
const updatePreferencesSchema = z.object({
|
||||||
disableMagnetlines: z.boolean(),
|
|
||||||
extratoNoteAsColumn: z.boolean(),
|
extratoNoteAsColumn: z.boolean(),
|
||||||
lancamentosColumnOrder: z.array(z.string()).nullable(),
|
lancamentosColumnOrder: z.array(z.string()).nullable(),
|
||||||
systemFont: z.enum(FONT_KEYS).default(DEFAULT_FONT_KEY),
|
systemFont: z.enum(FONT_KEYS).default(DEFAULT_FONT_KEY),
|
||||||
@@ -403,7 +402,6 @@ export async function updatePreferencesAction(
|
|||||||
await db
|
await db
|
||||||
.update(schema.preferenciasUsuario)
|
.update(schema.preferenciasUsuario)
|
||||||
.set({
|
.set({
|
||||||
disableMagnetlines: validated.disableMagnetlines,
|
|
||||||
extratoNoteAsColumn: validated.extratoNoteAsColumn,
|
extratoNoteAsColumn: validated.extratoNoteAsColumn,
|
||||||
lancamentosColumnOrder: validated.lancamentosColumnOrder,
|
lancamentosColumnOrder: validated.lancamentosColumnOrder,
|
||||||
systemFont: validated.systemFont,
|
systemFont: validated.systemFont,
|
||||||
@@ -415,7 +413,6 @@ export async function updatePreferencesAction(
|
|||||||
// Create new preferences
|
// Create new preferences
|
||||||
await db.insert(schema.preferenciasUsuario).values({
|
await db.insert(schema.preferenciasUsuario).values({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
disableMagnetlines: validated.disableMagnetlines,
|
|
||||||
extratoNoteAsColumn: validated.extratoNoteAsColumn,
|
extratoNoteAsColumn: validated.extratoNoteAsColumn,
|
||||||
lancamentosColumnOrder: validated.lancamentosColumnOrder,
|
lancamentosColumnOrder: validated.lancamentosColumnOrder,
|
||||||
systemFont: validated.systemFont,
|
systemFont: validated.systemFont,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { db, schema } from "@/lib/db";
|
|||||||
import { type FontKey, normalizeFontKey } from "@/public/fonts/font_index";
|
import { type FontKey, normalizeFontKey } from "@/public/fonts/font_index";
|
||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
disableMagnetlines: boolean;
|
|
||||||
extratoNoteAsColumn: boolean;
|
extratoNoteAsColumn: boolean;
|
||||||
lancamentosColumnOrder: string[] | null;
|
lancamentosColumnOrder: string[] | null;
|
||||||
systemFont: FontKey;
|
systemFont: FontKey;
|
||||||
@@ -34,7 +33,6 @@ export async function fetchUserPreferences(
|
|||||||
): Promise<UserPreferences | null> {
|
): Promise<UserPreferences | null> {
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
|
|
||||||
extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn,
|
extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn,
|
||||||
lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder,
|
lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder,
|
||||||
systemFont: schema.preferenciasUsuario.systemFont,
|
systemFont: schema.preferenciasUsuario.systemFont,
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ export default async function Page() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PreferencesForm
|
<PreferencesForm
|
||||||
disableMagnetlines={
|
|
||||||
userPreferences?.disableMagnetlines ?? false
|
|
||||||
}
|
|
||||||
extratoNoteAsColumn={
|
extratoNoteAsColumn={
|
||||||
userPreferences?.extratoNoteAsColumn ?? false
|
userPreferences?.extratoNoteAsColumn ?? false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { eq } from "drizzle-orm";
|
|||||||
import { db, schema } from "@/lib/db";
|
import { db, schema } from "@/lib/db";
|
||||||
|
|
||||||
export interface UserDashboardPreferences {
|
export interface UserDashboardPreferences {
|
||||||
disableMagnetlines: boolean;
|
|
||||||
dashboardWidgets: string | null;
|
dashboardWidgets: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,7 +10,6 @@ export async function fetchUserDashboardPreferences(
|
|||||||
): Promise<UserDashboardPreferences> {
|
): Promise<UserDashboardPreferences> {
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
|
|
||||||
dashboardWidgets: schema.preferenciasUsuario.dashboardWidgets,
|
dashboardWidgets: schema.preferenciasUsuario.dashboardWidgets,
|
||||||
})
|
})
|
||||||
.from(schema.preferenciasUsuario)
|
.from(schema.preferenciasUsuario)
|
||||||
@@ -19,7 +17,6 @@ export async function fetchUserDashboardPreferences(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disableMagnetlines: result[0]?.disableMagnetlines ?? false,
|
|
||||||
dashboardWidgets: result[0]?.dashboardWidgets ?? null,
|
dashboardWidgets: result[0]?.dashboardWidgets ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
export default function DashboardLoading() {
|
export default function DashboardLoading() {
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4">
|
<main className="flex flex-col gap-4">
|
||||||
{/* Welcome Banner skeleton */}
|
<div className="space-y-2 px-1 py-2">
|
||||||
<Skeleton className="h-[104px] w-full rounded-xl bg-foreground/10" />
|
<Skeleton className="h-8 w-72 rounded-xl bg-foreground/10" />
|
||||||
|
<Skeleton className="h-5 w-56 rounded-xl bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Month Picker skeleton */}
|
{/* Month Picker skeleton */}
|
||||||
<Skeleton className="h-[56px] w-full rounded-xl bg-foreground/10" />
|
<Skeleton className="h-[56px] w-full rounded-xl bg-foreground/10" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DashboardGridEditable } from "@/components/dashboard/dashboard-grid-editable";
|
import { DashboardGridEditable } from "@/components/dashboard/dashboard-grid-editable";
|
||||||
|
import { DashboardMetricsCards } from "@/components/dashboard/dashboard-metrics-cards";
|
||||||
import { DashboardWelcome } from "@/components/dashboard/dashboard-welcome";
|
import { DashboardWelcome } from "@/components/dashboard/dashboard-welcome";
|
||||||
import { SectionCards } from "@/components/dashboard/section-cards";
|
|
||||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
||||||
@@ -41,7 +41,7 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
fetchLancamentoFilterSources(user.id),
|
fetchLancamentoFilterSources(user.id),
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
]);
|
]);
|
||||||
const { disableMagnetlines, dashboardWidgets } = preferences;
|
const { dashboardWidgets } = preferences;
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const {
|
const {
|
||||||
pagadorOptions,
|
pagadorOptions,
|
||||||
@@ -57,12 +57,9 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4">
|
<main className="flex flex-col gap-4">
|
||||||
<DashboardWelcome
|
<DashboardWelcome name={user.name} />
|
||||||
name={user.name}
|
|
||||||
disableMagnetlines={disableMagnetlines}
|
|
||||||
/>
|
|
||||||
<MonthNavigation />
|
<MonthNavigation />
|
||||||
<SectionCards metrics={dashboardData.metrics} />
|
<DashboardMetricsCards metrics={dashboardData.metrics} />
|
||||||
<DashboardGridEditable
|
<DashboardGridEditable
|
||||||
data={dashboardData}
|
data={dashboardData}
|
||||||
period={selectedPeriod}
|
period={selectedPeriod}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
|
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
|
||||||
import { useFont } from "@/components/font-provider";
|
import { useFont } from "@/components/providers/font-provider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
import { FONT_OPTIONS } from "@/public/fonts/font_index";
|
import { FONT_OPTIONS } from "@/public/fonts/font_index";
|
||||||
|
|
||||||
interface PreferencesFormProps {
|
interface PreferencesFormProps {
|
||||||
disableMagnetlines: boolean;
|
|
||||||
extratoNoteAsColumn: boolean;
|
extratoNoteAsColumn: boolean;
|
||||||
lancamentosColumnOrder: string[] | null;
|
lancamentosColumnOrder: string[] | null;
|
||||||
systemFont: string;
|
systemFont: string;
|
||||||
@@ -84,7 +83,6 @@ function SortableColumnItem({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PreferencesForm({
|
export function PreferencesForm({
|
||||||
disableMagnetlines,
|
|
||||||
extratoNoteAsColumn: initialExtratoNoteAsColumn,
|
extratoNoteAsColumn: initialExtratoNoteAsColumn,
|
||||||
lancamentosColumnOrder: initialColumnOrder,
|
lancamentosColumnOrder: initialColumnOrder,
|
||||||
systemFont: initialSystemFont,
|
systemFont: initialSystemFont,
|
||||||
@@ -92,8 +90,6 @@ export function PreferencesForm({
|
|||||||
}: PreferencesFormProps) {
|
}: PreferencesFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
|
||||||
useState(disableMagnetlines);
|
|
||||||
const [extratoNoteAsColumn, setExtratoNoteAsColumn] = useState(
|
const [extratoNoteAsColumn, setExtratoNoteAsColumn] = useState(
|
||||||
initialExtratoNoteAsColumn,
|
initialExtratoNoteAsColumn,
|
||||||
);
|
);
|
||||||
@@ -138,7 +134,6 @@ export function PreferencesForm({
|
|||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updatePreferencesAction({
|
const result = await updatePreferencesAction({
|
||||||
disableMagnetlines: magnetlinesDisabled,
|
|
||||||
extratoNoteAsColumn,
|
extratoNoteAsColumn,
|
||||||
lancamentosColumnOrder: columnOrder,
|
lancamentosColumnOrder: columnOrder,
|
||||||
systemFont: selectedSystemFont,
|
systemFont: selectedSystemFont,
|
||||||
@@ -274,35 +269,6 @@ export function PreferencesForm({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="border-b" />
|
|
||||||
|
|
||||||
{/* Seção: Dashboard */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-semibold">Dashboard</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Opções que afetam a experiência no painel principal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="magnetlines" className="text-base">
|
|
||||||
Desabilitar Magnetlines
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Remove o recurso de linhas magnéticas do sistema.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="magnetlines"
|
|
||||||
checked={magnetlinesDisabled}
|
|
||||||
onCheckedChange={setMagnetlinesDisabled}
|
|
||||||
disabled={isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isPending} className="w-fit">
|
<Button type="submit" disabled={isPending} className="w-fit">
|
||||||
{isPending ? "Salvando..." : "Salvar preferências"}
|
{isPending ? "Salvando..." : "Salvar preferências"}
|
||||||
|
|||||||
35
components/dashboard/bill-widget.tsx
Normal file
35
components/dashboard/bill-widget.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { DashboardBill } from "@/lib/dashboard/bills";
|
||||||
|
import { useBillWidgetController } from "@/lib/dashboard/use-bill-widget-controller";
|
||||||
|
import { BillsWidgetView } from "./bills/bills-widget-view";
|
||||||
|
|
||||||
|
type BillWidgetProps = {
|
||||||
|
bills?: DashboardBill[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BillWidget({ bills }: BillWidgetProps) {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
selectedBill,
|
||||||
|
isModalOpen,
|
||||||
|
modalState,
|
||||||
|
isPending,
|
||||||
|
openPaymentDialog,
|
||||||
|
closePaymentDialog,
|
||||||
|
confirmPayment,
|
||||||
|
} = useBillWidgetController(bills);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BillsWidgetView
|
||||||
|
bills={items}
|
||||||
|
selectedBill={selectedBill}
|
||||||
|
isModalOpen={isModalOpen}
|
||||||
|
modalState={modalState}
|
||||||
|
isPending={isPending}
|
||||||
|
onOpenPaymentDialog={openPaymentDialog}
|
||||||
|
onClosePaymentDialog={closePaymentDialog}
|
||||||
|
onConfirmPayment={confirmPayment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/dashboard/bills/bill-list-item.tsx
Normal file
73
components/dashboard/bills/bill-list-item.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { RiCheckboxCircleFill } from "@remixicon/react";
|
||||||
|
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { DashboardBill } from "@/lib/dashboard/bills";
|
||||||
|
import {
|
||||||
|
buildBillStatusLabel,
|
||||||
|
isBillOverdue,
|
||||||
|
} from "@/lib/dashboard/bills-helpers";
|
||||||
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
|
type BillListItemProps = {
|
||||||
|
bill: DashboardBill;
|
||||||
|
onPay: (billId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||||
|
const statusLabel = buildBillStatusLabel(bill);
|
||||||
|
const overdue = isBillOverdue(bill);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||||
|
<EstabelecimentoLogo name={bill.name} size={37} />
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-medium text-foreground">
|
||||||
|
{bill.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{statusLabel ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full py-0.5",
|
||||||
|
bill.isSettled && "text-success",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col items-end">
|
||||||
|
<MoneyValues amount={bill.amount} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 disabled:opacity-100"
|
||||||
|
disabled={bill.isSettled}
|
||||||
|
onClick={() => onPay(bill.id)}
|
||||||
|
>
|
||||||
|
{bill.isSettled ? (
|
||||||
|
<span className="flex items-center gap-1 text-success">
|
||||||
|
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||||
|
</span>
|
||||||
|
) : overdue ? (
|
||||||
|
<span className="overdue-blink">
|
||||||
|
<span className="overdue-blink-primary text-destructive">
|
||||||
|
Atrasado
|
||||||
|
</span>
|
||||||
|
<span className="overdue-blink-secondary">Pagar</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Pagar"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
components/dashboard/bills/bill-payment-dialog.tsx
Normal file
189
components/dashboard/bills/bill-payment-dialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
RiBarcodeFill,
|
||||||
|
RiCheckboxCircleLine,
|
||||||
|
RiLoader4Line,
|
||||||
|
RiMoneyDollarCircleLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { DashboardBill } from "@/lib/dashboard/bills";
|
||||||
|
import {
|
||||||
|
type BillDialogState,
|
||||||
|
formatBillDateLabel,
|
||||||
|
getBillStatusBadgeVariant,
|
||||||
|
} from "@/lib/dashboard/bills-helpers";
|
||||||
|
|
||||||
|
type BillPaymentDialogProps = {
|
||||||
|
bill: DashboardBill | null;
|
||||||
|
open: boolean;
|
||||||
|
modalState: BillDialogState;
|
||||||
|
isPending: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BillPaymentDialog({
|
||||||
|
bill,
|
||||||
|
open,
|
||||||
|
modalState,
|
||||||
|
isPending,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: BillPaymentDialogProps) {
|
||||||
|
const isProcessing = modalState === "processing" || isPending;
|
||||||
|
const dueLabel = bill
|
||||||
|
? formatBillDateLabel(bill.dueDate, "Vencimento:")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (nextOpen || isProcessing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (isProcessing) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={(event) => {
|
||||||
|
if (isProcessing) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modalState === "success" ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||||
|
<RiCheckboxCircleLine className="size-8" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<DialogTitle className="text-base">
|
||||||
|
Pagamento registrado!
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm">
|
||||||
|
Atualizamos o status do boleto para pago. Em instantes ele
|
||||||
|
aparecerá como baixado no histórico.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="sm:justify-center">
|
||||||
|
<Button type="button" onClick={onClose} className="sm:w-auto">
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Confirme os dados para registrar o pagamento. Você poderá editar
|
||||||
|
o lançamento depois, se necessário.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{bill ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<RiBarcodeFill className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Boleto
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-foreground">
|
||||||
|
{bill.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dueLabel ? (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{dueLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<RiMoneyDollarCircleLine className="size-4" />
|
||||||
|
<span className="text-xs font-semibold uppercase">
|
||||||
|
Valor do Boleto
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<MoneyValues
|
||||||
|
amount={bill.amount}
|
||||||
|
className="text-lg font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<RiCheckboxCircleLine className="size-4" />
|
||||||
|
<span className="text-xs font-semibold uppercase">
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={getBillStatusBadgeVariant(
|
||||||
|
bill.isSettled ? "Pago" : "Pendente",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bill.isSettled ? "Pago" : "Pendente"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isProcessing || !bill || bill.isSettled}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<>
|
||||||
|
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||||
|
Processando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Confirmar pagamento"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
components/dashboard/bills/bills-list.tsx
Normal file
29
components/dashboard/bills/bills-list.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { RiBarcodeFill } from "@remixicon/react";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
import type { DashboardBill } from "@/lib/dashboard/bills";
|
||||||
|
import { BillListItem } from "./bill-list-item";
|
||||||
|
|
||||||
|
type BillsListProps = {
|
||||||
|
bills: DashboardBill[];
|
||||||
|
onPay: (billId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BillsList({ bills, onPay }: BillsListProps) {
|
||||||
|
if (bills.length === 0) {
|
||||||
|
return (
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
|
||||||
|
title="Nenhum boleto cadastrado para o período selecionado"
|
||||||
|
description="Cadastre boletos para monitorar os pagamentos aqui."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{bills.map((bill) => (
|
||||||
|
<BillListItem key={bill.id} bill={bill} onPay={onPay} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/dashboard/bills/bills-widget-view.tsx
Normal file
43
components/dashboard/bills/bills-widget-view.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { DashboardBill } from "@/lib/dashboard/bills";
|
||||||
|
import type { BillDialogState } from "@/lib/dashboard/bills-helpers";
|
||||||
|
import { BillPaymentDialog } from "./bill-payment-dialog";
|
||||||
|
import { BillsList } from "./bills-list";
|
||||||
|
|
||||||
|
type BillsWidgetViewProps = {
|
||||||
|
bills: DashboardBill[];
|
||||||
|
selectedBill: DashboardBill | null;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
modalState: BillDialogState;
|
||||||
|
isPending: boolean;
|
||||||
|
onOpenPaymentDialog: (billId: string) => void;
|
||||||
|
onClosePaymentDialog: () => void;
|
||||||
|
onConfirmPayment: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BillsWidgetView({
|
||||||
|
bills,
|
||||||
|
selectedBill,
|
||||||
|
isModalOpen,
|
||||||
|
modalState,
|
||||||
|
isPending,
|
||||||
|
onOpenPaymentDialog,
|
||||||
|
onClosePaymentDialog,
|
||||||
|
onConfirmPayment,
|
||||||
|
}: BillsWidgetViewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<BillsList bills={bills} onPay={onOpenPaymentDialog} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BillPaymentDialog
|
||||||
|
bill={selectedBill}
|
||||||
|
open={isModalOpen}
|
||||||
|
modalState={modalState}
|
||||||
|
isPending={isPending}
|
||||||
|
onClose={onClosePaymentDialog}
|
||||||
|
onConfirm={onConfirmPayment}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
RiBarcodeFill,
|
|
||||||
RiCheckboxCircleFill,
|
|
||||||
RiCheckboxCircleLine,
|
|
||||||
RiLoader4Line,
|
|
||||||
RiMoneyDollarCircleLine,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions";
|
|
||||||
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter as ModalFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import type { DashboardBoleto } from "@/lib/dashboard/boletos";
|
|
||||||
import { cn } from "@/lib/utils/ui";
|
|
||||||
import { Badge } from "../ui/badge";
|
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
|
||||||
|
|
||||||
type BoletosWidgetProps = {
|
|
||||||
boletos: DashboardBoleto[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ModalState = "idle" | "processing" | "success";
|
|
||||||
|
|
||||||
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildDateLabel = (value: string | null, prefix?: string) => {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [year, month, day] = value.split("-").map((part) => Number(part));
|
|
||||||
if (!year || !month || !day) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatted = DATE_FORMATTER.format(
|
|
||||||
new Date(Date.UTC(year, month - 1, day)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return prefix ? `${prefix} ${formatted}` : formatted;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildStatusLabel = (boleto: DashboardBoleto) => {
|
|
||||||
if (boleto.isSettled) {
|
|
||||||
return buildDateLabel(boleto.boletoPaymentDate, "Pago em");
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildDateLabel(boleto.dueDate, "Vence em");
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTodayDateString = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(now.getDate()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BoletosWidget({ boletos }: BoletosWidgetProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [items, setItems] = useState(boletos);
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [modalState, setModalState] = useState<ModalState>("idle");
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setItems(boletos);
|
|
||||||
}, [boletos]);
|
|
||||||
|
|
||||||
const selectedBoleto = useMemo(
|
|
||||||
() => items.find((boleto) => boleto.id === selectedId) ?? null,
|
|
||||||
[items, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isProcessing = modalState === "processing" || isPending;
|
|
||||||
|
|
||||||
const selectedBoletoDueLabel = selectedBoleto
|
|
||||||
? buildDateLabel(selectedBoleto.dueDate, "Vencimento:")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleOpenModal = (boletoId: string) => {
|
|
||||||
setSelectedId(boletoId);
|
|
||||||
setModalState("idle");
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetModalState = () => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
setSelectedId(null);
|
|
||||||
setModalState("idle");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmPayment = () => {
|
|
||||||
if (!selectedBoleto || selectedBoleto.isSettled || isProcessing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalState("processing");
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await toggleLancamentoSettlementAction({
|
|
||||||
id: selectedBoleto.id,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.error);
|
|
||||||
setModalState("idle");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setItems((previous) =>
|
|
||||||
previous.map((boleto) =>
|
|
||||||
boleto.id === selectedBoleto.id
|
|
||||||
? {
|
|
||||||
...boleto,
|
|
||||||
isSettled: true,
|
|
||||||
boletoPaymentDate: getTodayDateString(),
|
|
||||||
}
|
|
||||||
: boleto,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
toast.success(result.message);
|
|
||||||
router.refresh();
|
|
||||||
setModalState("success");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadgeVariant = (status: string): "success" | "info" => {
|
|
||||||
const normalizedStatus = status.toLowerCase();
|
|
||||||
if (normalizedStatus === "pendente") {
|
|
||||||
return "info";
|
|
||||||
}
|
|
||||||
return "success";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardContent className="flex flex-col gap-4 px-0">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<WidgetEmptyState
|
|
||||||
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
|
|
||||||
title="Nenhum boleto cadastrado para o período selecionado"
|
|
||||||
description="Cadastre boletos para monitorar os pagamentos aqui."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col">
|
|
||||||
{items.map((boleto) => {
|
|
||||||
const statusLabel = buildStatusLabel(boleto);
|
|
||||||
const isOverdue = (() => {
|
|
||||||
if (boleto.isSettled || !boleto.dueDate) return false;
|
|
||||||
const [y, m, d] = boleto.dueDate.split("-").map(Number);
|
|
||||||
if (!y || !m || !d) return false;
|
|
||||||
return new Date(Date.UTC(y, m - 1, d)) < new Date();
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={boleto.id}
|
|
||||||
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3 py-2">
|
|
||||||
<EstabelecimentoLogo name={boleto.name} size={37} />
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="block truncate text-sm font-medium text-foreground">
|
|
||||||
{boleto.name}
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full py-0.5",
|
|
||||||
boleto.isSettled && "text-success",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end">
|
|
||||||
<MoneyValues amount={boleto.amount} />
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="link"
|
|
||||||
className="h-auto p-0 disabled:opacity-100"
|
|
||||||
disabled={boleto.isSettled}
|
|
||||||
onClick={() => handleOpenModal(boleto.id)}
|
|
||||||
>
|
|
||||||
{boleto.isSettled ? (
|
|
||||||
<span className="flex items-center gap-1 text-success">
|
|
||||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
|
||||||
</span>
|
|
||||||
) : isOverdue ? (
|
|
||||||
<span className="overdue-blink">
|
|
||||||
<span className="overdue-blink-primary text-destructive">
|
|
||||||
Atrasado
|
|
||||||
</span>
|
|
||||||
<span className="overdue-blink-secondary">Pagar</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"Pagar"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={isModalOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
if (isProcessing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resetModalState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
|
||||||
onEscapeKeyDown={(event) => {
|
|
||||||
if (isProcessing) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resetModalState();
|
|
||||||
}}
|
|
||||||
onPointerDownOutside={(event) => {
|
|
||||||
if (isProcessing) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{modalState === "success" ? (
|
|
||||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
|
||||||
<RiCheckboxCircleLine className="size-8" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<DialogTitle className="text-base">
|
|
||||||
Pagamento registrado!
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm">
|
|
||||||
Atualizamos o status do boleto para pago. Em instantes ele
|
|
||||||
aparecerá como baixado no histórico.
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
<ModalFooter className="sm:justify-center">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={resetModalState}
|
|
||||||
className="sm:w-auto"
|
|
||||||
>
|
|
||||||
Fechar
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Confirme os dados para registrar o pagamento. Você poderá
|
|
||||||
editar o lançamento depois, se necessário.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{selectedBoleto ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
|
||||||
<RiBarcodeFill className="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
Boleto
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-bold text-foreground">
|
|
||||||
{selectedBoleto.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectedBoletoDueLabel ? (
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{selectedBoletoDueLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
|
||||||
<RiMoneyDollarCircleLine className="size-4" />
|
|
||||||
<span className="text-xs font-semibold uppercase">
|
|
||||||
Valor do Boleto
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<MoneyValues
|
|
||||||
amount={selectedBoleto.amount}
|
|
||||||
className="text-lg font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
|
||||||
<RiCheckboxCircleLine className="size-4" />
|
|
||||||
<span className="text-xs font-semibold uppercase">
|
|
||||||
Status
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={getStatusBadgeVariant(
|
|
||||||
selectedBoleto.isSettled ? "Pago" : "Pendente",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectedBoleto.isSettled ? "Pago" : "Pendente"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ModalFooter className="sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={resetModalState}
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleConfirmPayment}
|
|
||||||
disabled={
|
|
||||||
isProcessing || !selectedBoleto || selectedBoleto.isSettled
|
|
||||||
}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<>
|
|
||||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
|
||||||
Processando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Confirmar pagamento"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiArrowDownSFill,
|
||||||
|
RiArrowUpSFill,
|
||||||
|
RiExternalLinkLine,
|
||||||
|
RiListUnordered,
|
||||||
|
RiPieChart2Line,
|
||||||
|
RiPieChartLine,
|
||||||
|
RiWallet3Line,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Pie, PieChart, Tooltip } from "recharts";
|
||||||
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import type { DashboardCategoryBreakdownData } from "@/lib/dashboard/categories/category-breakdown";
|
||||||
|
import { formatCurrency } from "@/lib/lancamentos/formatting-helpers";
|
||||||
|
import { formatPercentage as formatPercentageValue } from "@/lib/utils/percentage";
|
||||||
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
|
|
||||||
|
type CategoryBreakdownVariant = "income" | "expense";
|
||||||
|
|
||||||
|
type CategoryBreakdownWidgetViewProps = {
|
||||||
|
data: DashboardCategoryBreakdownData;
|
||||||
|
period: string;
|
||||||
|
variant: CategoryBreakdownVariant;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_BREAKDOWN_COLORS = [
|
||||||
|
"var(--chart-1)",
|
||||||
|
"var(--chart-2)",
|
||||||
|
"var(--chart-3)",
|
||||||
|
"var(--chart-4)",
|
||||||
|
"var(--chart-5)",
|
||||||
|
"var(--chart-1)",
|
||||||
|
"var(--chart-2)",
|
||||||
|
];
|
||||||
|
|
||||||
|
const VARIANT_CONFIG = {
|
||||||
|
income: {
|
||||||
|
emptyTitle: "Nenhuma receita encontrada",
|
||||||
|
emptyDescription:
|
||||||
|
"Quando houver receitas registradas, elas aparecerão aqui.",
|
||||||
|
shareLabel: "receita total",
|
||||||
|
percentageDigits: 1,
|
||||||
|
changeClassName: {
|
||||||
|
increase: "text-success",
|
||||||
|
decrease: "text-destructive",
|
||||||
|
},
|
||||||
|
listItemClassName:
|
||||||
|
"flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0",
|
||||||
|
includeBudgetAmount: true,
|
||||||
|
},
|
||||||
|
expense: {
|
||||||
|
emptyTitle: "Nenhuma despesa encontrada",
|
||||||
|
emptyDescription:
|
||||||
|
"Quando houver despesas registradas, elas aparecerão aqui.",
|
||||||
|
shareLabel: "despesa total",
|
||||||
|
percentageDigits: 0,
|
||||||
|
changeClassName: {
|
||||||
|
increase: "text-destructive",
|
||||||
|
decrease: "text-success",
|
||||||
|
},
|
||||||
|
listItemClassName:
|
||||||
|
"flex flex-col py-2 border-b border-dashed last:border-0",
|
||||||
|
includeBudgetAmount: false,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const formatPercentage = (value: number, digits: number) =>
|
||||||
|
formatPercentageValue(value, {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
absolute: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CategoryBreakdownWidgetView({
|
||||||
|
data,
|
||||||
|
period,
|
||||||
|
variant,
|
||||||
|
}: CategoryBreakdownWidgetViewProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
|
||||||
|
const periodParam = formatPeriodForUrl(period);
|
||||||
|
const config = VARIANT_CONFIG[variant];
|
||||||
|
|
||||||
|
const chartConfig = useMemo(() => {
|
||||||
|
const nextConfig: ChartConfig = {};
|
||||||
|
|
||||||
|
if (data.categories.length <= 7) {
|
||||||
|
data.categories.forEach((category, index) => {
|
||||||
|
nextConfig[category.categoryId] = {
|
||||||
|
label: category.categoryName,
|
||||||
|
color:
|
||||||
|
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const topCategories = data.categories.slice(0, 7);
|
||||||
|
topCategories.forEach((category, index) => {
|
||||||
|
nextConfig[category.categoryId] = {
|
||||||
|
label: category.categoryName,
|
||||||
|
color:
|
||||||
|
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
nextConfig.outros = {
|
||||||
|
label: "Outros",
|
||||||
|
color: "var(--chart-6)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextConfig;
|
||||||
|
}, [data.categories]);
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (data.categories.length <= 7) {
|
||||||
|
return data.categories.map((category) => ({
|
||||||
|
category: category.categoryId,
|
||||||
|
name: category.categoryName,
|
||||||
|
value: category.currentAmount,
|
||||||
|
percentage: category.percentageOfTotal,
|
||||||
|
fill: chartConfig[category.categoryId]?.color,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const topCategories = data.categories.slice(0, 7);
|
||||||
|
const otherCategories = data.categories.slice(7);
|
||||||
|
const otherTotal = otherCategories.reduce(
|
||||||
|
(sum, category) => sum + category.currentAmount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const otherPercentage = otherCategories.reduce(
|
||||||
|
(sum, category) => sum + category.percentageOfTotal,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedData = topCategories.map((category) => ({
|
||||||
|
category: category.categoryId,
|
||||||
|
name: category.categoryName,
|
||||||
|
value: category.currentAmount,
|
||||||
|
percentage: category.percentageOfTotal,
|
||||||
|
fill: chartConfig[category.categoryId]?.color,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (otherCategories.length > 0) {
|
||||||
|
groupedData.push({
|
||||||
|
category: "outros",
|
||||||
|
name: "Outros",
|
||||||
|
value: otherTotal,
|
||||||
|
percentage: otherPercentage,
|
||||||
|
fill: chartConfig.outros?.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupedData;
|
||||||
|
}, [data.categories, chartConfig]);
|
||||||
|
|
||||||
|
if (data.categories.length === 0) {
|
||||||
|
return (
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
||||||
|
title={config.emptyTitle}
|
||||||
|
description={config.emptyDescription}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value: string) => setActiveTab(value as "list" | "chart")}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<TabsList className="grid grid-cols-2">
|
||||||
|
<TabsTrigger value="list" className="text-xs">
|
||||||
|
<RiListUnordered className="mr-1 size-3.5" />
|
||||||
|
Lista
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="chart" className="text-xs">
|
||||||
|
<RiPieChart2Line className="mr-1 size-3.5" />
|
||||||
|
Gráfico
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="list" className="mt-0">
|
||||||
|
<div className="flex flex-col px-0">
|
||||||
|
{data.categories.map((category, index) => {
|
||||||
|
const hasIncrease =
|
||||||
|
category.percentageChange !== null &&
|
||||||
|
category.percentageChange > 0;
|
||||||
|
const hasDecrease =
|
||||||
|
category.percentageChange !== null &&
|
||||||
|
category.percentageChange < 0;
|
||||||
|
const hasBudget = category.budgetAmount !== null;
|
||||||
|
const budgetExceeded =
|
||||||
|
hasBudget &&
|
||||||
|
category.budgetUsedPercentage !== null &&
|
||||||
|
category.budgetUsedPercentage > 100;
|
||||||
|
const exceededAmount =
|
||||||
|
budgetExceeded && category.budgetAmount
|
||||||
|
? category.currentAmount - category.budgetAmount
|
||||||
|
: 0;
|
||||||
|
const changeClassName = hasIncrease
|
||||||
|
? config.changeClassName.increase
|
||||||
|
: hasDecrease
|
||||||
|
? config.changeClassName.decrease
|
||||||
|
: "text-muted-foreground";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={category.categoryId}
|
||||||
|
className={config.listItemClassName}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<CategoryIconBadge
|
||||||
|
icon={category.categoryIcon}
|
||||||
|
name={category.categoryName}
|
||||||
|
colorIndex={index}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
|
||||||
|
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{category.categoryName}
|
||||||
|
</span>
|
||||||
|
<RiExternalLinkLine
|
||||||
|
className="size-3 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{formatPercentage(
|
||||||
|
category.percentageOfTotal,
|
||||||
|
config.percentageDigits,
|
||||||
|
)}{" "}
|
||||||
|
da {config.shareLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||||
|
<MoneyValues
|
||||||
|
className="text-foreground"
|
||||||
|
amount={category.currentAmount}
|
||||||
|
/>
|
||||||
|
{category.percentageChange !== null ? (
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-0.5 text-xs ${changeClassName}`}
|
||||||
|
>
|
||||||
|
{hasIncrease ? (
|
||||||
|
<RiArrowUpSFill className="size-3" />
|
||||||
|
) : null}
|
||||||
|
{hasDecrease ? (
|
||||||
|
<RiArrowDownSFill className="size-3" />
|
||||||
|
) : null}
|
||||||
|
{formatPercentage(
|
||||||
|
category.percentageChange,
|
||||||
|
config.percentageDigits,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasBudget && category.budgetUsedPercentage !== null ? (
|
||||||
|
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
||||||
|
<RiWallet3Line
|
||||||
|
className={`size-3 ${
|
||||||
|
budgetExceeded ? "text-destructive" : "text-info"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
budgetExceeded ? "text-destructive" : "text-info"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{budgetExceeded ? (
|
||||||
|
<>
|
||||||
|
{formatPercentage(
|
||||||
|
category.budgetUsedPercentage,
|
||||||
|
config.percentageDigits,
|
||||||
|
)}{" "}
|
||||||
|
do limite
|
||||||
|
{config.includeBudgetAmount &&
|
||||||
|
category.budgetAmount !== null
|
||||||
|
? ` ${formatCurrency(category.budgetAmount)}`
|
||||||
|
: ""}{" "}
|
||||||
|
- excedeu em {formatCurrency(exceededAmount)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{formatPercentage(
|
||||||
|
category.budgetUsedPercentage,
|
||||||
|
config.percentageDigits,
|
||||||
|
)}{" "}
|
||||||
|
do limite
|
||||||
|
{config.includeBudgetAmount &&
|
||||||
|
category.budgetAmount !== null
|
||||||
|
? ` ${formatCurrency(category.budgetAmount)}`
|
||||||
|
: ""}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="chart" className="mt-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ payload }) =>
|
||||||
|
formatPercentage(
|
||||||
|
(payload as { percentage?: number } | undefined)
|
||||||
|
?.percentage ?? 0,
|
||||||
|
config.percentageDigits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
outerRadius={75}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="category"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = payload[0]?.payload;
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-foreground">
|
||||||
|
{formatCurrency(entry.value)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatPercentage(
|
||||||
|
entry.percentage,
|
||||||
|
config.percentageDigits,
|
||||||
|
)}{" "}
|
||||||
|
do total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
<div className="min-w-[140px] flex flex-col gap-2">
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<div key={`legend-${index}`} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="size-3 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: entry.fill }}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -26,9 +27,9 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
|
||||||
import type { CategoryHistoryData } from "@/lib/dashboard/categories/category-history";
|
import type { CategoryHistoryData } from "@/lib/dashboard/categories/category-history";
|
||||||
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
|
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
|
||||||
|
import { formatCurrency, formatCurrencyCompact } from "@/lib/utils/currency";
|
||||||
import { getIconComponent } from "@/lib/utils/icons";
|
import { getIconComponent } from "@/lib/utils/icons";
|
||||||
|
|
||||||
type CategoryHistoryWidgetProps = {
|
type CategoryHistoryWidgetProps = {
|
||||||
@@ -124,33 +125,6 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
|||||||
return config;
|
return config;
|
||||||
}, [filteredCategories]);
|
}, [filteredCategories]);
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
|
||||||
return new Intl.NumberFormat("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrencyCompact = (value: number) => {
|
|
||||||
if (value >= 1000) {
|
|
||||||
return new Intl.NumberFormat("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
notation: "compact",
|
|
||||||
}).format(value);
|
|
||||||
}
|
|
||||||
return new Intl.NumberFormat("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCategory = (categoryId: string) => {
|
const handleAddCategory = (categoryId: string) => {
|
||||||
if (
|
if (
|
||||||
categoryId &&
|
categoryId &&
|
||||||
@@ -217,7 +191,9 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
|||||||
style={{ borderColor: color }}
|
style={{ borderColor: color }}
|
||||||
>
|
>
|
||||||
{IconComponent ? (
|
{IconComponent ? (
|
||||||
<IconComponent className="size-4" style={{ color }} />
|
<span style={{ color }}>
|
||||||
|
<IconComponent className="size-4" />
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="size-3 rounded-sm"
|
className="size-3 rounded-sm"
|
||||||
@@ -383,7 +359,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
tickFormatter={formatCurrencyCompact}
|
tickFormatter={(value) => formatCurrencyCompact(Number(value))}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={({ active, payload }) => {
|
content={({ active, payload }) => {
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ import {
|
|||||||
RiEyeOffLine,
|
RiEyeOffLine,
|
||||||
RiTodoLine,
|
RiTodoLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { useCallback, useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { NoteDialog } from "@/components/anotacoes/note-dialog";
|
import { NoteDialog } from "@/components/anotacoes/note-dialog";
|
||||||
import { SortableWidget } from "@/components/dashboard/sortable-widget";
|
import { SortableWidget } from "@/components/dashboard/sortable-widget";
|
||||||
import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog";
|
import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog";
|
||||||
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
||||||
import type { SelectOption } from "@/components/lancamentos/types";
|
import type { SelectOption } from "@/components/lancamentos/types";
|
||||||
|
import { ExpandableWidgetCard } from "@/components/shared/expandable-widget-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import WidgetCard from "@/components/widget-card";
|
|
||||||
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
||||||
import {
|
import {
|
||||||
resetWidgetPreferences,
|
resetWidgetPreferences,
|
||||||
@@ -58,6 +58,8 @@ type DashboardGridEditableProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
|
||||||
|
|
||||||
export function DashboardGridEditable({
|
export function DashboardGridEditable({
|
||||||
data,
|
data,
|
||||||
period,
|
period,
|
||||||
@@ -68,9 +70,8 @@ export function DashboardGridEditable({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
// Initialize widget order and hidden state
|
// Initialize widget order and hidden state
|
||||||
const defaultOrder = widgetsConfig.map((w) => w.id);
|
|
||||||
const [widgetOrder, setWidgetOrder] = useState<string[]>(
|
const [widgetOrder, setWidgetOrder] = useState<string[]>(
|
||||||
initialPreferences?.order ?? defaultOrder,
|
initialPreferences?.order ?? DEFAULT_WIDGET_ORDER,
|
||||||
);
|
);
|
||||||
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
||||||
initialPreferences?.hidden ?? [],
|
initialPreferences?.hidden ?? [],
|
||||||
@@ -118,7 +119,7 @@ export function DashboardGridEditable({
|
|||||||
return ordered;
|
return ordered;
|
||||||
}, [widgetOrder, hiddenWidgets]);
|
}, [widgetOrder, hiddenWidgets]);
|
||||||
|
|
||||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
@@ -128,10 +129,9 @@ export function DashboardGridEditable({
|
|||||||
return arrayMove(items, oldIndex, newIndex);
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleToggleWidget = useCallback(
|
const handleToggleWidget = (widgetId: string) => {
|
||||||
(widgetId: string) => {
|
|
||||||
const newHidden = hiddenWidgets.includes(widgetId)
|
const newHidden = hiddenWidgets.includes(widgetId)
|
||||||
? hiddenWidgets.filter((id) => id !== widgetId)
|
? hiddenWidgets.filter((id) => id !== widgetId)
|
||||||
: [...hiddenWidgets, widgetId];
|
: [...hiddenWidgets, widgetId];
|
||||||
@@ -145,27 +145,25 @@ export function DashboardGridEditable({
|
|||||||
hidden: newHidden,
|
hidden: newHidden,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
[hiddenWidgets, widgetOrder],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleHideWidget = useCallback((widgetId: string) => {
|
const handleHideWidget = (widgetId: string) => {
|
||||||
setHiddenWidgets((prev) => [...prev, widgetId]);
|
setHiddenWidgets((prev) => [...prev, widgetId]);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleStartEditing = useCallback(() => {
|
const handleStartEditing = () => {
|
||||||
setOriginalOrder(widgetOrder);
|
setOriginalOrder(widgetOrder);
|
||||||
setOriginalHidden(hiddenWidgets);
|
setOriginalHidden(hiddenWidgets);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}, [widgetOrder, hiddenWidgets]);
|
};
|
||||||
|
|
||||||
const handleCancelEditing = useCallback(() => {
|
const handleCancelEditing = () => {
|
||||||
setWidgetOrder(originalOrder);
|
setWidgetOrder(originalOrder);
|
||||||
setHiddenWidgets(originalHidden);
|
setHiddenWidgets(originalHidden);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}, [originalOrder, originalHidden]);
|
};
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updateWidgetPreferences({
|
const result = await updateWidgetPreferences({
|
||||||
order: widgetOrder,
|
order: widgetOrder,
|
||||||
@@ -179,21 +177,21 @@ export function DashboardGridEditable({
|
|||||||
toast.error(result.error ?? "Erro ao salvar");
|
toast.error(result.error ?? "Erro ao salvar");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [widgetOrder, hiddenWidgets]);
|
};
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await resetWidgetPreferences();
|
const result = await resetWidgetPreferences();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setWidgetOrder(defaultOrder);
|
setWidgetOrder(DEFAULT_WIDGET_ORDER);
|
||||||
setHiddenWidgets([]);
|
setHiddenWidgets([]);
|
||||||
toast.success("Preferências restauradas!");
|
toast.success("Preferências restauradas!");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? "Erro ao restaurar");
|
toast.error(result.error ?? "Erro ao restaurar");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [defaultOrder]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -360,14 +358,14 @@ export function DashboardGridEditable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<WidgetCard
|
<ExpandableWidgetCard
|
||||||
title={widget.title}
|
title={widget.title}
|
||||||
subtitle={widget.subtitle}
|
subtitle={widget.subtitle}
|
||||||
icon={widget.icon}
|
icon={widget.icon}
|
||||||
action={widget.action}
|
action={widget.action}
|
||||||
>
|
>
|
||||||
{widget.component({ data, period })}
|
{widget.component({ data, period })}
|
||||||
</WidgetCard>
|
</ExpandableWidgetCard>
|
||||||
</div>
|
</div>
|
||||||
</SortableWidget>
|
</SortableWidget>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RiIncreaseDecreaseLine,
|
RiIncreaseDecreaseLine,
|
||||||
RiSubtractLine,
|
RiSubtractLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardAction,
|
CardAction,
|
||||||
@@ -14,10 +15,10 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import type { DashboardCardMetrics } from "@/lib/dashboard/metrics";
|
import type { DashboardCardMetrics } from "@/lib/dashboard/dashboard-metrics";
|
||||||
import MoneyValues from "../money-values";
|
import { formatPercentage } from "@/lib/utils/percentage";
|
||||||
|
|
||||||
type SectionCardsProps = {
|
type DashboardMetricsCardsProps = {
|
||||||
metrics: DashboardCardMetrics;
|
metrics: DashboardCardMetrics;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,7 +71,11 @@ const getPercentChange = (current: number, previous: number): string => {
|
|||||||
|
|
||||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||||
return Number.isFinite(change) && Math.abs(change) < 1000000
|
return Number.isFinite(change) && Math.abs(change) < 1000000
|
||||||
? `${change > 0 ? "+" : ""}${change.toFixed(1)}%`
|
? formatPercentage(change, {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
signDisplay: "always",
|
||||||
|
})
|
||||||
: "—";
|
: "—";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,7 +87,7 @@ const getTrendColor = (trend: Trend, invertTrend: boolean): string => {
|
|||||||
: "text-destructive border-destructive";
|
: "text-destructive border-destructive";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SectionCards({ metrics }: SectionCardsProps) {
|
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||||
{CARDS.map(({ label, key, icon: Icon, invertTrend }) => {
|
{CARDS.map(({ label, key, icon: Icon, invertTrend }) => {
|
||||||
@@ -94,8 +99,8 @@ export function SectionCards({ metrics }: SectionCardsProps) {
|
|||||||
return (
|
return (
|
||||||
<Card key={label} className="@container/card gap-2">
|
<Card key={label} className="@container/card gap-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-1">
|
<CardTitle className="flex items-center gap-1 font-[aeonik] tracking-tighter lowercase">
|
||||||
<Icon className="size-4 text-primary" />
|
<Icon className="size-4" />
|
||||||
{label}
|
{label}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<MoneyValues className="text-2xl" amount={metric.current} />
|
<MoneyValues className="text-2xl" amount={metric.current} />
|
||||||
@@ -108,9 +113,9 @@ export function SectionCards({ metrics }: SectionCardsProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="flex-col items-start gap-2 text-sm">
|
<CardFooter className="flex-col items-start gap-2 text-sm">
|
||||||
<div className="line-clamp-1 flex gap-2 text-xs">
|
<div className="line-clamp-1 flex gap-2 text-xs">
|
||||||
Mês anterior
|
mês anterior
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-foreground">
|
||||||
<MoneyValues amount={metric.previous} />
|
<MoneyValues amount={metric.previous} />
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
@@ -1,79 +1,18 @@
|
|||||||
"use client";
|
import { formatCurrentDate, getGreeting } from "./welcome-widget";
|
||||||
|
|
||||||
import MagnetLines from "../magnet-lines";
|
export function DashboardWelcome({ name }: { name?: string | null }) {
|
||||||
import { Card } from "../ui/card";
|
|
||||||
|
|
||||||
type DashboardWelcomeProps = {
|
|
||||||
name?: string | null;
|
|
||||||
disableMagnetlines?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const capitalizeFirstLetter = (value: string) =>
|
|
||||||
value.length > 0 ? value[0]?.toUpperCase() + value.slice(1) : value;
|
|
||||||
|
|
||||||
const formatCurrentDate = (date = new Date()) => {
|
|
||||||
const formatted = new Intl.DateTimeFormat("pt-BR", {
|
|
||||||
weekday: "long",
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
timeZone: "America/Sao_Paulo",
|
|
||||||
}).format(date);
|
|
||||||
|
|
||||||
return capitalizeFirstLetter(formatted);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGreeting = () => {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Get hour in Brasilia timezone
|
|
||||||
const brasiliaHour = new Intl.DateTimeFormat("pt-BR", {
|
|
||||||
hour: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
timeZone: "America/Sao_Paulo",
|
|
||||||
}).format(now);
|
|
||||||
|
|
||||||
const hour = parseInt(brasiliaHour, 10);
|
|
||||||
|
|
||||||
if (hour >= 5 && hour < 12) {
|
|
||||||
return "Bom dia";
|
|
||||||
} else if (hour >= 12 && hour < 18) {
|
|
||||||
return "Boa tarde";
|
|
||||||
} else {
|
|
||||||
return "Boa noite";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DashboardWelcome({
|
|
||||||
name,
|
|
||||||
disableMagnetlines = false,
|
|
||||||
}: DashboardWelcomeProps) {
|
|
||||||
const displayName = name && name.trim().length > 0 ? name : "Administrador";
|
const displayName = name && name.trim().length > 0 ? name : "Administrador";
|
||||||
const formattedDate = formatCurrentDate();
|
const formattedDate = formatCurrentDate();
|
||||||
const greeting = getGreeting();
|
const greeting = getGreeting();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative px-6 py-12 bg-welcome-banner overflow-hidden">
|
<section className="p-2">
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
|
<div className="tracking-tight">
|
||||||
<MagnetLines
|
<h1 className="text-xl font-[aeonik]">
|
||||||
rows={8}
|
{greeting}, {displayName}
|
||||||
columns={16}
|
|
||||||
containerSize="100%"
|
|
||||||
lineColor="currentColor"
|
|
||||||
lineWidth="0.4vmin"
|
|
||||||
lineHeight="5vmin"
|
|
||||||
baseAngle={0}
|
|
||||||
className="text-welcome-banner-foreground"
|
|
||||||
disabled={disableMagnetlines}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative tracking-tight text-welcome-banner-foreground">
|
|
||||||
<h1 className="text-xl">
|
|
||||||
{greeting}, {displayName}! <span aria-hidden="true">👋</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm opacity-90">{formattedDate}</p>
|
<p className="text-sm mt-1">{formattedDate}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,328 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
RiArrowDownSFill,
|
|
||||||
RiArrowUpSFill,
|
|
||||||
RiExternalLinkLine,
|
|
||||||
RiListUnordered,
|
|
||||||
RiPieChart2Line,
|
|
||||||
RiPieChartLine,
|
|
||||||
RiWallet3Line,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Pie, PieChart, Tooltip } from "recharts";
|
|
||||||
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
|
||||||
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
|
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
|
||||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
|
||||||
|
|
||||||
type ExpensesByCategoryWidgetWithChartProps = {
|
type ExpensesByCategoryWidgetWithChartProps = {
|
||||||
data: ExpensesByCategoryData;
|
data: ExpensesByCategoryData;
|
||||||
period: string;
|
period: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPercentage = (value: number) => {
|
|
||||||
return `${Math.abs(value).toFixed(0)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
|
||||||
new Intl.NumberFormat("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
}).format(value);
|
|
||||||
|
|
||||||
export function ExpensesByCategoryWidgetWithChart({
|
export function ExpensesByCategoryWidgetWithChart({
|
||||||
data,
|
data,
|
||||||
period,
|
period,
|
||||||
}: ExpensesByCategoryWidgetWithChartProps) {
|
}: ExpensesByCategoryWidgetWithChartProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
|
|
||||||
const periodParam = formatPeriodForUrl(period);
|
|
||||||
|
|
||||||
// Configuração do chart com cores do CSS
|
|
||||||
const chartConfig = useMemo(() => {
|
|
||||||
const config: ChartConfig = {};
|
|
||||||
const colors = [
|
|
||||||
"var(--chart-1)",
|
|
||||||
"var(--chart-2)",
|
|
||||||
"var(--chart-3)",
|
|
||||||
"var(--chart-4)",
|
|
||||||
"var(--chart-5)",
|
|
||||||
"var(--chart-1)",
|
|
||||||
"var(--chart-2)",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (data.categories.length <= 7) {
|
|
||||||
data.categories.forEach((category, index) => {
|
|
||||||
config[category.categoryId] = {
|
|
||||||
label: category.categoryName,
|
|
||||||
color: colors[index % colors.length],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Top 7 + Outros
|
|
||||||
const top7 = data.categories.slice(0, 7);
|
|
||||||
top7.forEach((category, index) => {
|
|
||||||
config[category.categoryId] = {
|
|
||||||
label: category.categoryName,
|
|
||||||
color: colors[index % colors.length],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
config.outros = {
|
|
||||||
label: "Outros",
|
|
||||||
color: "var(--chart-6)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}, [data.categories]);
|
|
||||||
|
|
||||||
// Preparar dados para o gráfico de pizza - Top 7 + Outros
|
|
||||||
const chartData = useMemo(() => {
|
|
||||||
if (data.categories.length <= 7) {
|
|
||||||
return data.categories.map((category) => ({
|
|
||||||
category: category.categoryId,
|
|
||||||
name: category.categoryName,
|
|
||||||
value: category.currentAmount,
|
|
||||||
percentage: category.percentageOfTotal,
|
|
||||||
fill: chartConfig[category.categoryId]?.color,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pegar top 7 categorias
|
|
||||||
const top7 = data.categories.slice(0, 7);
|
|
||||||
const others = data.categories.slice(7);
|
|
||||||
|
|
||||||
// Somar o restante
|
|
||||||
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
|
|
||||||
const othersPercentage = others.reduce(
|
|
||||||
(sum, cat) => sum + cat.percentageOfTotal,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const top7Data = top7.map((category) => ({
|
|
||||||
category: category.categoryId,
|
|
||||||
name: category.categoryName,
|
|
||||||
value: category.currentAmount,
|
|
||||||
percentage: category.percentageOfTotal,
|
|
||||||
fill: chartConfig[category.categoryId]?.color,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Adicionar "Outros" se houver
|
|
||||||
if (others.length > 0) {
|
|
||||||
top7Data.push({
|
|
||||||
category: "outros",
|
|
||||||
name: "Outros",
|
|
||||||
value: othersTotal,
|
|
||||||
percentage: othersPercentage,
|
|
||||||
fill: chartConfig.outros?.color,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return top7Data;
|
|
||||||
}, [data.categories, chartConfig]);
|
|
||||||
|
|
||||||
if (data.categories.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<WidgetEmptyState
|
<CategoryBreakdownWidgetView
|
||||||
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
data={data}
|
||||||
title="Nenhuma despesa encontrada"
|
period={period}
|
||||||
description="Quando houver despesas registradas, elas aparecerão aqui."
|
variant="expense"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<TabsList className="grid grid-cols-2">
|
|
||||||
<TabsTrigger value="list" className="text-xs">
|
|
||||||
<RiListUnordered className="size-3.5 mr-1" />
|
|
||||||
Lista
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="chart" className="text-xs">
|
|
||||||
<RiPieChart2Line className="size-3.5 mr-1" />
|
|
||||||
Gráfico
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value="list" className="mt-0">
|
|
||||||
<div className="flex flex-col px-0">
|
|
||||||
{data.categories.map((category, index) => {
|
|
||||||
const hasIncrease =
|
|
||||||
category.percentageChange !== null &&
|
|
||||||
category.percentageChange > 0;
|
|
||||||
const hasDecrease =
|
|
||||||
category.percentageChange !== null &&
|
|
||||||
category.percentageChange < 0;
|
|
||||||
const hasBudget = category.budgetAmount !== null;
|
|
||||||
const budgetExceeded =
|
|
||||||
hasBudget &&
|
|
||||||
category.budgetUsedPercentage !== null &&
|
|
||||||
category.budgetUsedPercentage > 100;
|
|
||||||
|
|
||||||
const exceededAmount =
|
|
||||||
budgetExceeded && category.budgetAmount
|
|
||||||
? category.currentAmount - category.budgetAmount
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={category.categoryId}
|
|
||||||
className="flex flex-col py-2 border-b border-dashed last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<CategoryIconBadge
|
|
||||||
icon={category.categoryIcon}
|
|
||||||
name={category.categoryName}
|
|
||||||
colorIndex={index}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
|
|
||||||
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{category.categoryName}
|
|
||||||
</span>
|
|
||||||
<RiExternalLinkLine
|
|
||||||
className="size-3 shrink-0 text-muted-foreground"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{formatPercentage(category.percentageOfTotal)} da
|
|
||||||
despesa total
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
|
||||||
<MoneyValues
|
|
||||||
className="text-foreground"
|
|
||||||
amount={category.currentAmount}
|
|
||||||
/>
|
|
||||||
{category.percentageChange !== null && (
|
|
||||||
<span
|
|
||||||
className={`flex items-center gap-0.5 text-xs ${
|
|
||||||
hasIncrease
|
|
||||||
? "text-destructive"
|
|
||||||
: hasDecrease
|
|
||||||
? "text-success"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{hasIncrease && <RiArrowUpSFill className="size-3" />}
|
|
||||||
{hasDecrease && <RiArrowDownSFill className="size-3" />}
|
|
||||||
{formatPercentage(category.percentageChange)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasBudget && category.budgetUsedPercentage !== null && (
|
|
||||||
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
|
||||||
<RiWallet3Line
|
|
||||||
className={`size-3 ${
|
|
||||||
budgetExceeded ? "text-destructive" : "text-info"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
budgetExceeded ? "text-destructive" : "text-info"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{budgetExceeded ? (
|
|
||||||
<>
|
|
||||||
{formatPercentage(category.budgetUsedPercentage)} do
|
|
||||||
limite - excedeu em {formatCurrency(exceededAmount)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{formatPercentage(category.budgetUsedPercentage)} do
|
|
||||||
limite
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="chart" className="mt-0">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={chartData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={(entry) => formatPercentage(entry.percentage)}
|
|
||||||
outerRadius={75}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="category"
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const data = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
|
||||||
{data.name}
|
|
||||||
</span>
|
|
||||||
<span className="font-bold text-foreground">
|
|
||||||
{formatCurrency(data.value)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatPercentage(data.percentage)} do total
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ChartContainer>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 min-w-[140px]">
|
|
||||||
{chartData.map((entry, index) => (
|
|
||||||
<div key={`legend-${index}`} className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="size-3 rounded-sm shrink-0"
|
|
||||||
style={{ backgroundColor: entry.fill }}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
{entry.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,146 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiFundsLine, RiPencilLine } from "@remixicon/react";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import { BudgetDialog } from "@/components/orcamentos/budget-dialog";
|
|
||||||
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import type { GoalsProgressData } from "@/lib/dashboard/goals-progress";
|
import type { GoalsProgressData } from "@/lib/dashboard/goals-progress";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { useGoalsProgressWidgetController } from "@/lib/dashboard/use-goals-progress-widget-controller";
|
||||||
|
import { GoalsProgressWidgetView } from "./goals-progress/goals-progress-widget-view";
|
||||||
|
|
||||||
type GoalsProgressWidgetProps = {
|
type GoalsProgressWidgetProps = {
|
||||||
data: GoalsProgressData;
|
data: GoalsProgressData;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) =>
|
|
||||||
Math.min(max, Math.max(min, value));
|
|
||||||
|
|
||||||
const formatPercentage = (value: number, withSign = false) =>
|
|
||||||
`${new Intl.NumberFormat("pt-BR", {
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
...(withSign ? { signDisplay: "always" as const } : {}),
|
|
||||||
}).format(value)}%`;
|
|
||||||
|
|
||||||
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
|
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const {
|
||||||
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
selectedBudget,
|
||||||
|
editOpen,
|
||||||
const categories = useMemo<BudgetCategory[]>(
|
categories,
|
||||||
() =>
|
defaultPeriod,
|
||||||
data.categories.map((category) => ({
|
handleEdit,
|
||||||
id: category.id,
|
handleEditOpenChange,
|
||||||
name: category.name,
|
} = useGoalsProgressWidgetController(data);
|
||||||
icon: category.icon,
|
|
||||||
})),
|
|
||||||
[data.categories],
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultPeriod = data.items[0]?.period ?? "";
|
|
||||||
|
|
||||||
const handleEdit = useCallback((item: GoalsProgressData["items"][number]) => {
|
|
||||||
setSelectedBudget({
|
|
||||||
id: item.id,
|
|
||||||
amount: item.budgetAmount,
|
|
||||||
spent: item.spentAmount,
|
|
||||||
period: item.period,
|
|
||||||
createdAt: item.createdAt,
|
|
||||||
category: item.categoryId
|
|
||||||
? {
|
|
||||||
id: item.categoryId,
|
|
||||||
name: item.categoryName,
|
|
||||||
icon: item.categoryIcon,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
setEditOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
|
||||||
setEditOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
setSelectedBudget(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (data.items.length === 0) {
|
|
||||||
return (
|
|
||||||
<WidgetEmptyState
|
|
||||||
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
|
|
||||||
title="Nenhum orçamento para o período"
|
|
||||||
description="Cadastre orçamentos para acompanhar o progresso das metas."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 px-0">
|
<GoalsProgressWidgetView
|
||||||
<ul className="flex flex-col">
|
data={data}
|
||||||
{data.items.map((item, index) => {
|
selectedBudget={selectedBudget}
|
||||||
const statusColor =
|
editOpen={editOpen}
|
||||||
item.status === "exceeded" ? "text-destructive" : "";
|
|
||||||
const progressValue = clamp(item.usedPercentage, 0, 100);
|
|
||||||
const percentageDelta = item.usedPercentage - 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={item.id}
|
|
||||||
className="border-b border-dashed py-2 last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
|
||||||
<CategoryIconBadge
|
|
||||||
icon={item.categoryIcon}
|
|
||||||
name={item.categoryName}
|
|
||||||
colorIndex={index}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
|
||||||
{item.categoryName}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
<MoneyValues amount={item.spentAmount} /> de{" "}
|
|
||||||
<MoneyValues amount={item.budgetAmount} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<span className={`text-xs font-medium ${statusColor}`}>
|
|
||||||
{formatPercentage(percentageDelta, true)}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="size-7 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => handleEdit(item)}
|
|
||||||
aria-label={`Editar orçamento de ${item.categoryName}`}
|
|
||||||
>
|
|
||||||
<RiPencilLine className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 ml-11">
|
|
||||||
<Progress value={progressValue} />
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<BudgetDialog
|
|
||||||
mode="update"
|
|
||||||
budget={selectedBudget ?? undefined}
|
|
||||||
categories={categories}
|
categories={categories}
|
||||||
defaultPeriod={defaultPeriod}
|
defaultPeriod={defaultPeriod}
|
||||||
open={editOpen && !!selectedBudget}
|
onEdit={handleEdit}
|
||||||
onOpenChange={handleEditOpenChange}
|
onEditOpenChange={handleEditOpenChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
70
components/dashboard/goals-progress/goal-progress-item.tsx
Normal file
70
components/dashboard/goals-progress/goal-progress-item.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import type { GoalProgressItem as GoalProgressItemData } from "@/lib/dashboard/goals-progress";
|
||||||
|
import {
|
||||||
|
clampGoalProgress,
|
||||||
|
formatGoalProgressPercentage,
|
||||||
|
getGoalProgressStatusColorClass,
|
||||||
|
} from "@/lib/dashboard/goals-progress-helpers";
|
||||||
|
|
||||||
|
type GoalProgressItemProps = {
|
||||||
|
item: GoalProgressItemData;
|
||||||
|
index: number;
|
||||||
|
onEdit: (item: GoalProgressItemData) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GoalProgressItem({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
onEdit,
|
||||||
|
}: GoalProgressItemProps) {
|
||||||
|
const statusColor = getGoalProgressStatusColorClass(item.status);
|
||||||
|
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
|
||||||
|
const percentageDelta = item.usedPercentage - 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="border-b border-dashed py-2 last:border-b-0 last:pb-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||||
|
<CategoryIconBadge
|
||||||
|
icon={item.categoryIcon}
|
||||||
|
name={item.categoryName}
|
||||||
|
colorIndex={index}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{item.categoryName}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
<MoneyValues amount={item.spentAmount} /> de{" "}
|
||||||
|
<MoneyValues amount={item.budgetAmount} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className={`text-xs font-medium ${statusColor}`}>
|
||||||
|
{formatGoalProgressPercentage(percentageDelta, true)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="size-7 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
aria-label={`Editar orçamento de ${item.categoryName}`}
|
||||||
|
>
|
||||||
|
<RiPencilLine className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-11 mt-1.5">
|
||||||
|
<Progress value={progressValue} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/dashboard/goals-progress/goals-progress-list.tsx
Normal file
34
components/dashboard/goals-progress/goals-progress-list.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { RiFundsLine } from "@remixicon/react";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
import type { GoalProgressItem } from "@/lib/dashboard/goals-progress";
|
||||||
|
import { GoalProgressItem as GoalProgressListItem } from "./goal-progress-item";
|
||||||
|
|
||||||
|
type GoalsProgressListProps = {
|
||||||
|
items: GoalProgressItem[];
|
||||||
|
onEdit: (item: GoalProgressItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
|
||||||
|
title="Nenhum orçamento para o período"
|
||||||
|
description="Cadastre orçamentos para acompanhar o progresso das metas."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<GoalProgressListItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
onEdit={onEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { BudgetDialog } from "@/components/orcamentos/budget-dialog";
|
||||||
|
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
|
||||||
|
|
||||||
|
type GoalsProgressWidgetDialogsProps = {
|
||||||
|
selectedBudget: Budget | null;
|
||||||
|
editOpen: boolean;
|
||||||
|
categories: BudgetCategory[];
|
||||||
|
defaultPeriod: string;
|
||||||
|
onEditOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GoalsProgressWidgetDialogs({
|
||||||
|
selectedBudget,
|
||||||
|
editOpen,
|
||||||
|
categories,
|
||||||
|
defaultPeriod,
|
||||||
|
onEditOpenChange,
|
||||||
|
}: GoalsProgressWidgetDialogsProps) {
|
||||||
|
return (
|
||||||
|
<BudgetDialog
|
||||||
|
mode="update"
|
||||||
|
budget={selectedBudget ?? undefined}
|
||||||
|
categories={categories}
|
||||||
|
defaultPeriod={defaultPeriod}
|
||||||
|
open={editOpen && !!selectedBudget}
|
||||||
|
onOpenChange={onEditOpenChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
|
||||||
|
import type {
|
||||||
|
GoalProgressItem,
|
||||||
|
GoalsProgressData,
|
||||||
|
} from "@/lib/dashboard/goals-progress";
|
||||||
|
import { GoalsProgressList } from "./goals-progress-list";
|
||||||
|
import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";
|
||||||
|
|
||||||
|
type GoalsProgressWidgetViewProps = {
|
||||||
|
data: GoalsProgressData;
|
||||||
|
selectedBudget: Budget | null;
|
||||||
|
editOpen: boolean;
|
||||||
|
categories: BudgetCategory[];
|
||||||
|
defaultPeriod: string;
|
||||||
|
onEdit: (item: GoalProgressItem) => void;
|
||||||
|
onEditOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GoalsProgressWidgetView({
|
||||||
|
data,
|
||||||
|
selectedBudget,
|
||||||
|
editOpen,
|
||||||
|
categories,
|
||||||
|
defaultPeriod,
|
||||||
|
onEdit,
|
||||||
|
onEditOpenChange,
|
||||||
|
}: GoalsProgressWidgetViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 px-0">
|
||||||
|
<GoalsProgressList items={data.items} onEdit={onEdit} />
|
||||||
|
|
||||||
|
<GoalsProgressWidgetDialogs
|
||||||
|
selectedBudget={selectedBudget}
|
||||||
|
editOpen={editOpen}
|
||||||
|
categories={categories}
|
||||||
|
defaultPeriod={defaultPeriod}
|
||||||
|
onEditOpenChange={onEditOpenChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,331 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
RiArrowDownSFill,
|
|
||||||
RiArrowUpSFill,
|
|
||||||
RiExternalLinkLine,
|
|
||||||
RiListUnordered,
|
|
||||||
RiPieChart2Line,
|
|
||||||
RiPieChartLine,
|
|
||||||
RiWallet3Line,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Pie, PieChart, Tooltip } from "recharts";
|
|
||||||
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
|
||||||
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
|
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
|
||||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
|
||||||
|
|
||||||
type IncomeByCategoryWidgetWithChartProps = {
|
type IncomeByCategoryWidgetWithChartProps = {
|
||||||
data: IncomeByCategoryData;
|
data: IncomeByCategoryData;
|
||||||
period: string;
|
period: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPercentage = (value: number) => {
|
|
||||||
return `${Math.abs(value).toFixed(1)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
|
||||||
new Intl.NumberFormat("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
}).format(value);
|
|
||||||
|
|
||||||
export function IncomeByCategoryWidgetWithChart({
|
export function IncomeByCategoryWidgetWithChart({
|
||||||
data,
|
data,
|
||||||
period,
|
period,
|
||||||
}: IncomeByCategoryWidgetWithChartProps) {
|
}: IncomeByCategoryWidgetWithChartProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
|
|
||||||
const periodParam = formatPeriodForUrl(period);
|
|
||||||
|
|
||||||
// Configuração do chart com cores do CSS
|
|
||||||
const chartConfig = useMemo(() => {
|
|
||||||
const config: ChartConfig = {};
|
|
||||||
const colors = [
|
|
||||||
"var(--chart-1)",
|
|
||||||
"var(--chart-2)",
|
|
||||||
"var(--chart-3)",
|
|
||||||
"var(--chart-4)",
|
|
||||||
"var(--chart-5)",
|
|
||||||
"var(--chart-1)",
|
|
||||||
"var(--chart-2)",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (data.categories.length <= 7) {
|
|
||||||
data.categories.forEach((category, index) => {
|
|
||||||
config[category.categoryId] = {
|
|
||||||
label: category.categoryName,
|
|
||||||
color: colors[index % colors.length],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Top 7 + Outros
|
|
||||||
const top7 = data.categories.slice(0, 7);
|
|
||||||
top7.forEach((category, index) => {
|
|
||||||
config[category.categoryId] = {
|
|
||||||
label: category.categoryName,
|
|
||||||
color: colors[index % colors.length],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
config.outros = {
|
|
||||||
label: "Outros",
|
|
||||||
color: "var(--chart-6)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}, [data.categories]);
|
|
||||||
|
|
||||||
// Preparar dados para o gráfico de pizza - Top 7 + Outros
|
|
||||||
const chartData = useMemo(() => {
|
|
||||||
if (data.categories.length <= 7) {
|
|
||||||
return data.categories.map((category) => ({
|
|
||||||
category: category.categoryId,
|
|
||||||
name: category.categoryName,
|
|
||||||
value: category.currentAmount,
|
|
||||||
percentage: category.percentageOfTotal,
|
|
||||||
fill: chartConfig[category.categoryId]?.color,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pegar top 7 categorias
|
|
||||||
const top7 = data.categories.slice(0, 7);
|
|
||||||
const others = data.categories.slice(7);
|
|
||||||
|
|
||||||
// Somar o restante
|
|
||||||
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
|
|
||||||
const othersPercentage = others.reduce(
|
|
||||||
(sum, cat) => sum + cat.percentageOfTotal,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const top7Data = top7.map((category) => ({
|
|
||||||
category: category.categoryId,
|
|
||||||
name: category.categoryName,
|
|
||||||
value: category.currentAmount,
|
|
||||||
percentage: category.percentageOfTotal,
|
|
||||||
fill: chartConfig[category.categoryId]?.color,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Adicionar "Outros" se houver
|
|
||||||
if (others.length > 0) {
|
|
||||||
top7Data.push({
|
|
||||||
category: "outros",
|
|
||||||
name: "Outros",
|
|
||||||
value: othersTotal,
|
|
||||||
percentage: othersPercentage,
|
|
||||||
fill: chartConfig.outros?.color,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return top7Data;
|
|
||||||
}, [data.categories, chartConfig]);
|
|
||||||
|
|
||||||
if (data.categories.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<WidgetEmptyState
|
<CategoryBreakdownWidgetView data={data} period={period} variant="income" />
|
||||||
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
|
||||||
title="Nenhuma receita encontrada"
|
|
||||||
description="Quando houver receitas registradas, elas aparecerão aqui."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<TabsList className="grid grid-cols-2">
|
|
||||||
<TabsTrigger value="list" className="text-xs">
|
|
||||||
<RiListUnordered className="size-3.5 mr-1" />
|
|
||||||
Lista
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="chart" className="text-xs">
|
|
||||||
<RiPieChart2Line className="size-3.5 mr-1" />
|
|
||||||
Gráfico
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value="list" className="mt-0">
|
|
||||||
<div className="flex flex-col px-0">
|
|
||||||
{data.categories.map((category, index) => {
|
|
||||||
const hasIncrease =
|
|
||||||
category.percentageChange !== null &&
|
|
||||||
category.percentageChange > 0;
|
|
||||||
const hasDecrease =
|
|
||||||
category.percentageChange !== null &&
|
|
||||||
category.percentageChange < 0;
|
|
||||||
const hasBudget = category.budgetAmount !== null;
|
|
||||||
const budgetExceeded =
|
|
||||||
hasBudget &&
|
|
||||||
category.budgetUsedPercentage !== null &&
|
|
||||||
category.budgetUsedPercentage > 100;
|
|
||||||
|
|
||||||
const exceededAmount =
|
|
||||||
budgetExceeded && category.budgetAmount
|
|
||||||
? category.currentAmount - category.budgetAmount
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={category.categoryId}
|
|
||||||
className="flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<CategoryIconBadge
|
|
||||||
icon={category.categoryIcon}
|
|
||||||
name={category.categoryName}
|
|
||||||
colorIndex={index}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
|
|
||||||
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{category.categoryName}
|
|
||||||
</span>
|
|
||||||
<RiExternalLinkLine
|
|
||||||
className="size-3 shrink-0 text-muted-foreground"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{formatPercentage(category.percentageOfTotal)} da
|
|
||||||
receita total
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
|
||||||
<MoneyValues
|
|
||||||
className="text-foreground"
|
|
||||||
amount={category.currentAmount}
|
|
||||||
/>
|
|
||||||
{category.percentageChange !== null && (
|
|
||||||
<span
|
|
||||||
className={`flex items-center gap-0.5 text-xs ${
|
|
||||||
hasIncrease
|
|
||||||
? "text-success"
|
|
||||||
: hasDecrease
|
|
||||||
? "text-destructive"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{hasIncrease && <RiArrowUpSFill className="size-3" />}
|
|
||||||
{hasDecrease && <RiArrowDownSFill className="size-3" />}
|
|
||||||
{formatPercentage(category.percentageChange)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasBudget &&
|
|
||||||
category.budgetUsedPercentage !== null &&
|
|
||||||
category.budgetAmount !== null && (
|
|
||||||
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
|
||||||
<RiWallet3Line
|
|
||||||
className={`size-3 ${
|
|
||||||
budgetExceeded ? "text-destructive" : "text-info"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
budgetExceeded ? "text-destructive" : "text-info"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{budgetExceeded ? (
|
|
||||||
<>
|
|
||||||
{formatPercentage(category.budgetUsedPercentage)} do
|
|
||||||
limite {formatCurrency(category.budgetAmount)} -
|
|
||||||
excedeu em {formatCurrency(exceededAmount)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{formatPercentage(category.budgetUsedPercentage)} do
|
|
||||||
limite {formatCurrency(category.budgetAmount)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="chart" className="mt-0">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={chartData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={(entry) => formatPercentage(entry.percentage)}
|
|
||||||
outerRadius={75}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="category"
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const data = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
|
||||||
{data.name}
|
|
||||||
</span>
|
|
||||||
<span className="font-bold text-foreground">
|
|
||||||
{formatCurrency(data.value)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatPercentage(data.percentage)} do total
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ChartContainer>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 min-w-[140px]">
|
|
||||||
{chartData.map((entry, index) => (
|
|
||||||
<div key={`legend-${index}`} className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="size-3 rounded-sm shrink-0"
|
|
||||||
style={{ backgroundColor: entry.fill }}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
{entry.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
import { RiLineChartLine } from "@remixicon/react";
|
import { RiLineChartLine } from "@remixicon/react";
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
} from "@/components/ui/chart";
|
} from "@/components/ui/chart";
|
||||||
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
|
||||||
import type { IncomeExpenseBalanceData } from "@/lib/dashboard/income-expense-balance";
|
import type { IncomeExpenseBalanceData } from "@/lib/dashboard/income-expense-balance";
|
||||||
|
import { formatCurrency } from "@/lib/utils/currency";
|
||||||
|
|
||||||
type IncomeExpenseBalanceWidgetProps = {
|
type IncomeExpenseBalanceWidgetProps = {
|
||||||
data: IncomeExpenseBalanceData;
|
data: IncomeExpenseBalanceData;
|
||||||
@@ -80,15 +81,6 @@ export function IncomeExpenseBalanceWidget({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
|
||||||
return new Intl.NumberFormat("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -103,7 +95,7 @@ export function IncomeExpenseBalanceWidget({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded-full"
|
className="size-2 rounded-full"
|
||||||
style={{ backgroundColor: config?.color }}
|
style={{ backgroundColor: config?.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -144,7 +136,7 @@ export function IncomeExpenseBalanceWidget({
|
|||||||
<div className="flex items-center justify-center gap-6">
|
<div className="flex items-center justify-center gap-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded-full"
|
className="size-2 rounded-full"
|
||||||
style={{ backgroundColor: chartConfig.receita.color }}
|
style={{ backgroundColor: chartConfig.receita.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -153,7 +145,7 @@ export function IncomeExpenseBalanceWidget({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded-full"
|
className="size-2 rounded-full"
|
||||||
style={{ backgroundColor: chartConfig.despesa.color }}
|
style={{ backgroundColor: chartConfig.despesa.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -162,7 +154,7 @@ export function IncomeExpenseBalanceWidget({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded-full"
|
className="size-2 rounded-full"
|
||||||
style={{ backgroundColor: chartConfig.balanco.color }}
|
style={{ backgroundColor: chartConfig.balanco.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
RiCheckboxLine,
|
RiCheckboxLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { InstallmentGroupCard } from "./installment-group-card";
|
import { InstallmentGroupCard } from "./installment-group-card";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|||||||
@@ -1,191 +1,12 @@
|
|||||||
import { RiNumbersLine } from "@remixicon/react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import { CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
|
import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
|
||||||
import {
|
import { InstallmentExpensesWidgetView } from "./installment-expenses/installment-expenses-widget-view";
|
||||||
calculateLastInstallmentDate,
|
|
||||||
formatLastInstallmentDate,
|
|
||||||
} from "@/lib/installments/utils";
|
|
||||||
import { Progress } from "../ui/progress";
|
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
|
||||||
|
|
||||||
type InstallmentExpensesWidgetProps = {
|
type InstallmentExpensesWidgetProps = {
|
||||||
data: InstallmentExpensesData;
|
data: InstallmentExpensesData;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildCompactInstallmentLabel = (
|
|
||||||
currentInstallment: number | null,
|
|
||||||
installmentCount: number | null,
|
|
||||||
) => {
|
|
||||||
if (currentInstallment && installmentCount) {
|
|
||||||
return `${currentInstallment} de ${installmentCount}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLastInstallment = (
|
|
||||||
currentInstallment: number | null,
|
|
||||||
installmentCount: number | null,
|
|
||||||
) => {
|
|
||||||
if (!currentInstallment || !installmentCount) return false;
|
|
||||||
return currentInstallment === installmentCount && installmentCount > 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateRemainingInstallments = (
|
|
||||||
currentInstallment: number | null,
|
|
||||||
installmentCount: number | null,
|
|
||||||
) => {
|
|
||||||
if (!currentInstallment || !installmentCount) return 0;
|
|
||||||
return Math.max(0, installmentCount - currentInstallment);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateRemainingAmount = (
|
|
||||||
amount: number,
|
|
||||||
currentInstallment: number | null,
|
|
||||||
installmentCount: number | null,
|
|
||||||
) => {
|
|
||||||
const remaining = calculateRemainingInstallments(
|
|
||||||
currentInstallment,
|
|
||||||
installmentCount,
|
|
||||||
);
|
|
||||||
return amount * remaining;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatEndDate = (
|
|
||||||
period: string,
|
|
||||||
currentInstallment: number | null,
|
|
||||||
installmentCount: number | null,
|
|
||||||
) => {
|
|
||||||
if (!currentInstallment || !installmentCount) return null;
|
|
||||||
|
|
||||||
const lastDate = calculateLastInstallmentDate(
|
|
||||||
period,
|
|
||||||
currentInstallment,
|
|
||||||
installmentCount,
|
|
||||||
);
|
|
||||||
|
|
||||||
return formatLastInstallmentDate(lastDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildProgress = (
|
|
||||||
currentInstallment: number | null,
|
|
||||||
installmentCount: number | null,
|
|
||||||
) => {
|
|
||||||
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.min(
|
|
||||||
100,
|
|
||||||
Math.max(0, (currentInstallment / installmentCount) * 100),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InstallmentExpensesWidget({
|
export function InstallmentExpensesWidget({
|
||||||
data,
|
data,
|
||||||
}: InstallmentExpensesWidgetProps) {
|
}: InstallmentExpensesWidgetProps) {
|
||||||
if (data.expenses.length === 0) {
|
return <InstallmentExpensesWidgetView data={data} />;
|
||||||
return (
|
|
||||||
<WidgetEmptyState
|
|
||||||
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
|
|
||||||
title="Nenhuma despesa parcelada"
|
|
||||||
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardContent className="flex flex-col gap-4 px-0">
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{data.expenses.map((expense) => {
|
|
||||||
const compactLabel = buildCompactInstallmentLabel(
|
|
||||||
expense.currentInstallment,
|
|
||||||
expense.installmentCount,
|
|
||||||
);
|
|
||||||
const isLast = isLastInstallment(
|
|
||||||
expense.currentInstallment,
|
|
||||||
expense.installmentCount,
|
|
||||||
);
|
|
||||||
const remainingInstallments = calculateRemainingInstallments(
|
|
||||||
expense.currentInstallment,
|
|
||||||
expense.installmentCount,
|
|
||||||
);
|
|
||||||
const remainingAmount = calculateRemainingAmount(
|
|
||||||
expense.amount,
|
|
||||||
expense.currentInstallment,
|
|
||||||
expense.installmentCount,
|
|
||||||
);
|
|
||||||
const endDate = formatEndDate(
|
|
||||||
expense.period,
|
|
||||||
expense.currentInstallment,
|
|
||||||
expense.installmentCount,
|
|
||||||
);
|
|
||||||
const progress = buildProgress(
|
|
||||||
expense.currentInstallment,
|
|
||||||
expense.installmentCount,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={expense.id}
|
|
||||||
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
|
||||||
{expense.name}
|
|
||||||
</p>
|
|
||||||
{compactLabel && (
|
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
|
|
||||||
{compactLabel}
|
|
||||||
{isLast && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="inline-flex">
|
|
||||||
<Image
|
|
||||||
src="/icones/party.svg"
|
|
||||||
alt="Última parcela"
|
|
||||||
width={14}
|
|
||||||
height={14}
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
/>
|
|
||||||
<span className="sr-only">Última parcela</span>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
Última parcela!
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<MoneyValues amount={expense.amount} className="shrink-0" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground ">
|
|
||||||
{endDate && `Termina em ${endDate}`}
|
|
||||||
{" | Restante "}
|
|
||||||
<MoneyValues
|
|
||||||
amount={remainingAmount}
|
|
||||||
className="inline-block font-medium"
|
|
||||||
/>{" "}
|
|
||||||
({remainingInstallments})
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Progress value={progress} className="h-2 mt-1" />
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
|
||||||
|
import { buildInstallmentExpenseDisplay } from "@/lib/dashboard/installment-expenses-helpers";
|
||||||
|
|
||||||
|
type InstallmentExpenseListItemProps = {
|
||||||
|
expense: InstallmentExpense;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InstallmentExpenseListItem({
|
||||||
|
expense,
|
||||||
|
}: InstallmentExpenseListItemProps) {
|
||||||
|
const {
|
||||||
|
compactLabel,
|
||||||
|
isLast,
|
||||||
|
remainingInstallments,
|
||||||
|
remainingAmount,
|
||||||
|
endDate,
|
||||||
|
progress,
|
||||||
|
} = buildInstallmentExpenseDisplay(expense);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{expense.name}
|
||||||
|
</p>
|
||||||
|
{compactLabel ? (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{compactLabel}
|
||||||
|
{isLast ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex">
|
||||||
|
<Image
|
||||||
|
src="/icones/party.svg"
|
||||||
|
alt="Última parcela"
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Última parcela</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Última parcela!</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<MoneyValues amount={expense.amount} className="shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{endDate ? `Termina em ${endDate}` : null}
|
||||||
|
{" | Restante "}
|
||||||
|
<MoneyValues
|
||||||
|
amount={remainingAmount}
|
||||||
|
className="inline-block font-medium"
|
||||||
|
/>{" "}
|
||||||
|
({remainingInstallments})
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Progress value={progress} className="mt-1 h-2" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { RiNumbersLine } from "@remixicon/react";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
|
||||||
|
import { InstallmentExpenseListItem } from "./installment-expense-list-item";
|
||||||
|
|
||||||
|
type InstallmentExpensesListProps = {
|
||||||
|
expenses: InstallmentExpense[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InstallmentExpensesList({
|
||||||
|
expenses,
|
||||||
|
}: InstallmentExpensesListProps) {
|
||||||
|
if (expenses.length === 0) {
|
||||||
|
return (
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
|
||||||
|
title="Nenhuma despesa parcelada"
|
||||||
|
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{expenses.map((expense) => (
|
||||||
|
<InstallmentExpenseListItem key={expense.id} expense={expense} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
|
||||||
|
import { InstallmentExpensesList } from "./installment-expenses-list";
|
||||||
|
|
||||||
|
type InstallmentExpensesWidgetViewProps = {
|
||||||
|
data: InstallmentExpensesData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InstallmentExpensesWidgetView({
|
||||||
|
data,
|
||||||
|
}: InstallmentExpensesWidgetViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 px-0">
|
||||||
|
<InstallmentExpensesList expenses={data.expenses} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,584 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import {
|
|
||||||
RiBillLine,
|
|
||||||
RiCheckboxCircleFill,
|
|
||||||
RiCheckboxCircleLine,
|
|
||||||
RiExternalLinkLine,
|
|
||||||
RiLoader4Line,
|
|
||||||
RiMoneyDollarCircleLine,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { updateInvoicePaymentStatusAction } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
|
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter as ModalFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
|
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
|
||||||
import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas";
|
import { useInvoicesWidgetController } from "@/lib/dashboard/use-invoices-widget-controller";
|
||||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
import { InvoicesWidgetView } from "./invoices/invoices-widget-view";
|
||||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
|
||||||
import { Badge } from "../ui/badge";
|
|
||||||
import {
|
|
||||||
HoverCard,
|
|
||||||
HoverCardContent,
|
|
||||||
HoverCardTrigger,
|
|
||||||
} from "../ui/hover-card";
|
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
|
||||||
|
|
||||||
type InvoicesWidgetProps = {
|
type InvoicesWidgetProps = {
|
||||||
invoices: DashboardInvoice[];
|
invoices: DashboardInvoice[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModalState = "idle" | "processing" | "success";
|
|
||||||
|
|
||||||
const DUE_DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolveLogoPath = (logo: string | null) => {
|
|
||||||
if (!logo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (/^(https?:\/\/|data:)/.test(logo)) {
|
|
||||||
return logo;
|
|
||||||
}
|
|
||||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildInitials = (value: string) => {
|
|
||||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return "CC";
|
|
||||||
}
|
|
||||||
if (parts.length === 1) {
|
|
||||||
const firstPart = parts[0];
|
|
||||||
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
|
|
||||||
}
|
|
||||||
const firstChar = parts[0]?.[0] ?? "";
|
|
||||||
const secondChar = parts[1]?.[0] ?? "";
|
|
||||||
return `${firstChar}${secondChar}`.toUpperCase() || "CC";
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseDueDate = (period: string, dueDay: string) => {
|
|
||||||
const [yearStr, monthStr] = period.split("-");
|
|
||||||
const dayNumber = Number.parseInt(dueDay, 10);
|
|
||||||
const year = Number.parseInt(yearStr ?? "", 10);
|
|
||||||
const month = Number.parseInt(monthStr ?? "", 10);
|
|
||||||
|
|
||||||
if (
|
|
||||||
Number.isNaN(dayNumber) ||
|
|
||||||
Number.isNaN(year) ||
|
|
||||||
Number.isNaN(month) ||
|
|
||||||
period.length !== 7
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
label: `Vence dia ${dueDay}`,
|
|
||||||
date: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(Date.UTC(year, month - 1, dayNumber));
|
|
||||||
return {
|
|
||||||
label: `Vence em ${DUE_DATE_FORMATTER.format(date)}`,
|
|
||||||
date,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPaymentDate = (value: string | null) => {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [yearStr, monthStr, dayStr] = value.split("-");
|
|
||||||
const year = Number.parseInt(yearStr ?? "", 10);
|
|
||||||
const month = Number.parseInt(monthStr ?? "", 10);
|
|
||||||
const day = Number.parseInt(dayStr ?? "", 10);
|
|
||||||
|
|
||||||
if (
|
|
||||||
Number.isNaN(year) ||
|
|
||||||
Number.isNaN(month) ||
|
|
||||||
Number.isNaN(day) ||
|
|
||||||
yearStr?.length !== 4 ||
|
|
||||||
monthStr?.length !== 2 ||
|
|
||||||
dayStr?.length !== 2
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(Date.UTC(year, month - 1, day));
|
|
||||||
return {
|
|
||||||
label: `Pago em ${DUE_DATE_FORMATTER.format(date)}`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTodayDateString = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(now.getDate()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSharePercentage = (value: number) => {
|
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
|
||||||
return "0%";
|
|
||||||
}
|
|
||||||
const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
|
|
||||||
return `${value.toLocaleString("pt-BR", {
|
|
||||||
minimumFractionDigits: digits,
|
|
||||||
maximumFractionDigits: digits,
|
|
||||||
})}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getShareLabel = (amount: number, total: number) => {
|
|
||||||
if (total <= 0) {
|
|
||||||
return "0% do total";
|
|
||||||
}
|
|
||||||
const percentage = (amount / total) * 100;
|
|
||||||
return `${formatSharePercentage(percentage)} do total`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
|
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
|
||||||
const router = useRouter();
|
const {
|
||||||
const [isPending, startTransition] = useTransition();
|
items,
|
||||||
const [items, setItems] = useState(invoices);
|
selectedInvoice,
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
isModalOpen,
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
modalState,
|
||||||
const [modalState, setModalState] = useState<ModalState>("idle");
|
isPending,
|
||||||
|
openPaymentDialog,
|
||||||
useEffect(() => {
|
closePaymentDialog,
|
||||||
setItems(invoices);
|
confirmPayment,
|
||||||
}, [invoices]);
|
} = useInvoicesWidgetController(invoices);
|
||||||
|
|
||||||
const selectedInvoice = useMemo(
|
|
||||||
() => items.find((invoice) => invoice.id === selectedId) ?? null,
|
|
||||||
[items, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedLogo = useMemo(
|
|
||||||
() => (selectedInvoice ? resolveLogoPath(selectedInvoice.logo) : null),
|
|
||||||
[selectedInvoice],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedPaymentInfo = useMemo(
|
|
||||||
() => (selectedInvoice ? formatPaymentDate(selectedInvoice.paidAt) : null),
|
|
||||||
[selectedInvoice],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenModal = (invoiceId: string) => {
|
|
||||||
setSelectedId(invoiceId);
|
|
||||||
setModalState("idle");
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
setModalState("idle");
|
|
||||||
setSelectedId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmPayment = () => {
|
|
||||||
if (!selectedInvoice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalState("processing");
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await updateInvoicePaymentStatusAction({
|
|
||||||
cartaoId: selectedInvoice.cardId,
|
|
||||||
period: selectedInvoice.period,
|
|
||||||
status: INVOICE_PAYMENT_STATUS.PAID,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message);
|
|
||||||
setItems((previous) =>
|
|
||||||
previous.map((invoice) =>
|
|
||||||
invoice.id === selectedInvoice.id
|
|
||||||
? {
|
|
||||||
...invoice,
|
|
||||||
paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
|
|
||||||
paidAt: getTodayDateString(),
|
|
||||||
}
|
|
||||||
: invoice,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setModalState("success");
|
|
||||||
router.refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(result.error);
|
|
||||||
setModalState("idle");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadgeVariant = (status: string): "success" | "info" => {
|
|
||||||
const normalizedStatus = status.toLowerCase();
|
|
||||||
if (normalizedStatus === "em aberto") {
|
|
||||||
return "info";
|
|
||||||
}
|
|
||||||
return "success";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<InvoicesWidgetView
|
||||||
<CardContent className="flex flex-col gap-4 px-0">
|
invoices={items}
|
||||||
{items.length === 0 ? (
|
selectedInvoice={selectedInvoice}
|
||||||
<WidgetEmptyState
|
isModalOpen={isModalOpen}
|
||||||
icon={<RiBillLine className="size-6 text-muted-foreground" />}
|
modalState={modalState}
|
||||||
title="Nenhuma fatura para o período selecionado"
|
isPending={isPending}
|
||||||
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
|
onOpenPaymentDialog={openPaymentDialog}
|
||||||
|
onClosePaymentDialog={closePaymentDialog}
|
||||||
|
onConfirmPayment={confirmPayment}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col">
|
|
||||||
{items.map((invoice) => {
|
|
||||||
const logo = resolveLogoPath(invoice.logo);
|
|
||||||
const initials = buildInitials(invoice.cardName);
|
|
||||||
const dueInfo = parseDueDate(invoice.period, invoice.dueDay);
|
|
||||||
const isPaid =
|
|
||||||
invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
|
|
||||||
const isOverdue =
|
|
||||||
!isPaid && dueInfo.date !== null && dueInfo.date < new Date();
|
|
||||||
const paymentInfo = formatPaymentDate(invoice.paidAt);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={invoice.id}
|
|
||||||
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
|
||||||
<div className="flex size-9.5 shrink-0 items-center justify-center overflow-hidden rounded-full">
|
|
||||||
{logo ? (
|
|
||||||
<Image
|
|
||||||
src={logo}
|
|
||||||
alt={`Logo do cartão ${invoice.cardName}`}
|
|
||||||
width={36}
|
|
||||||
height={36}
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm font-semibold uppercase text-muted-foreground">
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
{(() => {
|
|
||||||
const breakdown = invoice.pagadorBreakdown ?? [];
|
|
||||||
const hasBreakdown = breakdown.length > 0;
|
|
||||||
const linkNode = (
|
|
||||||
<Link
|
|
||||||
prefetch
|
|
||||||
href={`/cartoes/${
|
|
||||||
invoice.cardId
|
|
||||||
}/fatura?periodo=${formatPeriodForUrl(
|
|
||||||
invoice.period,
|
|
||||||
)}`}
|
|
||||||
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
|
||||||
>
|
|
||||||
<span className="truncate">{invoice.cardName}</span>
|
|
||||||
<RiExternalLinkLine
|
|
||||||
className="size-3 shrink-0 text-muted-foreground"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasBreakdown) {
|
|
||||||
return linkNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalForShare = Math.abs(invoice.totalAmount);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverCard openDelay={150}>
|
|
||||||
<HoverCardTrigger asChild>
|
|
||||||
{linkNode}
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent
|
|
||||||
align="start"
|
|
||||||
className="w-72 space-y-3"
|
|
||||||
>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Distribuição por pagador
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{breakdown.map((share, index) => (
|
|
||||||
<li
|
|
||||||
key={`${invoice.id}-${
|
|
||||||
share.pagadorId ??
|
|
||||||
share.pagadorName ??
|
|
||||||
index
|
|
||||||
}`}
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Avatar className="size-9">
|
|
||||||
<AvatarImage
|
|
||||||
src={getAvatarSrc(share.pagadorAvatar)}
|
|
||||||
alt={`Avatar de ${share.pagadorName}`}
|
|
||||||
/>
|
|
||||||
<AvatarFallback>
|
|
||||||
{buildInitials(share.pagadorName)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
|
||||||
{share.pagadorName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{getShareLabel(
|
|
||||||
share.amount,
|
|
||||||
totalForShare,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-foreground">
|
|
||||||
<MoneyValues amount={share.amount} />
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
{!isPaid ? <span>{dueInfo.label}</span> : null}
|
|
||||||
{isPaid && paymentInfo ? (
|
|
||||||
<span className="text-success">
|
|
||||||
{paymentInfo.label}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end">
|
|
||||||
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
disabled={isPaid}
|
|
||||||
onClick={() => handleOpenModal(invoice.id)}
|
|
||||||
variant={"link"}
|
|
||||||
className="p-0 h-auto disabled:opacity-100"
|
|
||||||
>
|
|
||||||
{isPaid ? (
|
|
||||||
<span className="text-success flex items-center gap-1">
|
|
||||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
|
||||||
</span>
|
|
||||||
) : isOverdue ? (
|
|
||||||
<span className="overdue-blink">
|
|
||||||
<span className="overdue-blink-primary text-destructive">
|
|
||||||
Atrasado
|
|
||||||
</span>
|
|
||||||
<span className="overdue-blink-secondary">
|
|
||||||
Pagar
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>Pagar</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={isModalOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
handleCloseModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
|
||||||
onEscapeKeyDown={(event) => {
|
|
||||||
if (modalState === "processing") {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleCloseModal();
|
|
||||||
}}
|
|
||||||
onPointerDownOutside={(event) => {
|
|
||||||
if (modalState === "processing") {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{modalState === "success" ? (
|
|
||||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
|
||||||
<RiCheckboxCircleLine className="size-8" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<DialogTitle className="text-base">
|
|
||||||
Pagamento confirmado!
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm">
|
|
||||||
Atualizamos o status da fatura. O lançamento do pagamento
|
|
||||||
aparecerá no extrato em instantes.
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
<ModalFooter className="sm:justify-center">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="sm:w-auto"
|
|
||||||
>
|
|
||||||
Fechar
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Confirmar pagamento</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Revise os dados antes de confirmar. Vamos registrar a fatura
|
|
||||||
como paga.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{selectedInvoice ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/10">
|
|
||||||
{selectedLogo ? (
|
|
||||||
<Image
|
|
||||||
src={selectedLogo}
|
|
||||||
alt={`Logo do cartão ${selectedInvoice.cardName}`}
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-semibold uppercase text-primary">
|
|
||||||
{buildInitials(selectedInvoice.cardName)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
Cartão
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-bold text-foreground">
|
|
||||||
{selectedInvoice.cardName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
{selectedInvoice.paymentStatus !==
|
|
||||||
INVOICE_PAYMENT_STATUS.PAID ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{
|
|
||||||
parseDueDate(
|
|
||||||
selectedInvoice.period,
|
|
||||||
selectedInvoice.dueDay,
|
|
||||||
).label
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{selectedInvoice.paymentStatus ===
|
|
||||||
INVOICE_PAYMENT_STATUS.PAID && selectedPaymentInfo ? (
|
|
||||||
<p className="text-sm text-success">
|
|
||||||
{selectedPaymentInfo.label}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
|
||||||
<RiMoneyDollarCircleLine className="size-4" />
|
|
||||||
<span className="text-xs font-semibold uppercase">
|
|
||||||
Valor da Fatura
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<MoneyValues
|
|
||||||
amount={Math.abs(selectedInvoice.totalAmount)}
|
|
||||||
className="text-lg font-bold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
|
||||||
<RiCheckboxCircleLine className="size-4" />
|
|
||||||
<span className="text-xs font-semibold uppercase">
|
|
||||||
Status
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={getStatusBadgeVariant(
|
|
||||||
INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ModalFooter className="sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
disabled={modalState === "processing"}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleConfirmPayment}
|
|
||||||
disabled={modalState === "processing" || isPending}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
{modalState === "processing" || isPending ? (
|
|
||||||
<>
|
|
||||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
|
||||||
Processando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Confirmar pagamento"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
148
components/dashboard/invoices/invoice-list-item.tsx
Normal file
148
components/dashboard/invoices/invoice-list-item.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
|
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
|
||||||
|
import {
|
||||||
|
buildInvoiceDetailsHref,
|
||||||
|
buildInvoiceInitials,
|
||||||
|
formatInvoicePaymentDate,
|
||||||
|
getInvoiceShareLabel,
|
||||||
|
parseInvoiceDueDate,
|
||||||
|
} from "@/lib/dashboard/invoices-helpers";
|
||||||
|
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
|
||||||
|
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||||
|
import { isDateOnlyPast } from "@/lib/utils/date";
|
||||||
|
import { InvoiceLogo } from "./invoice-logo";
|
||||||
|
|
||||||
|
type InvoiceListItemProps = {
|
||||||
|
invoice: DashboardInvoice;
|
||||||
|
onPay: (invoiceId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||||
|
const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
|
||||||
|
const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||||
|
const isOverdue =
|
||||||
|
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
|
||||||
|
const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
|
||||||
|
const breakdown = invoice.pagadorBreakdown ?? [];
|
||||||
|
const hasBreakdown = breakdown.length > 0;
|
||||||
|
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
|
||||||
|
|
||||||
|
const linkNode = (
|
||||||
|
<Link
|
||||||
|
prefetch
|
||||||
|
href={detailHref}
|
||||||
|
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<span className="truncate">{invoice.cardName}</span>
|
||||||
|
<RiExternalLinkLine
|
||||||
|
className="size-3 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||||
|
<InvoiceLogo
|
||||||
|
cardName={invoice.cardName}
|
||||||
|
logo={invoice.logo}
|
||||||
|
size={36}
|
||||||
|
containerClassName="size-9.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
{hasBreakdown ? (
|
||||||
|
<HoverCard openDelay={150}>
|
||||||
|
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
|
||||||
|
<HoverCardContent align="start" className="w-72 space-y-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Distribuição por pagador
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{breakdown.map((share, index) => (
|
||||||
|
<li
|
||||||
|
key={`${invoice.id}-${
|
||||||
|
share.pagadorId ?? share.pagadorName ?? index
|
||||||
|
}`}
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Avatar className="size-9">
|
||||||
|
<AvatarImage
|
||||||
|
src={getAvatarSrc(share.pagadorAvatar)}
|
||||||
|
alt={`Avatar de ${share.pagadorName}`}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{buildInvoiceInitials(share.pagadorName)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{share.pagadorName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{getInvoiceShareLabel(
|
||||||
|
share.amount,
|
||||||
|
Math.abs(invoice.totalAmount),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-foreground">
|
||||||
|
<MoneyValues amount={share.amount} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
) : (
|
||||||
|
linkNode
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{!isPaid ? <span>{dueInfo.label}</span> : null}
|
||||||
|
{isPaid && paymentInfo ? (
|
||||||
|
<span className="text-success">{paymentInfo.label}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col items-end">
|
||||||
|
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 disabled:opacity-100"
|
||||||
|
disabled={isPaid}
|
||||||
|
onClick={() => onPay(invoice.id)}
|
||||||
|
>
|
||||||
|
{isPaid ? (
|
||||||
|
<span className="flex items-center gap-1 text-success">
|
||||||
|
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||||
|
</span>
|
||||||
|
) : isOverdue ? (
|
||||||
|
<span className="overdue-blink">
|
||||||
|
<span className="overdue-blink-primary text-destructive">
|
||||||
|
Atrasado
|
||||||
|
</span>
|
||||||
|
<span className="overdue-blink-secondary">Pagar</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Pagar</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
components/dashboard/invoices/invoice-logo.tsx
Normal file
59
components/dashboard/invoices/invoice-logo.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
buildInvoiceInitials,
|
||||||
|
type InvoiceLogoTone,
|
||||||
|
} from "@/lib/dashboard/invoices-helpers";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
|
type InvoiceLogoProps = {
|
||||||
|
cardName: string;
|
||||||
|
logo: string | null;
|
||||||
|
size: number;
|
||||||
|
containerClassName?: string;
|
||||||
|
imageClassName?: string;
|
||||||
|
fallbackClassName?: string;
|
||||||
|
tone?: InvoiceLogoTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoiceLogo({
|
||||||
|
cardName,
|
||||||
|
logo,
|
||||||
|
size,
|
||||||
|
containerClassName,
|
||||||
|
imageClassName,
|
||||||
|
fallbackClassName,
|
||||||
|
tone = "muted",
|
||||||
|
}: InvoiceLogoProps) {
|
||||||
|
const resolvedLogo = resolveLogoSrc(logo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||||
|
tone === "accent" && "bg-primary/10",
|
||||||
|
containerClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{resolvedLogo ? (
|
||||||
|
<Image
|
||||||
|
src={resolvedLogo}
|
||||||
|
alt={`Logo do cartão ${cardName}`}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={cn("h-full w-full object-contain", imageClassName)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-semibold uppercase text-muted-foreground",
|
||||||
|
tone === "accent" && "text-primary",
|
||||||
|
fallbackClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{buildInvoiceInitials(cardName)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
components/dashboard/invoices/invoice-payment-dialog.tsx
Normal file
203
components/dashboard/invoices/invoice-payment-dialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
RiCheckboxCircleLine,
|
||||||
|
RiLoader4Line,
|
||||||
|
RiMoneyDollarCircleLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
|
||||||
|
import {
|
||||||
|
formatInvoicePaymentDate,
|
||||||
|
getInvoiceStatusBadgeVariant,
|
||||||
|
type InvoiceDialogState,
|
||||||
|
parseInvoiceDueDate,
|
||||||
|
} from "@/lib/dashboard/invoices-helpers";
|
||||||
|
import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas";
|
||||||
|
import { InvoiceLogo } from "./invoice-logo";
|
||||||
|
|
||||||
|
type InvoicePaymentDialogProps = {
|
||||||
|
invoice: DashboardInvoice | null;
|
||||||
|
open: boolean;
|
||||||
|
modalState: InvoiceDialogState;
|
||||||
|
isPending: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoicePaymentDialog({
|
||||||
|
invoice,
|
||||||
|
open,
|
||||||
|
modalState,
|
||||||
|
isPending,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: InvoicePaymentDialogProps) {
|
||||||
|
const isProcessing = modalState === "processing" || isPending;
|
||||||
|
const paymentInfo = invoice ? formatInvoicePaymentDate(invoice.paidAt) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (nextOpen || isProcessing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (isProcessing) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={(event) => {
|
||||||
|
if (isProcessing) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modalState === "success" ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||||
|
<RiCheckboxCircleLine className="size-8" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<DialogTitle className="text-base">
|
||||||
|
Pagamento confirmado!
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm">
|
||||||
|
Atualizamos o status da fatura. O lançamento do pagamento
|
||||||
|
aparecerá no extrato em instantes.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="sm:justify-center">
|
||||||
|
<Button type="button" onClick={onClose} className="sm:w-auto">
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Revise os dados antes de confirmar. Vamos registrar a fatura
|
||||||
|
como paga.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{invoice ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<InvoiceLogo
|
||||||
|
cardName={invoice.cardName}
|
||||||
|
logo={invoice.logo}
|
||||||
|
size={40}
|
||||||
|
tone="accent"
|
||||||
|
containerClassName="size-10"
|
||||||
|
fallbackClassName="text-xs"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Cartão
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-foreground">
|
||||||
|
{invoice.cardName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PAID ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{
|
||||||
|
parseInvoiceDueDate(invoice.period, invoice.dueDay)
|
||||||
|
.label
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID &&
|
||||||
|
paymentInfo ? (
|
||||||
|
<p className="text-sm text-success">
|
||||||
|
{paymentInfo.label}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<RiMoneyDollarCircleLine className="size-4" />
|
||||||
|
<span className="text-xs font-semibold uppercase">
|
||||||
|
Valor da Fatura
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<MoneyValues
|
||||||
|
amount={Math.abs(invoice.totalAmount)}
|
||||||
|
className="text-lg font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<RiCheckboxCircleLine className="size-4" />
|
||||||
|
<span className="text-xs font-semibold uppercase">
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={getInvoiceStatusBadgeVariant(
|
||||||
|
INVOICE_STATUS_LABEL[invoice.paymentStatus],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isProcessing || !invoice}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<>
|
||||||
|
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||||
|
Processando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Confirmar pagamento"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
components/dashboard/invoices/invoices-list.tsx
Normal file
29
components/dashboard/invoices/invoices-list.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { RiBillLine } from "@remixicon/react";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
|
||||||
|
import { InvoiceListItem } from "./invoice-list-item";
|
||||||
|
|
||||||
|
type InvoicesListProps = {
|
||||||
|
invoices: DashboardInvoice[];
|
||||||
|
onPay: (invoiceId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoicesList({ invoices, onPay }: InvoicesListProps) {
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
return (
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiBillLine className="size-6 text-muted-foreground" />}
|
||||||
|
title="Nenhuma fatura para o período selecionado"
|
||||||
|
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<InvoiceListItem key={invoice.id} invoice={invoice} onPay={onPay} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/dashboard/invoices/invoices-widget-view.tsx
Normal file
43
components/dashboard/invoices/invoices-widget-view.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
|
||||||
|
import type { InvoiceDialogState } from "@/lib/dashboard/invoices-helpers";
|
||||||
|
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
|
||||||
|
import { InvoicesList } from "./invoices-list";
|
||||||
|
|
||||||
|
type InvoicesWidgetViewProps = {
|
||||||
|
invoices: DashboardInvoice[];
|
||||||
|
selectedInvoice: DashboardInvoice | null;
|
||||||
|
isModalOpen: boolean;
|
||||||
|
modalState: InvoiceDialogState;
|
||||||
|
isPending: boolean;
|
||||||
|
onOpenPaymentDialog: (invoiceId: string) => void;
|
||||||
|
onClosePaymentDialog: () => void;
|
||||||
|
onConfirmPayment: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoicesWidgetView({
|
||||||
|
invoices,
|
||||||
|
selectedInvoice,
|
||||||
|
isModalOpen,
|
||||||
|
modalState,
|
||||||
|
isPending,
|
||||||
|
onOpenPaymentDialog,
|
||||||
|
onClosePaymentDialog,
|
||||||
|
onConfirmPayment,
|
||||||
|
}: InvoicesWidgetViewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InvoicePaymentDialog
|
||||||
|
invoice={selectedInvoice}
|
||||||
|
open={isModalOpen}
|
||||||
|
modalState={modalState}
|
||||||
|
isPending={isPending}
|
||||||
|
onClose={onClosePaymentDialog}
|
||||||
|
onConfirm={onConfirmPayment}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +1,38 @@
|
|||||||
import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react";
|
import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { CardFooter } from "@/components/ui/card";
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import type { DashboardAccount } from "@/lib/dashboard/accounts";
|
import type { DashboardAccount } from "@/lib/dashboard/accounts";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
import MoneyValues from "../money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
|
||||||
type MyAccountsWidgetProps = {
|
type MyAccountsWidgetProps = {
|
||||||
accounts: DashboardAccount[];
|
accounts: DashboardAccount[];
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
maxVisible?: number;
|
|
||||||
period: string;
|
period: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveLogoSrc = (logo: string | null) => {
|
|
||||||
if (!logo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
|
|
||||||
return `/logos/${fileName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildInitials = (name: string) => {
|
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length === 0) return "CC";
|
|
||||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
||||||
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MyAccountsWidget({
|
export function MyAccountsWidget({
|
||||||
accounts,
|
accounts,
|
||||||
totalBalance,
|
totalBalance,
|
||||||
maxVisible = 5,
|
|
||||||
period,
|
period,
|
||||||
}: MyAccountsWidgetProps) {
|
}: MyAccountsWidgetProps) {
|
||||||
const visibleAccounts = accounts.filter(
|
const visibleAccounts = accounts.filter(
|
||||||
(account) => !account.excludeFromBalance,
|
(account) => !account.excludeFromBalance,
|
||||||
);
|
);
|
||||||
const displayedAccounts = visibleAccounts.slice(0, maxVisible);
|
const displayedAccounts = visibleAccounts.slice(0, 5);
|
||||||
const remainingCount = visibleAccounts.length - displayedAccounts.length;
|
const remainingCount = visibleAccounts.length - displayedAccounts.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader className="pb-4 px-0">
|
<div className="flex justify-between py-2">
|
||||||
<CardDescription>Saldo Total</CardDescription>
|
Saldo Total
|
||||||
<div className="text-2xl text-foreground">
|
<MoneyValues className="text-2xl" amount={totalBalance} />
|
||||||
<MoneyValues amount={totalBalance} />
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="py-2 px-0">
|
<div className="py-2 px-0">
|
||||||
{displayedAccounts.length === 0 ? (
|
{displayedAccounts.length === 0 ? (
|
||||||
<div className="-mt-10">
|
<div className="-mt-10">
|
||||||
<WidgetEmptyState
|
<WidgetEmptyState
|
||||||
@@ -71,7 +47,6 @@ export function MyAccountsWidget({
|
|||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
{displayedAccounts.map((account) => {
|
{displayedAccounts.map((account) => {
|
||||||
const logoSrc = resolveLogoSrc(account.logo);
|
const logoSrc = resolveLogoSrc(account.logo);
|
||||||
const initials = buildInitials(account.name);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
@@ -79,7 +54,6 @@ export function MyAccountsWidget({
|
|||||||
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
|
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
{logoSrc ? (
|
|
||||||
<div className="relative size-10 overflow-hidden">
|
<div className="relative size-10 overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={logoSrc}
|
src={logoSrc}
|
||||||
@@ -88,11 +62,6 @@ export function MyAccountsWidget({
|
|||||||
className="object-contain rounded-full"
|
className="object-contain rounded-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-sm font-semibold uppercase text-secondary-foreground">
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Link
|
<Link
|
||||||
@@ -122,7 +91,7 @@ export function MyAccountsWidget({
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
|
|
||||||
{visibleAccounts.length > displayedAccounts.length ? (
|
{visibleAccounts.length > displayedAccounts.length ? (
|
||||||
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
|
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,154 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiFileList2Line, RiPencilLine, RiTodoLine } from "@remixicon/react";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog";
|
|
||||||
import { NoteDialog } from "@/components/anotacoes/note-dialog";
|
|
||||||
import type { Note } from "@/components/anotacoes/types";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CardContent } from "@/components/ui/card";
|
|
||||||
import type { DashboardNote } from "@/lib/dashboard/notes";
|
import type { DashboardNote } from "@/lib/dashboard/notes";
|
||||||
import { Badge } from "../ui/badge";
|
import { useNotesWidgetController } from "@/lib/dashboard/use-notes-widget-controller";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { NotesWidgetView } from "./notes/notes-widget-view";
|
||||||
|
|
||||||
type NotesWidgetProps = {
|
type NotesWidgetProps = {
|
||||||
notes: DashboardNote[];
|
notes: DashboardNote[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildDisplayTitle = (value: string) => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length ? trimmed : "Anotação sem título";
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
|
|
||||||
id: note.id,
|
|
||||||
title: note.title,
|
|
||||||
description: note.description,
|
|
||||||
type: note.type,
|
|
||||||
tasks: note.tasks,
|
|
||||||
arquivada: note.arquivada,
|
|
||||||
createdAt: note.createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getTasksSummary = (note: DashboardNote) => {
|
|
||||||
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 function NotesWidget({ notes }: NotesWidgetProps) {
|
export function NotesWidget({ notes }: NotesWidgetProps) {
|
||||||
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
|
const {
|
||||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
mappedNotes,
|
||||||
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
|
noteToEdit,
|
||||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
isEditOpen,
|
||||||
|
noteDetails,
|
||||||
const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]);
|
isDetailsOpen,
|
||||||
|
openEdit,
|
||||||
const handleOpenEdit = useCallback((note: Note) => {
|
openDetails,
|
||||||
setNoteToEdit(note);
|
handleEditOpenChange,
|
||||||
setIsEditOpen(true);
|
handleDetailsOpenChange,
|
||||||
}, []);
|
} = useNotesWidgetController(notes);
|
||||||
|
|
||||||
const handleOpenDetails = useCallback((note: Note) => {
|
|
||||||
setNoteDetails(note);
|
|
||||||
setIsDetailsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
|
||||||
setIsEditOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
setNoteToEdit(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDetailsOpenChange = useCallback((open: boolean) => {
|
|
||||||
setIsDetailsOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
setNoteDetails(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<NotesWidgetView
|
||||||
<CardContent className="flex flex-col gap-4 px-0">
|
notes={mappedNotes}
|
||||||
{mappedNotes.length === 0 ? (
|
noteToEdit={noteToEdit}
|
||||||
<WidgetEmptyState
|
isEditOpen={isEditOpen}
|
||||||
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
|
noteDetails={noteDetails}
|
||||||
title="Nenhuma anotação ativa"
|
isDetailsOpen={isDetailsOpen}
|
||||||
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
|
onOpenEdit={openEdit}
|
||||||
|
onOpenDetails={openDetails}
|
||||||
|
onEditOpenChange={handleEditOpenChange}
|
||||||
|
onDetailsOpenChange={handleDetailsOpenChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col">
|
|
||||||
{mappedNotes.map((note) => (
|
|
||||||
<li
|
|
||||||
key={note.id}
|
|
||||||
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
|
||||||
{buildDisplayTitle(note.title)}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
|
||||||
{getTasksSummary(note)}
|
|
||||||
</Badge>
|
|
||||||
<p className="truncate text-[11px] text-muted-foreground">
|
|
||||||
{DATE_FORMATTER.format(new Date(note.createdAt))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => handleOpenEdit(note)}
|
|
||||||
aria-label={`Editar anotação ${buildDisplayTitle(note.title)}`}
|
|
||||||
>
|
|
||||||
<RiPencilLine className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => handleOpenDetails(note)}
|
|
||||||
aria-label={`Ver detalhes da anotação ${buildDisplayTitle(
|
|
||||||
note.title,
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
<RiFileList2Line className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<NoteDialog
|
|
||||||
mode="update"
|
|
||||||
note={noteToEdit ?? undefined}
|
|
||||||
open={isEditOpen}
|
|
||||||
onOpenChange={handleEditOpenChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NoteDetailsDialog
|
|
||||||
note={noteDetails}
|
|
||||||
open={isDetailsOpen}
|
|
||||||
onOpenChange={handleDetailsOpenChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
65
components/dashboard/notes/note-list-item.tsx
Normal file
65
components/dashboard/notes/note-list-item.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { RiFileList2Line, RiPencilLine } from "@remixicon/react";
|
||||||
|
import type { Note } from "@/components/anotacoes/types";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
buildNoteDisplayTitle,
|
||||||
|
formatNoteCreatedAt,
|
||||||
|
getNoteTasksSummary,
|
||||||
|
} from "@/lib/notes/formatters";
|
||||||
|
|
||||||
|
type NoteListItemProps = {
|
||||||
|
note: Note;
|
||||||
|
onOpenEdit: (note: Note) => void;
|
||||||
|
onOpenDetails: (note: Note) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NoteListItem({
|
||||||
|
note,
|
||||||
|
onOpenEdit,
|
||||||
|
onOpenDetails,
|
||||||
|
}: NoteListItemProps) {
|
||||||
|
const displayTitle = buildNoteDisplayTitle(note.title);
|
||||||
|
const createdAtLabel = formatNoteCreatedAt(note.createdAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{displayTitle}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||||
|
{getNoteTasksSummary(note)}
|
||||||
|
</Badge>
|
||||||
|
{createdAtLabel ? (
|
||||||
|
<p className="truncate text-[11px] text-muted-foreground">
|
||||||
|
{createdAtLabel}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onOpenEdit(note)}
|
||||||
|
aria-label={`Editar anotação ${displayTitle}`}
|
||||||
|
>
|
||||||
|
<RiPencilLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onOpenDetails(note)}
|
||||||
|
aria-label={`Ver detalhes da anotação ${displayTitle}`}
|
||||||
|
>
|
||||||
|
<RiFileList2Line className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/dashboard/notes/notes-list.tsx
Normal file
39
components/dashboard/notes/notes-list.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { RiTodoLine } from "@remixicon/react";
|
||||||
|
import type { Note } from "@/components/anotacoes/types";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
import { NoteListItem } from "./note-list-item";
|
||||||
|
|
||||||
|
type NotesListProps = {
|
||||||
|
notes: Note[];
|
||||||
|
onOpenEdit: (note: Note) => void;
|
||||||
|
onOpenDetails: (note: Note) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotesList({
|
||||||
|
notes,
|
||||||
|
onOpenEdit,
|
||||||
|
onOpenDetails,
|
||||||
|
}: NotesListProps) {
|
||||||
|
if (notes.length === 0) {
|
||||||
|
return (
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
|
||||||
|
title="Nenhuma anotação ativa"
|
||||||
|
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<NoteListItem
|
||||||
|
key={note.id}
|
||||||
|
note={note}
|
||||||
|
onOpenEdit={onOpenEdit}
|
||||||
|
onOpenDetails={onOpenDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
components/dashboard/notes/notes-widget-dialogs.tsx
Normal file
38
components/dashboard/notes/notes-widget-dialogs.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog";
|
||||||
|
import { NoteDialog } from "@/components/anotacoes/note-dialog";
|
||||||
|
import type { Note } from "@/components/anotacoes/types";
|
||||||
|
|
||||||
|
type NotesWidgetDialogsProps = {
|
||||||
|
noteToEdit: Note | null;
|
||||||
|
isEditOpen: boolean;
|
||||||
|
noteDetails: Note | null;
|
||||||
|
isDetailsOpen: boolean;
|
||||||
|
onEditOpenChange: (open: boolean) => void;
|
||||||
|
onDetailsOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotesWidgetDialogs({
|
||||||
|
noteToEdit,
|
||||||
|
isEditOpen,
|
||||||
|
noteDetails,
|
||||||
|
isDetailsOpen,
|
||||||
|
onEditOpenChange,
|
||||||
|
onDetailsOpenChange,
|
||||||
|
}: NotesWidgetDialogsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NoteDialog
|
||||||
|
mode="update"
|
||||||
|
note={noteToEdit ?? undefined}
|
||||||
|
open={isEditOpen}
|
||||||
|
onOpenChange={onEditOpenChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NoteDetailsDialog
|
||||||
|
note={noteDetails}
|
||||||
|
open={isDetailsOpen}
|
||||||
|
onOpenChange={onDetailsOpenChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/dashboard/notes/notes-widget-view.tsx
Normal file
48
components/dashboard/notes/notes-widget-view.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Note } from "@/components/anotacoes/types";
|
||||||
|
import { NotesList } from "./notes-list";
|
||||||
|
import { NotesWidgetDialogs } from "./notes-widget-dialogs";
|
||||||
|
|
||||||
|
type NotesWidgetViewProps = {
|
||||||
|
notes: Note[];
|
||||||
|
noteToEdit: Note | null;
|
||||||
|
isEditOpen: boolean;
|
||||||
|
noteDetails: Note | null;
|
||||||
|
isDetailsOpen: boolean;
|
||||||
|
onOpenEdit: (note: Note) => void;
|
||||||
|
onOpenDetails: (note: Note) => void;
|
||||||
|
onEditOpenChange: (open: boolean) => void;
|
||||||
|
onDetailsOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotesWidgetView({
|
||||||
|
notes,
|
||||||
|
noteToEdit,
|
||||||
|
isEditOpen,
|
||||||
|
noteDetails,
|
||||||
|
isDetailsOpen,
|
||||||
|
onOpenEdit,
|
||||||
|
onOpenDetails,
|
||||||
|
onEditOpenChange,
|
||||||
|
onDetailsOpenChange,
|
||||||
|
}: NotesWidgetViewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4 px-0">
|
||||||
|
<NotesList
|
||||||
|
notes={notes}
|
||||||
|
onOpenEdit={onOpenEdit}
|
||||||
|
onOpenDetails={onOpenDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotesWidgetDialogs
|
||||||
|
noteToEdit={noteToEdit}
|
||||||
|
isEditOpen={isEditOpen}
|
||||||
|
noteDetails={noteDetails}
|
||||||
|
isDetailsOpen={isDetailsOpen}
|
||||||
|
onEditOpenChange={onEditOpenChange}
|
||||||
|
onDetailsOpenChange={onDetailsOpenChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,21 +8,18 @@ import {
|
|||||||
RiVerifiedBadgeFill,
|
RiVerifiedBadgeFill,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import type { DashboardPagador } from "@/lib/dashboard/pagadores";
|
import type { DashboardPagador } from "@/lib/dashboard/pagadores";
|
||||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { formatPercentage } from "@/lib/utils/percentage";
|
||||||
|
|
||||||
type PagadoresWidgetProps = {
|
type PayersWidgetProps = {
|
||||||
pagadores: DashboardPagador[];
|
pagadores: DashboardPagador[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPercentage = (value: number) => {
|
|
||||||
return `${Math.abs(value).toFixed(0)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildInitials = (value: string) => {
|
const buildInitials = (value: string) => {
|
||||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
@@ -37,7 +34,7 @@ const buildInitials = (value: string) => {
|
|||||||
return `${firstChar}${secondChar}`.toUpperCase() || "??";
|
return `${firstChar}${secondChar}`.toUpperCase() || "??";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
|
export function PayersWidget({ pagadores }: PayersWidgetProps) {
|
||||||
return (
|
return (
|
||||||
<CardContent className="flex flex-col gap-4 px-0">
|
<CardContent className="flex flex-col gap-4 px-0">
|
||||||
{pagadores.length === 0 ? (
|
{pagadores.length === 0 ? (
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import {
|
|
||||||
RiCheckLine,
|
|
||||||
RiLoader2Fill,
|
|
||||||
RiRefreshLine,
|
|
||||||
RiSlideshowLine,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
|
|
||||||
import { Progress } from "../ui/progress";
|
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
|
||||||
|
|
||||||
type PaymentConditionsWidgetProps = {
|
|
||||||
data: PaymentConditionsData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONDITION_ICON_CLASSES =
|
|
||||||
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
|
|
||||||
|
|
||||||
const CONDITION_ICONS: Record<string, ReactNode> = {
|
|
||||||
"À vista": <RiCheckLine className="size-5" aria-hidden />,
|
|
||||||
Parcelado: <RiLoader2Fill className="size-5" aria-hidden />,
|
|
||||||
Recorrente: <RiRefreshLine className="size-5" aria-hidden />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPercentage = (value: number) =>
|
|
||||||
new Intl.NumberFormat("pt-BR", {
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
}).format(value);
|
|
||||||
|
|
||||||
export function PaymentConditionsWidget({
|
|
||||||
data,
|
|
||||||
}: PaymentConditionsWidgetProps) {
|
|
||||||
if (data.conditions.length === 0) {
|
|
||||||
return (
|
|
||||||
<WidgetEmptyState
|
|
||||||
icon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
|
|
||||||
title="Nenhuma despesa encontrada"
|
|
||||||
description="As distribuições por condição aparecerão conforme novos lançamentos."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 px-0">
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{data.conditions.map((condition) => {
|
|
||||||
const Icon =
|
|
||||||
CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"];
|
|
||||||
const percentageLabel = formatPercentage(condition.percentage);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={condition.condition}
|
|
||||||
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className={CONDITION_ICON_CLASSES}>{Icon}</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="font-medium text-foreground text-sm">
|
|
||||||
{condition.condition}
|
|
||||||
</p>
|
|
||||||
<MoneyValues amount={condition.amount} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{condition.transactions}{" "}
|
|
||||||
{condition.transactions === 1
|
|
||||||
? "lançamento"
|
|
||||||
: "lançamentos"}
|
|
||||||
</span>
|
|
||||||
<span>{percentageLabel}%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1">
|
|
||||||
<Progress value={condition.percentage} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
|
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
|
|
||||||
import { getPaymentMethodIcon } from "@/lib/utils/icons";
|
|
||||||
import { Progress } from "../ui/progress";
|
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
|
||||||
|
|
||||||
type PaymentMethodsWidgetProps = {
|
|
||||||
data: PaymentMethodsData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICON_WRAPPER_CLASS =
|
|
||||||
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
|
|
||||||
|
|
||||||
const formatPercentage = (value: number) =>
|
|
||||||
new Intl.NumberFormat("pt-BR", {
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
}).format(value);
|
|
||||||
|
|
||||||
const resolveIcon = (paymentMethod: string | null | undefined) => {
|
|
||||||
if (!paymentMethod) {
|
|
||||||
return <RiMoneyDollarCircleLine className="size-5" aria-hidden />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = getPaymentMethodIcon(paymentMethod);
|
|
||||||
if (icon) {
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <RiBankCard2Line className="size-5" aria-hidden />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
|
|
||||||
if (data.methods.length === 0) {
|
|
||||||
return (
|
|
||||||
<WidgetEmptyState
|
|
||||||
icon={
|
|
||||||
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
|
|
||||||
}
|
|
||||||
title="Nenhuma despesa encontrada"
|
|
||||||
description="Cadastre despesas para visualizar a distribuição por forma de pagamento."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 px-0">
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{data.methods.map((method) => {
|
|
||||||
const icon = resolveIcon(method.paymentMethod);
|
|
||||||
const percentageLabel = formatPercentage(method.percentage);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={method.paymentMethod}
|
|
||||||
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className={ICON_WRAPPER_CLASS}>{icon}</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="font-medium text-foreground text-sm">
|
|
||||||
{method.paymentMethod}
|
|
||||||
</p>
|
|
||||||
<MoneyValues amount={method.amount} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{method.transactions}{" "}
|
|
||||||
{method.transactions === 1 ? "lançamento" : "lançamentos"}
|
|
||||||
</span>
|
|
||||||
<span>{percentageLabel}%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1">
|
|
||||||
<Progress value={method.percentage} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
|
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
|
||||||
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
|
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
import { usePaymentOverviewWidgetController } from "@/lib/dashboard/use-payment-overview-widget-controller";
|
||||||
import { PaymentConditionsWidget } from "./payment-conditions-widget";
|
import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-widget-view";
|
||||||
import { PaymentMethodsWidget } from "./payment-methods-widget";
|
|
||||||
|
|
||||||
type PaymentOverviewWidgetProps = {
|
type PaymentOverviewWidgetProps = {
|
||||||
paymentConditionsData: PaymentConditionsData;
|
paymentConditionsData: PaymentConditionsData;
|
||||||
@@ -17,34 +14,14 @@ export function PaymentOverviewWidget({
|
|||||||
paymentConditionsData,
|
paymentConditionsData,
|
||||||
paymentMethodsData,
|
paymentMethodsData,
|
||||||
}: PaymentOverviewWidgetProps) {
|
}: PaymentOverviewWidgetProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"conditions" | "methods">(
|
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
|
||||||
"conditions",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<PaymentOverviewWidgetView
|
||||||
value={activeTab}
|
activeTab={activeTab}
|
||||||
onValueChange={(value) => setActiveTab(value as "conditions" | "methods")}
|
paymentConditionsData={paymentConditionsData}
|
||||||
className="w-full"
|
paymentMethodsData={paymentMethodsData}
|
||||||
>
|
onTabChange={handleTabChange}
|
||||||
<TabsList className="grid grid-cols-2">
|
/>
|
||||||
<TabsTrigger value="conditions" className="text-xs">
|
|
||||||
<RiSlideshowLine className="mr-1 size-3.5" />
|
|
||||||
Condições
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="methods" className="text-xs">
|
|
||||||
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
|
|
||||||
Formas
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="conditions" className="mt-2">
|
|
||||||
<PaymentConditionsWidget data={paymentConditionsData} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="methods" className="mt-2">
|
|
||||||
<PaymentMethodsWidget data={paymentMethodsData} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
formatPaymentBreakdownPercentage,
|
||||||
|
formatPaymentBreakdownTransactionsLabel,
|
||||||
|
} from "@/lib/dashboard/payment-breakdown-formatters";
|
||||||
|
|
||||||
|
const ICON_WRAPPER_CLASS =
|
||||||
|
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
|
||||||
|
|
||||||
|
export type PaymentBreakdownListItemData = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
amount: number;
|
||||||
|
transactions: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaymentBreakdownListItemProps = {
|
||||||
|
item: PaymentBreakdownListItemData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentBreakdownListItem({
|
||||||
|
item,
|
||||||
|
}: PaymentBreakdownListItemProps) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
|
||||||
|
<div className={ICON_WRAPPER_CLASS}>{item.icon}</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||||
|
<MoneyValues amount={item.amount} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{formatPaymentBreakdownTransactionsLabel(item.transactions)}
|
||||||
|
</span>
|
||||||
|
<span>{formatPaymentBreakdownPercentage(item.percentage)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1">
|
||||||
|
<Progress value={item.percentage} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
import {
|
||||||
|
PaymentBreakdownListItem,
|
||||||
|
type PaymentBreakdownListItemData,
|
||||||
|
} from "./payment-breakdown-list-item";
|
||||||
|
|
||||||
|
export type { PaymentBreakdownListItemData } from "./payment-breakdown-list-item";
|
||||||
|
|
||||||
|
type PaymentBreakdownListProps = {
|
||||||
|
items: PaymentBreakdownListItemData[];
|
||||||
|
emptyIcon: ReactNode;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyDescription: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentBreakdownList({
|
||||||
|
items,
|
||||||
|
emptyIcon,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
}: PaymentBreakdownListProps) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={emptyIcon}
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 px-0">
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<PaymentBreakdownListItem key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
|
||||||
|
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
|
||||||
|
import { getConditionIcon } from "@/lib/utils/icons";
|
||||||
|
import {
|
||||||
|
PaymentBreakdownList,
|
||||||
|
type PaymentBreakdownListItemData,
|
||||||
|
} from "./payment-breakdown-list";
|
||||||
|
|
||||||
|
type PaymentConditionsWidgetProps = {
|
||||||
|
data: PaymentConditionsData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveConditionIcon = (condition: string) =>
|
||||||
|
getConditionIcon(condition) ?? <RiCheckLine className="size-5" aria-hidden />;
|
||||||
|
|
||||||
|
export function PaymentConditionsWidget({
|
||||||
|
data,
|
||||||
|
}: PaymentConditionsWidgetProps) {
|
||||||
|
const items: PaymentBreakdownListItemData[] = data.conditions.map(
|
||||||
|
(condition) => ({
|
||||||
|
id: condition.condition,
|
||||||
|
title: condition.condition,
|
||||||
|
icon: resolveConditionIcon(condition.condition),
|
||||||
|
amount: condition.amount,
|
||||||
|
transactions: condition.transactions,
|
||||||
|
percentage: condition.percentage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaymentBreakdownList
|
||||||
|
items={items}
|
||||||
|
emptyIcon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
|
||||||
|
emptyTitle="Nenhuma despesa encontrada"
|
||||||
|
emptyDescription="As distribuições por condição aparecerão conforme novos lançamentos."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
|
||||||
|
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
|
||||||
|
import { getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||||
|
import {
|
||||||
|
PaymentBreakdownList,
|
||||||
|
type PaymentBreakdownListItemData,
|
||||||
|
} from "./payment-breakdown-list";
|
||||||
|
|
||||||
|
type PaymentMethodsWidgetProps = {
|
||||||
|
data: PaymentMethodsData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePaymentMethodIcon = (paymentMethod: string) =>
|
||||||
|
getPaymentMethodIcon(paymentMethod) ?? (
|
||||||
|
<RiBankCard2Line className="size-5" aria-hidden />
|
||||||
|
);
|
||||||
|
|
||||||
|
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
|
||||||
|
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({
|
||||||
|
id: method.paymentMethod,
|
||||||
|
title: method.paymentMethod,
|
||||||
|
icon: resolvePaymentMethodIcon(method.paymentMethod),
|
||||||
|
amount: method.amount,
|
||||||
|
transactions: method.transactions,
|
||||||
|
percentage: method.percentage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaymentBreakdownList
|
||||||
|
items={items}
|
||||||
|
emptyIcon={
|
||||||
|
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
emptyTitle="Nenhuma despesa encontrada"
|
||||||
|
emptyDescription="Cadastre despesas para visualizar a distribuição por forma de pagamento."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import type { PaymentOverviewTab } from "@/lib/dashboard/payment-overview-tabs";
|
||||||
|
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
|
||||||
|
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
|
||||||
|
import { PaymentConditionsWidget } from "./payment-conditions-widget";
|
||||||
|
import { PaymentMethodsWidget } from "./payment-methods-widget";
|
||||||
|
|
||||||
|
type PaymentOverviewWidgetViewProps = {
|
||||||
|
activeTab: PaymentOverviewTab;
|
||||||
|
paymentConditionsData: PaymentConditionsData;
|
||||||
|
paymentMethodsData: PaymentMethodsData;
|
||||||
|
onTabChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentOverviewWidgetView({
|
||||||
|
activeTab,
|
||||||
|
paymentConditionsData,
|
||||||
|
paymentMethodsData,
|
||||||
|
onTabChange,
|
||||||
|
}: PaymentOverviewWidgetViewProps) {
|
||||||
|
return (
|
||||||
|
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||||
|
<TabsList className="grid grid-cols-2">
|
||||||
|
<TabsTrigger value="conditions" className="text-xs">
|
||||||
|
<RiSlideshowLine className="mr-1 size-3.5" />
|
||||||
|
Condições
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="methods" className="text-xs">
|
||||||
|
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
|
||||||
|
Formas
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="conditions" className="mt-2">
|
||||||
|
<PaymentConditionsWidget data={paymentConditionsData} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="methods" className="mt-2">
|
||||||
|
<PaymentMethodsWidget data={paymentMethodsData} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,103 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
RiCheckboxCircleLine,
|
|
||||||
RiHourglass2Line,
|
|
||||||
RiWallet3Line,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import { CardContent } from "@/components/ui/card";
|
|
||||||
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
|
||||||
import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status";
|
import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status";
|
||||||
import { Progress } from "../ui/progress";
|
import { PaymentStatusWidgetView } from "./payment-status/payment-status-widget-view";
|
||||||
|
|
||||||
type PaymentStatusWidgetProps = {
|
type PaymentStatusWidgetProps = {
|
||||||
data: PaymentStatusData;
|
data: PaymentStatusData;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategorySectionProps = {
|
|
||||||
title: string;
|
|
||||||
total: number;
|
|
||||||
confirmed: number;
|
|
||||||
pending: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function CategorySection({
|
|
||||||
title,
|
|
||||||
total,
|
|
||||||
confirmed,
|
|
||||||
pending,
|
|
||||||
}: CategorySectionProps) {
|
|
||||||
// Usa valores absolutos para calcular percentual corretamente
|
|
||||||
const absTotal = Math.abs(total);
|
|
||||||
const absConfirmed = Math.abs(confirmed);
|
|
||||||
const confirmedPercentage =
|
|
||||||
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
|
||||||
<MoneyValues
|
|
||||||
amount={total}
|
|
||||||
className="text-sm font-medium tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Barra de progresso */}
|
|
||||||
<Progress value={confirmedPercentage} className="h-2" />
|
|
||||||
|
|
||||||
{/* Status de confirmados e pendentes */}
|
|
||||||
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RiCheckboxCircleLine className="size-3 shrink-0 text-success" />
|
|
||||||
<MoneyValues amount={confirmed} className="tabular-nums" />
|
|
||||||
<span className="text-xs text-muted-foreground">confirmados</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RiHourglass2Line className="size-3 shrink-0 text-warning" />
|
|
||||||
<MoneyValues amount={pending} className="tabular-nums" />
|
|
||||||
<span className="text-xs text-muted-foreground">pendentes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
|
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
|
||||||
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
|
return <PaymentStatusWidgetView data={data} />;
|
||||||
|
|
||||||
if (isEmpty) {
|
|
||||||
return (
|
|
||||||
<CardContent className="px-0">
|
|
||||||
<WidgetEmptyState
|
|
||||||
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
|
|
||||||
title="Nenhum valor a receber ou pagar no período"
|
|
||||||
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardContent className="space-y-6 px-0">
|
|
||||||
<CategorySection
|
|
||||||
title="A Receber"
|
|
||||||
total={data.income.total}
|
|
||||||
confirmed={data.income.confirmed}
|
|
||||||
pending={data.income.pending}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Linha divisória pontilhada */}
|
|
||||||
<div className="border-t border-dashed" />
|
|
||||||
|
|
||||||
<CategorySection
|
|
||||||
title="A Pagar"
|
|
||||||
total={data.expenses.total}
|
|
||||||
confirmed={data.expenses.confirmed}
|
|
||||||
pending={data.expenses.pending}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import StatusDot from "@/components/shared/status-dot";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
type PaymentStatusCategorySectionProps = {
|
||||||
|
title: string;
|
||||||
|
total: number;
|
||||||
|
confirmed: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentStatusCategorySection({
|
||||||
|
title,
|
||||||
|
total,
|
||||||
|
confirmed,
|
||||||
|
pending,
|
||||||
|
}: PaymentStatusCategorySectionProps) {
|
||||||
|
const absTotal = Math.abs(total);
|
||||||
|
const absConfirmed = Math.abs(confirmed);
|
||||||
|
const confirmedPercentage =
|
||||||
|
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={total}
|
||||||
|
className="text-sm font-medium tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress value={confirmedPercentage} className="h-2" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<StatusDot color="bg-primary" />
|
||||||
|
<MoneyValues amount={confirmed} className="tabular-nums" />
|
||||||
|
<span className="text-xs text-muted-foreground">confirmados</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<StatusDot color="bg-warning/40" />
|
||||||
|
<MoneyValues amount={pending} className="tabular-nums" />
|
||||||
|
<span className="text-xs text-muted-foreground">pendentes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { RiWallet3Line } from "@remixicon/react";
|
||||||
|
import { CardContent } from "@/components/ui/card";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status";
|
||||||
|
import { PaymentStatusCategorySection } from "./payment-status-category-section";
|
||||||
|
|
||||||
|
type PaymentStatusWidgetViewProps = {
|
||||||
|
data: PaymentStatusData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentStatusWidgetView({
|
||||||
|
data,
|
||||||
|
}: PaymentStatusWidgetViewProps) {
|
||||||
|
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
<CardContent className="px-0">
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
|
||||||
|
title="Nenhum valor a receber ou pagar no período"
|
||||||
|
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContent className="space-y-6 px-0">
|
||||||
|
<PaymentStatusCategorySection
|
||||||
|
title="A Receber"
|
||||||
|
total={data.income.total}
|
||||||
|
confirmed={data.income.confirmed}
|
||||||
|
pending={data.income.pending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-dashed" />
|
||||||
|
|
||||||
|
<PaymentStatusCategorySection
|
||||||
|
title="A Pagar"
|
||||||
|
total={data.expenses.total}
|
||||||
|
confirmed={data.expenses.confirmed}
|
||||||
|
pending={data.expenses.pending}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
|
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { CATEGORY_TYPE_LABEL } from "@/lib/categorias/constants";
|
import { CATEGORY_TYPE_LABEL } from "@/lib/categorias/constants";
|
||||||
import type { PurchasesByCategoryData } from "@/lib/dashboard/purchases-by-category";
|
import type { PurchasesByCategoryData } from "@/lib/dashboard/purchases-by-category";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
|
||||||
|
|
||||||
type PurchasesByCategoryWidgetProps = {
|
type PurchasesByCategoryWidgetProps = {
|
||||||
data: PurchasesByCategoryData;
|
data: PurchasesByCategoryData;
|
||||||
@@ -38,21 +38,11 @@ const STORAGE_KEY = "purchases-by-category-selected";
|
|||||||
export function PurchasesByCategoryWidget({
|
export function PurchasesByCategoryWidget({
|
||||||
data,
|
data,
|
||||||
}: PurchasesByCategoryWidgetProps) {
|
}: PurchasesByCategoryWidgetProps) {
|
||||||
// Inicializa com a categoria salva ou a primeira disponível
|
const firstCategoryId = data.categories[0]?.id ?? "";
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string>(() => {
|
const hasRestoredSelectionRef = useRef(false);
|
||||||
if (typeof window === "undefined") {
|
const hasPersistedSelectionRef = useRef(false);
|
||||||
const firstCategory = data.categories[0];
|
const [selectedCategoryId, setSelectedCategoryId] =
|
||||||
return firstCategory ? firstCategory.id : "";
|
useState<string>(firstCategoryId);
|
||||||
}
|
|
||||||
|
|
||||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
|
||||||
if (saved && data.categories.some((cat) => cat.id === saved)) {
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstCategory = data.categories[0];
|
|
||||||
return firstCategory ? firstCategory.id : "";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Agrupa categorias por tipo
|
// Agrupa categorias por tipo
|
||||||
const categoriesByType = useMemo(() => {
|
const categoriesByType = useMemo(() => {
|
||||||
@@ -72,27 +62,52 @@ export function PurchasesByCategoryWidget({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [data.categories]);
|
}, [data.categories]);
|
||||||
|
|
||||||
// Salva a categoria selecionada quando mudar
|
// Restaura a categoria salva apenas depois da montagem para manter SSR e cliente consistentes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasRestoredSelectionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRestoredSelectionRef.current = true;
|
||||||
|
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved && data.categories.some((cat) => cat.id === saved)) {
|
||||||
|
setSelectedCategoryId(saved);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCategoryId(firstCategoryId);
|
||||||
|
}, [data.categories, firstCategoryId]);
|
||||||
|
|
||||||
|
// Salva a categoria selecionada quando mudar, sem sobrescrever o valor salvo na primeira montagem.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasPersistedSelectionRef.current) {
|
||||||
|
hasPersistedSelectionRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedCategoryId) {
|
if (selectedCategoryId) {
|
||||||
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
|
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
}, [selectedCategoryId]);
|
}, [selectedCategoryId]);
|
||||||
|
|
||||||
// Atualiza a categoria selecionada se ela não existir mais na lista
|
// Atualiza a categoria selecionada se ela não existir mais na lista
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!selectedCategoryId && firstCategoryId) {
|
||||||
|
setSelectedCategoryId(firstCategoryId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
selectedCategoryId &&
|
selectedCategoryId &&
|
||||||
!data.categories.some((cat) => cat.id === selectedCategoryId)
|
!data.categories.some((cat) => cat.id === selectedCategoryId)
|
||||||
) {
|
) {
|
||||||
const firstCategory = data.categories[0];
|
setSelectedCategoryId(firstCategoryId);
|
||||||
if (firstCategory) {
|
|
||||||
setSelectedCategoryId(firstCategory.id);
|
|
||||||
} else {
|
|
||||||
setSelectedCategoryId("");
|
|
||||||
}
|
}
|
||||||
}
|
}, [data.categories, firstCategoryId, selectedCategoryId]);
|
||||||
}, [data.categories, selectedCategoryId]);
|
|
||||||
|
|
||||||
const currentTransactions = useMemo(() => {
|
const currentTransactions = useMemo(() => {
|
||||||
if (!selectedCategoryId) {
|
if (!selectedCategoryId) {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { RiRefreshLine } from "@remixicon/react";
|
import { RiRefreshLine } from "@remixicon/react";
|
||||||
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { CardContent } from "@/components/ui/card";
|
|
||||||
import type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses";
|
import type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
|
||||||
type RecurringExpensesWidgetProps = {
|
type RecurringExpensesWidgetProps = {
|
||||||
data: RecurringExpensesData;
|
data: RecurringExpensesData;
|
||||||
@@ -31,7 +30,7 @@ export function RecurringExpensesWidget({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardContent className="flex flex-col gap-4 px-0">
|
<div className="flex flex-col gap-4 px-0">
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
{data.expenses.map((expense) => {
|
{data.expenses.map((expense) => {
|
||||||
return (
|
return (
|
||||||
@@ -61,6 +60,6 @@ export function RecurringExpensesWidget({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { RiStore2Line } from "@remixicon/react";
|
import { RiStore2Line } from "@remixicon/react";
|
||||||
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments";
|
import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
|
||||||
type TopEstablishmentsWidgetProps = {
|
type TopEstablishmentsWidgetProps = {
|
||||||
data: TopEstablishmentsData;
|
data: TopEstablishmentsData;
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import { RiArrowUpDoubleLine } from "@remixicon/react";
|
import { RiArrowUpDoubleLine } from "@remixicon/react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import type {
|
import type {
|
||||||
TopExpense,
|
TopExpense,
|
||||||
TopExpensesData,
|
TopExpensesData,
|
||||||
} from "@/lib/dashboard/expenses/top-expenses";
|
} from "@/lib/dashboard/expenses/top-expenses";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
|
|
||||||
type TopExpensesWidgetProps = {
|
type TopExpensesWidgetProps = {
|
||||||
allExpenses: TopExpensesData;
|
allExpenses: TopExpensesData;
|
||||||
|
|||||||
9
components/dashboard/welcome-widget.ts
Normal file
9
components/dashboard/welcome-widget.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import {
|
||||||
|
formatBusinessCurrentDate,
|
||||||
|
getBusinessGreeting,
|
||||||
|
} from "@/lib/utils/date";
|
||||||
|
|
||||||
|
export const formatCurrentDate = (date = new Date()) =>
|
||||||
|
formatBusinessCurrentDate(date);
|
||||||
|
|
||||||
|
export const getGreeting = (date = new Date()) => getBusinessGreeting(date);
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type React from "react";
|
|
||||||
import { type CSSProperties, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
interface MagnetLinesProps {
|
|
||||||
rows?: number;
|
|
||||||
columns?: number;
|
|
||||||
containerSize?: string;
|
|
||||||
lineColor?: string;
|
|
||||||
lineWidth?: string;
|
|
||||||
lineHeight?: string;
|
|
||||||
baseAngle?: number;
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MagnetLines: React.FC<MagnetLinesProps> = ({
|
|
||||||
rows = 9,
|
|
||||||
columns = 9,
|
|
||||||
containerSize = "80vmin",
|
|
||||||
lineColor = "#efefef",
|
|
||||||
lineWidth = "1vmin",
|
|
||||||
lineHeight = "6vmin",
|
|
||||||
baseAngle = -10,
|
|
||||||
className = "",
|
|
||||||
style = {},
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (disabled) return;
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const items = container.querySelectorAll<HTMLSpanElement>("span");
|
|
||||||
|
|
||||||
const onPointerMove = (pointer: { x: number; y: number }) => {
|
|
||||||
items.forEach((item) => {
|
|
||||||
const rect = item.getBoundingClientRect();
|
|
||||||
const centerX = rect.x + rect.width / 2;
|
|
||||||
const centerY = rect.y + rect.height / 2;
|
|
||||||
|
|
||||||
const b = pointer.x - centerX;
|
|
||||||
const a = pointer.y - centerY;
|
|
||||||
const c = Math.sqrt(a * a + b * b) || 1;
|
|
||||||
const r =
|
|
||||||
((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1);
|
|
||||||
|
|
||||||
item.style.setProperty("--rotate", `${r}deg`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerMove = (e: PointerEvent) => {
|
|
||||||
onPointerMove({ x: e.x, y: e.y });
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", handlePointerMove);
|
|
||||||
|
|
||||||
if (items.length) {
|
|
||||||
const middleIndex = Math.floor(items.length / 2);
|
|
||||||
const rect = items[middleIndex].getBoundingClientRect();
|
|
||||||
onPointerMove({ x: rect.x, y: rect.y });
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("pointermove", handlePointerMove);
|
|
||||||
};
|
|
||||||
}, [disabled]);
|
|
||||||
|
|
||||||
// Se magnetlines estiver desabilitado, não renderiza nada
|
|
||||||
if (disabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = rows * columns;
|
|
||||||
const spans = Array.from({ length: total }, (_, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="block origin-center"
|
|
||||||
style={{
|
|
||||||
backgroundColor: lineColor,
|
|
||||||
width: lineWidth,
|
|
||||||
height: lineHeight,
|
|
||||||
// @ts-expect-error -- CSS custom property para controlar a rotação inline
|
|
||||||
"--rotate": `${baseAngle}deg`,
|
|
||||||
transform: "rotate(var(--rotate))",
|
|
||||||
willChange: "transform",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={`grid place-items-center ${className}`}
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
||||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
|
||||||
width: containerSize,
|
|
||||||
height: containerSize,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{spans}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MagnetLines;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Skeleton fiel aos cards de métricas do dashboard (SectionCards)
|
|
||||||
* Mantém o mesmo layout de 4 colunas responsivo
|
|
||||||
*/
|
|
||||||
export function SectionCardsSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
|
||||||
<Card key={index} className="@container/card gap-2">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Título com ícone */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Skeleton className="size-4 rounded-2xl bg-foreground/10" />
|
|
||||||
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Valor principal */}
|
|
||||||
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
|
|
||||||
|
|
||||||
{/* Badge de tendência */}
|
|
||||||
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
|
||||||
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
|
|
||||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -127,7 +127,6 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.unique()
|
.unique()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
|
|
||||||
extratoNoteAsColumn: boolean("extrato_note_as_column")
|
extratoNoteAsColumn: boolean("extrato_note_as_column")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
1
drizzle/0018_rainy_epoch.sql
Normal file
1
drizzle/0018_rainy_epoch.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "disable_magnetlines";
|
||||||
2416
drizzle/meta/0018_snapshot.json
Normal file
2416
drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
|||||||
"when": 1772400510326,
|
"when": 1772400510326,
|
||||||
"tag": "0017_previous_warstar",
|
"tag": "0017_previous_warstar",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773020417482,
|
||||||
|
"tag": "0018_rainy_epoch",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||||
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
|
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
type RawDashboardAccount = {
|
type RawDashboardAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
53
lib/dashboard/bills-helpers.ts
Normal file
53
lib/dashboard/bills-helpers.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { DashboardBill } from "@/lib/dashboard/bills";
|
||||||
|
import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller";
|
||||||
|
import { getBusinessDateString, isDateOnlyPast } from "@/lib/utils/date";
|
||||||
|
import {
|
||||||
|
buildFinancialStatusLabel,
|
||||||
|
formatFinancialDateLabel,
|
||||||
|
} from "@/lib/utils/financial-dates";
|
||||||
|
|
||||||
|
export type BillDialogState = PaymentDialogState;
|
||||||
|
export type BillStatusDateItem = Pick<
|
||||||
|
DashboardBill,
|
||||||
|
"dueDate" | "boletoPaymentDate" | "isSettled"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const formatBillDateLabel = (value: string | null, prefix?: string) => {
|
||||||
|
return formatFinancialDateLabel(value, prefix);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
|
||||||
|
return buildFinancialStatusLabel({
|
||||||
|
isSettled: bill.isSettled,
|
||||||
|
dueDate: bill.dueDate,
|
||||||
|
paidAt: bill.boletoPaymentDate,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentBillDateString = () => getBusinessDateString();
|
||||||
|
|
||||||
|
export const isBillOverdue = (bill: DashboardBill) => {
|
||||||
|
if (bill.isSettled || !bill.dueDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDateOnlyPast(bill.dueDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBillStatusBadgeVariant = (
|
||||||
|
statusLabel: string,
|
||||||
|
): "success" | "info" => {
|
||||||
|
if (statusLabel.toLowerCase() === "pendente") {
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
return "success";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markBillAsSettled = (
|
||||||
|
bill: DashboardBill,
|
||||||
|
boletoPaymentDate: string,
|
||||||
|
): DashboardBill => ({
|
||||||
|
...bill,
|
||||||
|
isSettled: true,
|
||||||
|
boletoPaymentDate,
|
||||||
|
});
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
import { lancamentos } from "@/db/schema";
|
import { lancamentos } from "@/db/schema";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { toDateOnlyString } from "@/lib/utils/date";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||||
|
|
||||||
type RawDashboardBoleto = {
|
type RawDashboardBill = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
amount: string | number | null;
|
amount: string | number | null;
|
||||||
@@ -17,7 +18,7 @@ type RawDashboardBoleto = {
|
|||||||
isSettled: boolean | null;
|
isSettled: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardBoleto = {
|
export type DashboardBill = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -26,35 +27,19 @@ export type DashboardBoleto = {
|
|||||||
isSettled: boolean;
|
isSettled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardBoletosSnapshot = {
|
export type DashboardBillsSnapshot = {
|
||||||
boletos: DashboardBoleto[];
|
bills: DashboardBill[];
|
||||||
totalPendingAmount: number;
|
totalPendingAmount: number;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toISODate = (value: Date | string | null) => {
|
export async function fetchDashboardBills(
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return value.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchDashboardBoletos(
|
|
||||||
userId: string,
|
userId: string,
|
||||||
period: string,
|
period: string,
|
||||||
): Promise<DashboardBoletosSnapshot> {
|
): Promise<DashboardBillsSnapshot> {
|
||||||
const adminPagadorId = await getAdminPagadorId(userId);
|
const adminPagadorId = await getAdminPagadorId(userId);
|
||||||
if (!adminPagadorId) {
|
if (!adminPagadorId) {
|
||||||
return { boletos: [], totalPendingAmount: 0, pendingCount: 0 };
|
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -81,14 +66,14 @@ export async function fetchDashboardBoletos(
|
|||||||
asc(lancamentos.name),
|
asc(lancamentos.name),
|
||||||
);
|
);
|
||||||
|
|
||||||
const boletos = rows.map((row: RawDashboardBoleto): DashboardBoleto => {
|
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
|
||||||
const amount = Math.abs(toNumber(row.amount));
|
const amount = Math.abs(toNumber(row.amount));
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
amount,
|
amount,
|
||||||
dueDate: toISODate(row.dueDate),
|
dueDate: toDateOnlyString(row.dueDate),
|
||||||
boletoPaymentDate: toISODate(row.boletoPaymentDate),
|
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
|
||||||
isSettled: Boolean(row.isSettled),
|
isSettled: Boolean(row.isSettled),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -96,15 +81,15 @@ export async function fetchDashboardBoletos(
|
|||||||
let totalPendingAmount = 0;
|
let totalPendingAmount = 0;
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
|
|
||||||
for (const boleto of boletos) {
|
for (const bill of bills) {
|
||||||
if (!boleto.isSettled) {
|
if (!bill.isSettled) {
|
||||||
totalPendingAmount += boleto.amount;
|
totalPendingAmount += bill.amount;
|
||||||
pendingCount += 1;
|
pendingCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
boletos,
|
bills,
|
||||||
totalPendingAmount,
|
totalPendingAmount,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
};
|
};
|
||||||
121
lib/dashboard/categories/category-breakdown.ts
Normal file
121
lib/dashboard/categories/category-breakdown.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { calculatePercentageChange } from "@/lib/utils/math";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
|
export type DashboardCategoryBreakdownItem = {
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
categoryIcon: string | null;
|
||||||
|
currentAmount: number;
|
||||||
|
previousAmount: number;
|
||||||
|
percentageChange: number | null;
|
||||||
|
percentageOfTotal: number;
|
||||||
|
budgetAmount: number | null;
|
||||||
|
budgetUsedPercentage: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardCategoryBreakdownData = {
|
||||||
|
categories: DashboardCategoryBreakdownItem[];
|
||||||
|
currentTotal: number;
|
||||||
|
previousTotal: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CategoryBreakdownRow = {
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
categoryIcon: string | null;
|
||||||
|
period: string | null;
|
||||||
|
total: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CategoryBudgetRow = {
|
||||||
|
categoriaId: string | null;
|
||||||
|
amount: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildCategoryBreakdownData({
|
||||||
|
rows,
|
||||||
|
budgetRows,
|
||||||
|
period,
|
||||||
|
}: {
|
||||||
|
rows: CategoryBreakdownRow[];
|
||||||
|
budgetRows: CategoryBudgetRow[];
|
||||||
|
period: string;
|
||||||
|
}): DashboardCategoryBreakdownData {
|
||||||
|
const budgetMap = new Map<string, number>();
|
||||||
|
for (const row of budgetRows) {
|
||||||
|
if (row.categoriaId) {
|
||||||
|
budgetMap.set(row.categoriaId, toNumber(row.amount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const entry = categoryMap.get(row.categoryId) ?? {
|
||||||
|
name: row.categoryName,
|
||||||
|
icon: row.categoryIcon,
|
||||||
|
current: 0,
|
||||||
|
previous: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const amount = Math.abs(toNumber(row.total));
|
||||||
|
if (row.period === period) {
|
||||||
|
entry.current = amount;
|
||||||
|
} else {
|
||||||
|
entry.previous = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryMap.set(row.categoryId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentTotal = 0;
|
||||||
|
let previousTotal = 0;
|
||||||
|
for (const entry of categoryMap.values()) {
|
||||||
|
currentTotal += entry.current;
|
||||||
|
previousTotal += entry.previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories: DashboardCategoryBreakdownItem[] = [];
|
||||||
|
for (const [categoryId, entry] of categoryMap) {
|
||||||
|
const percentageChange = calculatePercentageChange(
|
||||||
|
entry.current,
|
||||||
|
entry.previous,
|
||||||
|
);
|
||||||
|
const percentageOfTotal =
|
||||||
|
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
|
||||||
|
|
||||||
|
const budgetAmount = budgetMap.get(categoryId) ?? null;
|
||||||
|
const budgetUsedPercentage =
|
||||||
|
budgetAmount && budgetAmount > 0
|
||||||
|
? (entry.current / budgetAmount) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
categories.push({
|
||||||
|
categoryId,
|
||||||
|
categoryName: entry.name,
|
||||||
|
categoryIcon: entry.icon,
|
||||||
|
currentAmount: entry.current,
|
||||||
|
previousAmount: entry.previous,
|
||||||
|
percentageChange,
|
||||||
|
percentageOfTotal,
|
||||||
|
budgetAmount,
|
||||||
|
budgetUsedPercentage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
currentTotal,
|
||||||
|
previousTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import {
|
|||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
INITIAL_BALANCE_NOTE,
|
INITIAL_BALANCE_NOTE,
|
||||||
} from "@/lib/contas/constants";
|
} from "@/lib/contas/constants";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
|
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||||
|
|
||||||
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
|
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { addMonths, format } from "date-fns";
|
|
||||||
import { ptBR } from "date-fns/locale";
|
|
||||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
|
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
import {
|
||||||
|
addMonthsToPeriod,
|
||||||
|
buildPeriodWindow,
|
||||||
|
formatPeriodMonthShort,
|
||||||
|
} from "@/lib/utils/period";
|
||||||
|
|
||||||
export type CategoryOption = {
|
export type CategoryOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,6 +37,19 @@ export type CategoryHistoryData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CHART_COLORS = CATEGORY_COLORS;
|
const CHART_COLORS = CATEGORY_COLORS;
|
||||||
|
type MonthlyCategoryRow = {
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
categoryIcon: string | null;
|
||||||
|
period: string;
|
||||||
|
totalAmount: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UniqueCategory = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchAllCategories(
|
export async function fetchAllCategories(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -61,26 +77,16 @@ export async function fetchCategoryHistory(
|
|||||||
currentPeriod: string,
|
currentPeriod: string,
|
||||||
): Promise<CategoryHistoryData> {
|
): Promise<CategoryHistoryData> {
|
||||||
// Generate last 8 months, current month, and next month (10 total)
|
// Generate last 8 months, current month, and next month (10 total)
|
||||||
const periods: string[] = [];
|
const periods = buildPeriodWindow(addMonthsToPeriod(currentPeriod, 1), 10);
|
||||||
const monthLabels: string[] = [];
|
const monthLabels = periods.map((period) =>
|
||||||
|
formatPeriodMonthShort(period).toUpperCase(),
|
||||||
const [year, month] = currentPeriod.split("-").map(Number);
|
);
|
||||||
const currentDate = new Date(year, month - 1, 1);
|
|
||||||
|
|
||||||
// Generate months from -8 to +1 (relative to current)
|
|
||||||
for (let i = 8; i >= -1; i--) {
|
|
||||||
const date = addMonths(currentDate, -i);
|
|
||||||
const period = format(date, "yyyy-MM");
|
|
||||||
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
|
|
||||||
periods.push(period);
|
|
||||||
monthLabels.push(label);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all categories for the selector
|
// Fetch all categories for the selector
|
||||||
const allCategories = await fetchAllCategories(userId);
|
const allCategories = await fetchAllCategories(userId);
|
||||||
|
|
||||||
// Fetch monthly data for ALL categories with transactions
|
// Fetch monthly data for ALL categories with transactions
|
||||||
const monthlyDataQuery = await db
|
const monthlyDataQuery = (await db
|
||||||
.select({
|
.select({
|
||||||
categoryId: categorias.id,
|
categoryId: categorias.id,
|
||||||
categoryName: categorias.name,
|
categoryName: categorias.name,
|
||||||
@@ -112,7 +118,7 @@ export async function fetchCategoryHistory(
|
|||||||
categorias.name,
|
categorias.name,
|
||||||
categorias.icon,
|
categorias.icon,
|
||||||
lancamentos.period,
|
lancamentos.period,
|
||||||
);
|
)) as MonthlyCategoryRow[];
|
||||||
|
|
||||||
if (monthlyDataQuery.length === 0) {
|
if (monthlyDataQuery.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -124,8 +130,8 @@ export async function fetchCategoryHistory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get unique categories from query results
|
// Get unique categories from query results
|
||||||
const uniqueCategories = Array.from(
|
const uniqueCategories: UniqueCategory[] = Array.from(
|
||||||
new Map(
|
new Map<string, UniqueCategory>(
|
||||||
monthlyDataQuery.map((row) => [
|
monthlyDataQuery.map((row) => [
|
||||||
row.categoryId,
|
row.categoryId,
|
||||||
{
|
{
|
||||||
@@ -178,15 +184,20 @@ export async function fetchCategoryHistory(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Convert to chart data format
|
// Convert to chart data format
|
||||||
const chartData = monthLabels.map((month) => {
|
const chartData: CategoryHistoryData["chartData"] = monthLabels.map(
|
||||||
const dataPoint: Record<string, number | string> = { month };
|
(month) => {
|
||||||
|
const dataPoint: {
|
||||||
|
month: string;
|
||||||
|
[categoryName: string]: number | string;
|
||||||
|
} = { month };
|
||||||
|
|
||||||
categoriesMap.forEach((category) => {
|
categoriesMap.forEach((category) => {
|
||||||
dataPoint[category.name] = category.data[month];
|
dataPoint[category.name] = category.data[month];
|
||||||
});
|
});
|
||||||
|
|
||||||
return dataPoint;
|
return dataPoint;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
months: monthLabels,
|
months: monthLabels,
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { categorias, lancamentos, orcamentos } from "@/db/schema";
|
import { categorias, lancamentos, orcamentos } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
import {
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
buildCategoryBreakdownData,
|
||||||
|
type DashboardCategoryBreakdownData,
|
||||||
|
type DashboardCategoryBreakdownItem,
|
||||||
|
} from "@/lib/dashboard/categories/category-breakdown";
|
||||||
|
import {
|
||||||
|
buildDashboardAdminFilters,
|
||||||
|
excludeAutoInvoiceEntries,
|
||||||
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
|
||||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||||
|
|
||||||
export type CategoryExpenseItem = {
|
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
|
||||||
categoryId: string;
|
export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
|
||||||
categoryName: string;
|
|
||||||
categoryIcon: string | null;
|
|
||||||
currentAmount: number;
|
|
||||||
previousAmount: number;
|
|
||||||
percentageChange: number | null;
|
|
||||||
percentageOfTotal: number;
|
|
||||||
budgetAmount: number | null;
|
|
||||||
budgetUsedPercentage: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExpensesByCategoryData = {
|
|
||||||
categories: CategoryExpenseItem[];
|
|
||||||
currentTotal: number;
|
|
||||||
previousTotal: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchExpensesByCategory(
|
export async function fetchExpensesByCategory(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -50,15 +41,11 @@ export async function fetchExpensesByCategory(
|
|||||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
|
||||||
inArray(lancamentos.period, [period, previousPeriod]),
|
inArray(lancamentos.period, [period, previousPeriod]),
|
||||||
eq(lancamentos.transactionType, "Despesa"),
|
eq(lancamentos.transactionType, "Despesa"),
|
||||||
eq(categorias.type, "despesa"),
|
eq(categorias.type, "despesa"),
|
||||||
or(
|
excludeAutoInvoiceEntries(),
|
||||||
isNull(lancamentos.note),
|
|
||||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
@@ -76,85 +63,9 @@ export async function fetchExpensesByCategory(
|
|||||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
|
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build budget lookup
|
return buildCategoryBreakdownData({
|
||||||
const budgetMap = new Map<string, number>();
|
rows,
|
||||||
for (const row of budgetRows) {
|
budgetRows,
|
||||||
if (row.categoriaId) {
|
period,
|
||||||
budgetMap.set(row.categoriaId, toNumber(row.amount));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build category data from grouped results
|
|
||||||
const categoryMap = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
name: string;
|
|
||||||
icon: string | null;
|
|
||||||
current: number;
|
|
||||||
previous: number;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const entry = categoryMap.get(row.categoryId) ?? {
|
|
||||||
name: row.categoryName,
|
|
||||||
icon: row.categoryIcon,
|
|
||||||
current: 0,
|
|
||||||
previous: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const amount = Math.abs(toNumber(row.total));
|
|
||||||
if (row.period === period) {
|
|
||||||
entry.current = amount;
|
|
||||||
} else {
|
|
||||||
entry.previous = amount;
|
|
||||||
}
|
|
||||||
categoryMap.set(row.categoryId, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
let currentTotal = 0;
|
|
||||||
let previousTotal = 0;
|
|
||||||
for (const entry of categoryMap.values()) {
|
|
||||||
currentTotal += entry.current;
|
|
||||||
previousTotal += entry.previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build result
|
|
||||||
const categories: CategoryExpenseItem[] = [];
|
|
||||||
for (const [categoryId, entry] of categoryMap) {
|
|
||||||
const percentageChange = calculatePercentageChange(
|
|
||||||
entry.current,
|
|
||||||
entry.previous,
|
|
||||||
);
|
|
||||||
const percentageOfTotal =
|
|
||||||
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
|
|
||||||
|
|
||||||
const budgetAmount = budgetMap.get(categoryId) ?? null;
|
|
||||||
const budgetUsedPercentage =
|
|
||||||
budgetAmount && budgetAmount > 0
|
|
||||||
? (entry.current / budgetAmount) * 100
|
|
||||||
: null;
|
|
||||||
|
|
||||||
categories.push({
|
|
||||||
categoryId,
|
|
||||||
categoryName: entry.name,
|
|
||||||
categoryIcon: entry.icon,
|
|
||||||
currentAmount: entry.current,
|
|
||||||
previousAmount: entry.previous,
|
|
||||||
percentageChange,
|
|
||||||
percentageOfTotal,
|
|
||||||
budgetAmount,
|
|
||||||
budgetUsedPercentage,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordena por valor atual (maior para menor)
|
|
||||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
|
||||||
|
|
||||||
return {
|
|
||||||
categories,
|
|
||||||
currentTotal,
|
|
||||||
previousTotal,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,32 +1,21 @@
|
|||||||
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
|
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
buildCategoryBreakdownData,
|
||||||
INITIAL_BALANCE_NOTE,
|
type DashboardCategoryBreakdownData,
|
||||||
} from "@/lib/contas/constants";
|
type DashboardCategoryBreakdownItem,
|
||||||
|
} from "@/lib/dashboard/categories/category-breakdown";
|
||||||
|
import {
|
||||||
|
buildDashboardAdminFilters,
|
||||||
|
excludeAutoInvoiceEntries,
|
||||||
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
|
||||||
import { safeToNumber } from "@/lib/utils/number";
|
|
||||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||||
|
|
||||||
export type CategoryIncomeItem = {
|
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
|
||||||
categoryId: string;
|
export type IncomeByCategoryData = DashboardCategoryBreakdownData;
|
||||||
categoryName: string;
|
|
||||||
categoryIcon: string | null;
|
|
||||||
currentAmount: number;
|
|
||||||
previousAmount: number;
|
|
||||||
percentageChange: number | null;
|
|
||||||
percentageOfTotal: number;
|
|
||||||
budgetAmount: number | null;
|
|
||||||
budgetUsedPercentage: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IncomeByCategoryData = {
|
|
||||||
categories: CategoryIncomeItem[];
|
|
||||||
currentTotal: number;
|
|
||||||
previousTotal: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchIncomeByCategory(
|
export async function fetchIncomeByCategory(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -54,21 +43,12 @@ export async function fetchIncomeByCategory(
|
|||||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
|
||||||
inArray(lancamentos.period, [period, previousPeriod]),
|
inArray(lancamentos.period, [period, previousPeriod]),
|
||||||
eq(lancamentos.transactionType, "Receita"),
|
eq(lancamentos.transactionType, "Receita"),
|
||||||
eq(categorias.type, "receita"),
|
eq(categorias.type, "receita"),
|
||||||
or(
|
excludeAutoInvoiceEntries(),
|
||||||
isNull(lancamentos.note),
|
excludeInitialBalanceWhenConfigured(),
|
||||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
|
||||||
),
|
|
||||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
|
||||||
or(
|
|
||||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
|
||||||
isNull(contas.excludeInitialBalanceFromIncome),
|
|
||||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
@@ -86,85 +66,9 @@ export async function fetchIncomeByCategory(
|
|||||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
|
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build budget lookup
|
return buildCategoryBreakdownData({
|
||||||
const budgetMap = new Map<string, number>();
|
rows,
|
||||||
for (const row of budgetRows) {
|
budgetRows,
|
||||||
if (row.categoriaId) {
|
period,
|
||||||
budgetMap.set(row.categoriaId, safeToNumber(row.amount));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build category data from grouped results
|
|
||||||
const categoryMap = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
name: string;
|
|
||||||
icon: string | null;
|
|
||||||
current: number;
|
|
||||||
previous: number;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const entry = categoryMap.get(row.categoryId) ?? {
|
|
||||||
name: row.categoryName,
|
|
||||||
icon: row.categoryIcon,
|
|
||||||
current: 0,
|
|
||||||
previous: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const amount = Math.abs(safeToNumber(row.total));
|
|
||||||
if (row.period === period) {
|
|
||||||
entry.current = amount;
|
|
||||||
} else {
|
|
||||||
entry.previous = amount;
|
|
||||||
}
|
|
||||||
categoryMap.set(row.categoryId, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
let currentTotal = 0;
|
|
||||||
let previousTotal = 0;
|
|
||||||
for (const entry of categoryMap.values()) {
|
|
||||||
currentTotal += entry.current;
|
|
||||||
previousTotal += entry.previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build result
|
|
||||||
const categories: CategoryIncomeItem[] = [];
|
|
||||||
for (const [categoryId, entry] of categoryMap) {
|
|
||||||
const percentageChange = calculatePercentageChange(
|
|
||||||
entry.current,
|
|
||||||
entry.previous,
|
|
||||||
);
|
|
||||||
const percentageOfTotal =
|
|
||||||
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
|
|
||||||
|
|
||||||
const budgetAmount = budgetMap.get(categoryId) ?? null;
|
|
||||||
const budgetUsedPercentage =
|
|
||||||
budgetAmount && budgetAmount > 0
|
|
||||||
? (entry.current / budgetAmount) * 100
|
|
||||||
: null;
|
|
||||||
|
|
||||||
categories.push({
|
|
||||||
categoryId,
|
|
||||||
categoryName: entry.name,
|
|
||||||
categoryIcon: entry.icon,
|
|
||||||
currentAmount: entry.current,
|
|
||||||
previousAmount: entry.previous,
|
|
||||||
percentageChange,
|
|
||||||
percentageOfTotal,
|
|
||||||
budgetAmount,
|
|
||||||
budgetUsedPercentage,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordena por valor atual (maior para menor)
|
|
||||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
|
||||||
|
|
||||||
return {
|
|
||||||
categories,
|
|
||||||
currentTotal,
|
|
||||||
previousTotal,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
|
||||||
import { safeToNumber } from "@/lib/utils/number";
|
|
||||||
|
|
||||||
export { safeToNumber, calculatePercentageChange };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alias for backward compatibility - dashboard uses "toNumber" naming
|
|
||||||
*/
|
|
||||||
export const toNumber = safeToNumber;
|
|
||||||
@@ -1,21 +1,10 @@
|
|||||||
import {
|
import { and, asc, eq, gte, lte, ne, sum } from "drizzle-orm";
|
||||||
and,
|
|
||||||
asc,
|
|
||||||
eq,
|
|
||||||
gte,
|
|
||||||
ilike,
|
|
||||||
isNull,
|
|
||||||
lte,
|
|
||||||
ne,
|
|
||||||
not,
|
|
||||||
or,
|
|
||||||
sum,
|
|
||||||
} from "drizzle-orm";
|
|
||||||
import { contas, lancamentos } from "@/db/schema";
|
import { contas, lancamentos } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
buildDashboardAdminFilters,
|
||||||
INITIAL_BALANCE_NOTE,
|
excludeAutoInvoiceEntries,
|
||||||
} from "@/lib/contas/constants";
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
import { safeToNumber } from "@/lib/utils/number";
|
import { safeToNumber } from "@/lib/utils/number";
|
||||||
@@ -107,21 +96,12 @@ export async function fetchDashboardCardMetrics(
|
|||||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
|
||||||
gte(lancamentos.period, startPeriod),
|
gte(lancamentos.period, startPeriod),
|
||||||
lte(lancamentos.period, period),
|
lte(lancamentos.period, period),
|
||||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||||
or(
|
excludeAutoInvoiceEntries(),
|
||||||
isNull(lancamentos.note),
|
excludeInitialBalanceWhenConfigured(),
|
||||||
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
|
||||||
),
|
|
||||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
|
||||||
or(
|
|
||||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
|
||||||
isNull(contas.excludeInitialBalanceFromIncome),
|
|
||||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(lancamentos.period, lancamentos.transactionType)
|
.groupBy(lancamentos.period, lancamentos.transactionType)
|
||||||
@@ -4,23 +4,28 @@ import {
|
|||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
INITIAL_BALANCE_NOTE,
|
INITIAL_BALANCE_NOTE,
|
||||||
} from "@/lib/contas/constants";
|
} from "@/lib/contas/constants";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
|
import {
|
||||||
|
buildDateOnlyStringFromPeriodDay,
|
||||||
|
parseLocalDateString,
|
||||||
|
} from "@/lib/utils/date";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
// Calcula a data de vencimento baseada no período e dia de vencimento do cartão
|
// Calcula a data de vencimento baseada no período e dia de vencimento do cartão
|
||||||
function calculateDueDate(period: string, dueDay: string | null): Date | null {
|
function calculateDueDate(period: string, dueDay: string | null): Date | null {
|
||||||
if (!dueDay) return null;
|
if (!dueDay) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [year, month] = period.split("-");
|
const dueDateString = buildDateOnlyStringFromPeriodDay(period, dueDay);
|
||||||
if (!year || !month) return null;
|
if (!dueDateString) return null;
|
||||||
|
|
||||||
const day = parseInt(dueDay, 10);
|
const dueDate = parseLocalDateString(dueDateString);
|
||||||
if (Number.isNaN(day)) return null;
|
if (Number.isNaN(dueDate.getTime())) return null;
|
||||||
|
|
||||||
// Criar data ao meio-dia para evitar problemas de timezone
|
// Meio-dia evita drift visual em serialização/locales diferentes.
|
||||||
return new Date(parseInt(year, 10), parseInt(month, 10) - 1, day, 12, 0, 0);
|
dueDate.setHours(12, 0, 0, 0);
|
||||||
|
return dueDate;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import {
|
|||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
INITIAL_BALANCE_NOTE,
|
INITIAL_BALANCE_NOTE,
|
||||||
} from "@/lib/contas/constants";
|
} from "@/lib/contas/constants";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
export type InstallmentExpense = {
|
export type InstallmentExpense = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import {
|
|||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
INITIAL_BALANCE_NOTE,
|
INITIAL_BALANCE_NOTE,
|
||||||
} from "@/lib/contas/constants";
|
} from "@/lib/contas/constants";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
export type RecurringExpense = {
|
export type RecurringExpense = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
import { cartoes, contas, lancamentos } from "@/db/schema";
|
import { cartoes, contas, lancamentos } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
buildDashboardAdminPeriodFilters,
|
||||||
INITIAL_BALANCE_NOTE,
|
excludeAutoGeneratedEntryNotes,
|
||||||
} from "@/lib/contas/constants";
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
export type TopExpense = {
|
export type TopExpense = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,19 +32,13 @@ export async function fetchTopExpenses(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminPeriodFilters({
|
||||||
eq(lancamentos.period, period),
|
userId,
|
||||||
|
period,
|
||||||
|
adminPagadorId,
|
||||||
|
}),
|
||||||
eq(lancamentos.transactionType, "Despesa"),
|
eq(lancamentos.transactionType, "Despesa"),
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
excludeAutoGeneratedEntryNotes(),
|
||||||
or(
|
|
||||||
isNull(lancamentos.note),
|
|
||||||
and(
|
|
||||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
|
||||||
sql`${
|
|
||||||
lancamentos.note
|
|
||||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Se cardOnly for true, filtra apenas pagamentos com cartão
|
// Se cardOnly for true, filtra apenas pagamentos com cartão
|
||||||
@@ -72,7 +66,7 @@ export async function fetchTopExpenses(
|
|||||||
.limit(10);
|
.limit(10);
|
||||||
|
|
||||||
const expenses = results.map(
|
const expenses = results.map(
|
||||||
(row): TopExpense => ({
|
(row: (typeof results)[number]): TopExpense => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
amount: Math.abs(toNumber(row.amount)),
|
amount: Math.abs(toNumber(row.amount)),
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { unstable_cache } from "next/cache";
|
import { unstable_cache } from "next/cache";
|
||||||
import { fetchDashboardAccounts } from "./accounts";
|
import { fetchDashboardAccounts } from "./accounts";
|
||||||
import { fetchDashboardBoletos } from "./boletos";
|
import { fetchDashboardBills } from "./bills";
|
||||||
import { fetchExpensesByCategory } from "./categories/expenses-by-category";
|
import { fetchExpensesByCategory } from "./categories/expenses-by-category";
|
||||||
import { fetchIncomeByCategory } from "./categories/income-by-category";
|
import { fetchIncomeByCategory } from "./categories/income-by-category";
|
||||||
|
import { fetchDashboardCardMetrics } from "./dashboard-metrics";
|
||||||
import { fetchInstallmentExpenses } from "./expenses/installment-expenses";
|
import { fetchInstallmentExpenses } from "./expenses/installment-expenses";
|
||||||
import { fetchRecurringExpenses } from "./expenses/recurring-expenses";
|
import { fetchRecurringExpenses } from "./expenses/recurring-expenses";
|
||||||
import { fetchTopExpenses } from "./expenses/top-expenses";
|
import { fetchTopExpenses } from "./expenses/top-expenses";
|
||||||
import { fetchGoalsProgressData } from "./goals-progress";
|
import { fetchGoalsProgressData } from "./goals-progress";
|
||||||
import { fetchIncomeExpenseBalance } from "./income-expense-balance";
|
import { fetchIncomeExpenseBalance } from "./income-expense-balance";
|
||||||
import { fetchDashboardInvoices } from "./invoices";
|
import { fetchDashboardInvoices } from "./invoices";
|
||||||
import { fetchDashboardCardMetrics } from "./metrics";
|
|
||||||
import { fetchDashboardNotes } from "./notes";
|
import { fetchDashboardNotes } from "./notes";
|
||||||
import { fetchDashboardPagadores } from "./pagadores";
|
import { fetchDashboardPagadores } from "./pagadores";
|
||||||
import { fetchPaymentConditions } from "./payments/payment-conditions";
|
import { fetchPaymentConditions } from "./payments/payment-conditions";
|
||||||
@@ -23,7 +23,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
metrics,
|
metrics,
|
||||||
accountsSnapshot,
|
accountsSnapshot,
|
||||||
invoicesSnapshot,
|
invoicesSnapshot,
|
||||||
boletosSnapshot,
|
billsSnapshot,
|
||||||
goalsProgressData,
|
goalsProgressData,
|
||||||
paymentStatusData,
|
paymentStatusData,
|
||||||
incomeExpenseBalanceData,
|
incomeExpenseBalanceData,
|
||||||
@@ -43,7 +43,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
fetchDashboardCardMetrics(userId, period),
|
fetchDashboardCardMetrics(userId, period),
|
||||||
fetchDashboardAccounts(userId),
|
fetchDashboardAccounts(userId),
|
||||||
fetchDashboardInvoices(userId, period),
|
fetchDashboardInvoices(userId, period),
|
||||||
fetchDashboardBoletos(userId, period),
|
fetchDashboardBills(userId, period),
|
||||||
fetchGoalsProgressData(userId, period),
|
fetchGoalsProgressData(userId, period),
|
||||||
fetchPaymentStatus(userId, period),
|
fetchPaymentStatus(userId, period),
|
||||||
fetchIncomeExpenseBalance(userId, period),
|
fetchIncomeExpenseBalance(userId, period),
|
||||||
@@ -65,7 +65,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
metrics,
|
metrics,
|
||||||
accountsSnapshot,
|
accountsSnapshot,
|
||||||
invoicesSnapshot,
|
invoicesSnapshot,
|
||||||
boletosSnapshot,
|
billsSnapshot,
|
||||||
goalsProgressData,
|
goalsProgressData,
|
||||||
paymentStatusData,
|
paymentStatusData,
|
||||||
incomeExpenseBalanceData,
|
incomeExpenseBalanceData,
|
||||||
@@ -95,7 +95,7 @@ export function fetchDashboardData(userId: string, period: string) {
|
|||||||
[`dashboard-${userId}-${period}`],
|
[`dashboard-${userId}-${period}`],
|
||||||
{
|
{
|
||||||
tags: ["dashboard", `dashboard-${userId}`],
|
tags: ["dashboard", `dashboard-${userId}`],
|
||||||
revalidate: 120,
|
revalidate: 60,
|
||||||
},
|
},
|
||||||
)();
|
)();
|
||||||
}
|
}
|
||||||
|
|||||||
45
lib/dashboard/goals-progress-helpers.ts
Normal file
45
lib/dashboard/goals-progress-helpers.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
|
||||||
|
import type {
|
||||||
|
GoalProgressCategory,
|
||||||
|
GoalProgressItem,
|
||||||
|
GoalProgressStatus,
|
||||||
|
} from "@/lib/dashboard/goals-progress";
|
||||||
|
import { formatPercentage } from "@/lib/utils/percentage";
|
||||||
|
|
||||||
|
export const clampGoalProgress = (value: number, min: number, max: number) =>
|
||||||
|
Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
export const formatGoalProgressPercentage = (value: number, withSign = false) =>
|
||||||
|
formatPercentage(value, {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
signDisplay: withSign ? "always" : "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getGoalProgressStatusColorClass = (status: GoalProgressStatus) =>
|
||||||
|
status === "exceeded" ? "text-destructive" : "";
|
||||||
|
|
||||||
|
export const mapGoalProgressCategoriesToBudgetCategories = (
|
||||||
|
categories: GoalProgressCategory[],
|
||||||
|
): BudgetCategory[] =>
|
||||||
|
categories.map((category) => ({
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
icon: category.icon,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const mapGoalProgressItemToBudget = (
|
||||||
|
item: GoalProgressItem,
|
||||||
|
): Budget => ({
|
||||||
|
id: item.id,
|
||||||
|
amount: item.budgetAmount,
|
||||||
|
spent: item.spentAmount,
|
||||||
|
period: item.period,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
category: item.categoryId
|
||||||
|
? {
|
||||||
|
id: item.categoryId,
|
||||||
|
name: item.categoryName,
|
||||||
|
icon: item.categoryIcon,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { and, eq, ne, sql } from "drizzle-orm";
|
import { and, eq, ne, sql } from "drizzle-orm";
|
||||||
import { categorias, lancamentos, orcamentos } from "@/db/schema";
|
import { categorias, lancamentos, orcamentos } from "@/db/schema";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
const BUDGET_CRITICAL_THRESHOLD = 80;
|
const BUDGET_CRITICAL_THRESHOLD = 80;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { contas, lancamentos } from "@/db/schema";
|
import { contas, lancamentos } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
buildDashboardAdminFilters,
|
||||||
INITIAL_BALANCE_NOTE,
|
excludeAutoInvoiceEntries,
|
||||||
} from "@/lib/contas/constants";
|
excludeInitialBalanceWhenConfigured,
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
import {
|
||||||
|
buildPeriodWindow,
|
||||||
|
formatPeriodMonthShort,
|
||||||
|
getCurrentPeriod,
|
||||||
|
} from "@/lib/utils/period";
|
||||||
|
|
||||||
export type MonthData = {
|
export type MonthData = {
|
||||||
month: string;
|
month: string;
|
||||||
@@ -20,47 +26,12 @@ export type IncomeExpenseBalanceData = {
|
|||||||
months: MonthData[];
|
months: MonthData[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTH_LABELS: Record<string, string> = {
|
|
||||||
"01": "jan",
|
|
||||||
"02": "fev",
|
|
||||||
"03": "mar",
|
|
||||||
"04": "abr",
|
|
||||||
"05": "mai",
|
|
||||||
"06": "jun",
|
|
||||||
"07": "jul",
|
|
||||||
"08": "ago",
|
|
||||||
"09": "set",
|
|
||||||
"10": "out",
|
|
||||||
"11": "nov",
|
|
||||||
"12": "dez",
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateLast6Months = (currentPeriod: string): string[] => {
|
const generateLast6Months = (currentPeriod: string): string[] => {
|
||||||
const [yearStr, monthStr] = currentPeriod.split("-");
|
try {
|
||||||
let year = Number.parseInt(yearStr ?? "", 10);
|
return buildPeriodWindow(currentPeriod, 6);
|
||||||
let month = Number.parseInt(monthStr ?? "", 10);
|
} catch {
|
||||||
|
return buildPeriodWindow(getCurrentPeriod(), 6);
|
||||||
if (Number.isNaN(year) || Number.isNaN(month)) {
|
|
||||||
const now = new Date();
|
|
||||||
year = now.getFullYear();
|
|
||||||
month = now.getMonth() + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const periods: string[] = [];
|
|
||||||
|
|
||||||
for (let i = 5; i >= 0; i--) {
|
|
||||||
let targetMonth = month - i;
|
|
||||||
let targetYear = year;
|
|
||||||
|
|
||||||
while (targetMonth <= 0) {
|
|
||||||
targetMonth += 12;
|
|
||||||
targetYear -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return periods;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchIncomeExpenseBalance(
|
export async function fetchIncomeExpenseBalance(
|
||||||
@@ -85,17 +56,11 @@ export async function fetchIncomeExpenseBalance(
|
|||||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
|
||||||
inArray(lancamentos.period, periods),
|
inArray(lancamentos.period, periods),
|
||||||
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
|
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
|
||||||
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
|
excludeAutoInvoiceEntries(),
|
||||||
// Excluir saldos iniciais se a conta tiver o flag ativo
|
excludeInitialBalanceWhenConfigured(),
|
||||||
or(
|
|
||||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
|
||||||
isNull(contas.excludeInitialBalanceFromIncome),
|
|
||||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(lancamentos.period, lancamentos.transactionType);
|
.groupBy(lancamentos.period, lancamentos.transactionType);
|
||||||
@@ -117,12 +82,10 @@ export async function fetchIncomeExpenseBalance(
|
|||||||
// Build result array preserving period order
|
// Build result array preserving period order
|
||||||
const months = periods.map((period) => {
|
const months = periods.map((period) => {
|
||||||
const entry = dataMap.get(period) ?? { income: 0, expense: 0 };
|
const entry = dataMap.get(period) ?? { income: 0, expense: 0 };
|
||||||
const [, monthPart] = period.split("-");
|
|
||||||
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
month: period,
|
month: period,
|
||||||
monthLabel: monthLabel ?? "",
|
monthLabel: formatPeriodMonthShort(period).toLowerCase(),
|
||||||
income: entry.income,
|
income: entry.income,
|
||||||
expense: entry.expense,
|
expense: entry.expense,
|
||||||
balance: entry.income - entry.expense,
|
balance: entry.income - entry.expense,
|
||||||
|
|||||||
116
lib/dashboard/installment-expenses-helpers.ts
Normal file
116
lib/dashboard/installment-expenses-helpers.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
|
||||||
|
import {
|
||||||
|
calculateLastInstallmentDate,
|
||||||
|
formatLastInstallmentDate,
|
||||||
|
} from "@/lib/installments/utils";
|
||||||
|
|
||||||
|
export type InstallmentExpenseDisplay = {
|
||||||
|
compactLabel: string | null;
|
||||||
|
isLast: boolean;
|
||||||
|
remainingInstallments: number;
|
||||||
|
remainingAmount: number;
|
||||||
|
endDate: string | null;
|
||||||
|
progress: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildInstallmentCompactLabel = (
|
||||||
|
currentInstallment: number | null,
|
||||||
|
installmentCount: number | null,
|
||||||
|
) => {
|
||||||
|
if (currentInstallment && installmentCount) {
|
||||||
|
return `${currentInstallment} de ${installmentCount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isInstallmentLast = (
|
||||||
|
currentInstallment: number | null,
|
||||||
|
installmentCount: number | null,
|
||||||
|
) => {
|
||||||
|
if (!currentInstallment || !installmentCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentInstallment === installmentCount && installmentCount > 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateInstallmentRemainingCount = (
|
||||||
|
currentInstallment: number | null,
|
||||||
|
installmentCount: number | null,
|
||||||
|
) => {
|
||||||
|
if (!currentInstallment || !installmentCount) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, installmentCount - currentInstallment);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateInstallmentRemainingAmount = (
|
||||||
|
amount: number,
|
||||||
|
currentInstallment: number | null,
|
||||||
|
installmentCount: number | null,
|
||||||
|
) =>
|
||||||
|
amount *
|
||||||
|
calculateInstallmentRemainingCount(currentInstallment, installmentCount);
|
||||||
|
|
||||||
|
export const formatInstallmentEndDate = (
|
||||||
|
period: string,
|
||||||
|
currentInstallment: number | null,
|
||||||
|
installmentCount: number | null,
|
||||||
|
) => {
|
||||||
|
if (!currentInstallment || !installmentCount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastDate = calculateLastInstallmentDate(
|
||||||
|
period,
|
||||||
|
currentInstallment,
|
||||||
|
installmentCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
return formatLastInstallmentDate(lastDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildInstallmentProgress = (
|
||||||
|
currentInstallment: number | null,
|
||||||
|
installmentCount: number | null,
|
||||||
|
) => {
|
||||||
|
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(0, (currentInstallment / installmentCount) * 100),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildInstallmentExpenseDisplay = (
|
||||||
|
expense: InstallmentExpense,
|
||||||
|
): InstallmentExpenseDisplay => {
|
||||||
|
const { amount, currentInstallment, installmentCount, period } = expense;
|
||||||
|
|
||||||
|
return {
|
||||||
|
compactLabel: buildInstallmentCompactLabel(
|
||||||
|
currentInstallment,
|
||||||
|
installmentCount,
|
||||||
|
),
|
||||||
|
isLast: isInstallmentLast(currentInstallment, installmentCount),
|
||||||
|
remainingInstallments: calculateInstallmentRemainingCount(
|
||||||
|
currentInstallment,
|
||||||
|
installmentCount,
|
||||||
|
),
|
||||||
|
remainingAmount: calculateInstallmentRemainingAmount(
|
||||||
|
amount,
|
||||||
|
currentInstallment,
|
||||||
|
installmentCount,
|
||||||
|
),
|
||||||
|
endDate: formatInstallmentEndDate(
|
||||||
|
period,
|
||||||
|
currentInstallment,
|
||||||
|
installmentCount,
|
||||||
|
),
|
||||||
|
progress: buildInstallmentProgress(currentInstallment, installmentCount),
|
||||||
|
};
|
||||||
|
};
|
||||||
104
lib/dashboard/invoices-helpers.ts
Normal file
104
lib/dashboard/invoices-helpers.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
|
||||||
|
import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller";
|
||||||
|
import {
|
||||||
|
INVOICE_PAYMENT_STATUS,
|
||||||
|
type InvoicePaymentStatus,
|
||||||
|
} from "@/lib/faturas";
|
||||||
|
import { getBusinessDateString } from "@/lib/utils/date";
|
||||||
|
import {
|
||||||
|
buildDueDateInfoFromPeriodDay,
|
||||||
|
formatFinancialDateLabel,
|
||||||
|
} from "@/lib/utils/financial-dates";
|
||||||
|
import { formatPercentage } from "@/lib/utils/percentage";
|
||||||
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
|
|
||||||
|
export type InvoiceDialogState = PaymentDialogState;
|
||||||
|
export type InvoiceLogoTone = "muted" | "accent";
|
||||||
|
|
||||||
|
type InvoicePaymentDateInfo = {
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InvoiceDueDateInfo = {
|
||||||
|
label: string;
|
||||||
|
date: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildInvoiceInitials = (value: string) => {
|
||||||
|
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "CC";
|
||||||
|
}
|
||||||
|
if (parts.length === 1) {
|
||||||
|
const firstPart = parts[0];
|
||||||
|
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
|
||||||
|
}
|
||||||
|
const firstChar = parts[0]?.[0] ?? "";
|
||||||
|
const secondChar = parts[1]?.[0] ?? "";
|
||||||
|
return `${firstChar}${secondChar}`.toUpperCase() || "CC";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseInvoiceDueDate = (
|
||||||
|
period: string,
|
||||||
|
dueDay: string,
|
||||||
|
): InvoiceDueDateInfo => {
|
||||||
|
return buildDueDateInfoFromPeriodDay(period, dueDay);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatInvoicePaymentDate = (
|
||||||
|
value: string | null,
|
||||||
|
): InvoicePaymentDateInfo | null => {
|
||||||
|
const label = formatFinancialDateLabel(value, "Pago em");
|
||||||
|
if (!label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentDateString = () => getBusinessDateString();
|
||||||
|
|
||||||
|
const formatInvoiceSharePercentage = (value: number) => {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return "0%";
|
||||||
|
}
|
||||||
|
const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
|
||||||
|
return formatPercentage(value, {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInvoiceShareLabel = (amount: number, total: number) => {
|
||||||
|
if (total <= 0) {
|
||||||
|
return "0% do total";
|
||||||
|
}
|
||||||
|
const percentage = (amount / total) * 100;
|
||||||
|
return `${formatInvoiceSharePercentage(percentage)} do total`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInvoiceStatusBadgeVariant = (
|
||||||
|
statusLabel: string,
|
||||||
|
): "success" | "info" => {
|
||||||
|
if (statusLabel.toLowerCase() === "em aberto") {
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
return "success";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildInvoiceDetailsHref = (cardId: string, period: string) =>
|
||||||
|
`/cartoes/${cardId}/fatura?periodo=${formatPeriodForUrl(period)}`;
|
||||||
|
|
||||||
|
export const markInvoiceAsPaid = (
|
||||||
|
invoice: DashboardInvoice,
|
||||||
|
paidAt: string,
|
||||||
|
): DashboardInvoice => ({
|
||||||
|
...invoice,
|
||||||
|
paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
|
||||||
|
paidAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isInvoicePaid = (status: InvoicePaymentStatus) =>
|
||||||
|
status === INVOICE_PAYMENT_STATUS.PAID;
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
|
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
|
||||||
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
|
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
INVOICE_PAYMENT_STATUS,
|
INVOICE_PAYMENT_STATUS,
|
||||||
INVOICE_STATUS_VALUES,
|
INVOICE_STATUS_VALUES,
|
||||||
type InvoicePaymentStatus,
|
type InvoicePaymentStatus,
|
||||||
} from "@/lib/faturas";
|
} from "@/lib/faturas";
|
||||||
|
import { toDateOnlyString } from "@/lib/utils/date";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
type RawDashboardInvoice = {
|
type RawDashboardInvoice = {
|
||||||
invoiceId: string | null;
|
invoiceId: string | null;
|
||||||
@@ -24,6 +25,15 @@ type RawDashboardInvoice = {
|
|||||||
invoiceCreatedAt: Date | null;
|
invoiceCreatedAt: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RawInvoiceBreakdownRow = {
|
||||||
|
cardId: string | null;
|
||||||
|
period: string | null;
|
||||||
|
pagadorId: string | null;
|
||||||
|
pagadorName: string | null;
|
||||||
|
pagadorAvatar: string | null;
|
||||||
|
amount: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type InvoicePagadorBreakdown = {
|
export type InvoicePagadorBreakdown = {
|
||||||
pagadorId: string | null;
|
pagadorId: string | null;
|
||||||
pagadorName: string;
|
pagadorName: string;
|
||||||
@@ -51,22 +61,6 @@ export type DashboardInvoicesSnapshot = {
|
|||||||
totalPending: number;
|
totalPending: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toISODate = (value: Date | string | null | undefined) => {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return value.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
|
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
|
||||||
typeof value === "string" &&
|
typeof value === "string" &&
|
||||||
(INVOICE_STATUS_VALUES as string[]).includes(value);
|
(INVOICE_STATUS_VALUES as string[]).includes(value);
|
||||||
@@ -113,7 +107,7 @@ export async function fetchDashboardInvoices(
|
|||||||
!Number.isNaN(row.purchaseDate.valueOf())
|
!Number.isNaN(row.purchaseDate.valueOf())
|
||||||
? row.purchaseDate
|
? row.purchaseDate
|
||||||
: row.createdAt;
|
: row.createdAt;
|
||||||
const isoDate = toISODate(resolvedDate);
|
const isoDate = toDateOnlyString(resolvedDate);
|
||||||
if (!isoDate) {
|
if (!isoDate) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -123,7 +117,10 @@ export async function fetchDashboardInvoices(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows, breakdownRows] = await Promise.all([
|
const [rows, breakdownRows]: [
|
||||||
|
RawDashboardInvoice[],
|
||||||
|
RawInvoiceBreakdownRow[],
|
||||||
|
] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
invoiceId: faturas.id,
|
invoiceId: faturas.id,
|
||||||
@@ -216,9 +213,12 @@ export async function fetchDashboardInvoices(
|
|||||||
breakdownMap.set(key, current);
|
breakdownMap.set(key, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoices = rows
|
const invoices: DashboardInvoice[] = [];
|
||||||
.map((row: RawDashboardInvoice | null) => {
|
|
||||||
if (!row) return null;
|
for (const row of rows) {
|
||||||
|
if (!row) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const totalAmount = toNumber(row.totalAmount);
|
const totalAmount = toNumber(row.totalAmount);
|
||||||
const transactionCount = toNumber(row.transactionCount);
|
const transactionCount = toNumber(row.transactionCount);
|
||||||
@@ -232,17 +232,17 @@ export async function fetchDashboardInvoices(
|
|||||||
row.invoiceId !== null;
|
row.invoiceId !== null;
|
||||||
|
|
||||||
if (!shouldInclude) {
|
if (!shouldInclude) {
|
||||||
return null;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPeriod = row.period ?? period;
|
const resolvedPeriod = row.period ?? period;
|
||||||
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
|
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
|
||||||
const paidAt =
|
const paidAt =
|
||||||
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||||
? (paymentMap.get(paymentKey) ?? toISODate(row.invoiceCreatedAt))
|
? (paymentMap.get(paymentKey) ?? toDateOnlyString(row.invoiceCreatedAt))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
invoices.push({
|
||||||
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
|
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
|
||||||
cardId: row.cardId,
|
cardId: row.cardId,
|
||||||
cardName: row.cardName,
|
cardName: row.cardName,
|
||||||
@@ -257,10 +257,10 @@ export async function fetchDashboardInvoices(
|
|||||||
pagadorBreakdown: (
|
pagadorBreakdown: (
|
||||||
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
|
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
|
||||||
).sort((a, b) => b.amount - a.amount),
|
).sort((a, b) => b.amount - a.amount),
|
||||||
} satisfies DashboardInvoice;
|
});
|
||||||
})
|
}
|
||||||
.filter((invoice): invoice is DashboardInvoice => invoice !== null)
|
|
||||||
.sort((a, b) => {
|
invoices.sort((a, b) => {
|
||||||
// Ordena do maior valor para o menor
|
// Ordena do maior valor para o menor
|
||||||
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
|
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
|
||||||
});
|
});
|
||||||
|
|||||||
56
lib/dashboard/lancamento-filters.ts
Normal file
56
lib/dashboard/lancamento-filters.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { and, eq, ilike, isNull, ne, not, or } from "drizzle-orm";
|
||||||
|
import { contas, lancamentos } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
|
INITIAL_BALANCE_NOTE,
|
||||||
|
} from "@/lib/contas/constants";
|
||||||
|
|
||||||
|
type DashboardAdminFiltersParams = {
|
||||||
|
userId: string;
|
||||||
|
adminPagadorId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardAdminPeriodFiltersParams = DashboardAdminFiltersParams & {
|
||||||
|
period: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildDashboardAdminFilters = ({
|
||||||
|
userId,
|
||||||
|
adminPagadorId,
|
||||||
|
}: DashboardAdminFiltersParams) =>
|
||||||
|
[
|
||||||
|
eq(lancamentos.userId, userId),
|
||||||
|
eq(lancamentos.pagadorId, adminPagadorId),
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const buildDashboardAdminPeriodFilters = ({
|
||||||
|
userId,
|
||||||
|
period,
|
||||||
|
adminPagadorId,
|
||||||
|
}: DashboardAdminPeriodFiltersParams) =>
|
||||||
|
[
|
||||||
|
...buildDashboardAdminFilters({ userId, adminPagadorId }),
|
||||||
|
eq(lancamentos.period, period),
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const excludeAutoInvoiceEntries = () =>
|
||||||
|
or(
|
||||||
|
isNull(lancamentos.note),
|
||||||
|
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const excludeAutoGeneratedEntryNotes = () =>
|
||||||
|
or(
|
||||||
|
isNull(lancamentos.note),
|
||||||
|
and(
|
||||||
|
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||||
|
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const excludeInitialBalanceWhenConfigured = () =>
|
||||||
|
or(
|
||||||
|
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||||
|
isNull(contas.excludeInitialBalanceFromIncome),
|
||||||
|
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||||
|
);
|
||||||
15
lib/dashboard/notes-mappers.ts
Normal file
15
lib/dashboard/notes-mappers.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Note } from "@/components/anotacoes/types";
|
||||||
|
import type { DashboardNote } from "@/lib/dashboard/notes";
|
||||||
|
|
||||||
|
export const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
description: note.description,
|
||||||
|
type: note.type,
|
||||||
|
tasks: note.tasks,
|
||||||
|
arquivada: note.arquivada,
|
||||||
|
createdAt: note.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mapDashboardNotesToNotes = (notes: DashboardNote[]) =>
|
||||||
|
notes.map(mapDashboardNoteToNote);
|
||||||
@@ -11,6 +11,14 @@ import {
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
|
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import {
|
||||||
|
buildDateOnlyStringFromPeriodDay,
|
||||||
|
getBusinessDateString,
|
||||||
|
isDateOnlyPast,
|
||||||
|
isDateOnlyWithinDays,
|
||||||
|
toDateOnlyString,
|
||||||
|
} from "@/lib/utils/date";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
export type NotificationType = "overdue" | "due_soon";
|
export type NotificationType = "overdue" | "due_soon";
|
||||||
|
|
||||||
@@ -46,100 +54,6 @@ export type DashboardNotificationsSnapshot = {
|
|||||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||||
const BUDGET_CRITICAL_THRESHOLD = 80;
|
const BUDGET_CRITICAL_THRESHOLD = 80;
|
||||||
|
|
||||||
/**
|
|
||||||
* Calcula a data de vencimento de uma fatura baseado no período e dia de vencimento
|
|
||||||
* @param period Período no formato YYYY-MM
|
|
||||||
* @param dueDay Dia do vencimento (1-31)
|
|
||||||
* @returns Data de vencimento no formato YYYY-MM-DD
|
|
||||||
*/
|
|
||||||
function calculateDueDate(period: string, dueDay: string): string {
|
|
||||||
const [year, month] = period.split("-");
|
|
||||||
const yearNumber = Number(year);
|
|
||||||
const monthNumber = Number(month);
|
|
||||||
const hasValidMonth =
|
|
||||||
Number.isInteger(yearNumber) &&
|
|
||||||
Number.isInteger(monthNumber) &&
|
|
||||||
monthNumber >= 1 &&
|
|
||||||
monthNumber <= 12;
|
|
||||||
|
|
||||||
const daysInMonth = hasValidMonth
|
|
||||||
? new Date(yearNumber, monthNumber, 0).getDate()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const dueDayNumber = Number(dueDay);
|
|
||||||
const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0;
|
|
||||||
|
|
||||||
const clampedDay =
|
|
||||||
hasValidMonth && hasValidDueDay && daysInMonth
|
|
||||||
? Math.min(dueDayNumber, daysInMonth)
|
|
||||||
: hasValidDueDay
|
|
||||||
? dueDayNumber
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const day = clampedDay
|
|
||||||
? String(clampedDay).padStart(2, "0")
|
|
||||||
: dueDay.padStart(2, "0");
|
|
||||||
|
|
||||||
const normalizedMonth =
|
|
||||||
hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month;
|
|
||||||
|
|
||||||
return `${year}-${normalizedMonth}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normaliza uma data para o início do dia em UTC (00:00:00)
|
|
||||||
*/
|
|
||||||
function normalizeDate(date: Date): Date {
|
|
||||||
return new Date(
|
|
||||||
Date.UTC(
|
|
||||||
date.getUTCFullYear(),
|
|
||||||
date.getUTCMonth(),
|
|
||||||
date.getUTCDate(),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converte string "YYYY-MM-DD" para Date em UTC (evita problemas de timezone)
|
|
||||||
*/
|
|
||||||
function parseUTCDate(dateString: string): Date {
|
|
||||||
const [year, month, day] = dateString.split("-").map(Number);
|
|
||||||
return new Date(Date.UTC(year, month - 1, day));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifica se uma data está atrasada (antes do dia atual, não incluindo hoje)
|
|
||||||
*/
|
|
||||||
function isOverdue(dueDate: string, today: Date): boolean {
|
|
||||||
const due = parseUTCDate(dueDate);
|
|
||||||
const dueNormalized = normalizeDate(due);
|
|
||||||
return dueNormalized < today;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifica se uma data vence nos próximos X dias (incluindo hoje)
|
|
||||||
*/
|
|
||||||
function isDueWithinDays(
|
|
||||||
dueDate: string,
|
|
||||||
today: Date,
|
|
||||||
daysThreshold: number,
|
|
||||||
): boolean {
|
|
||||||
const due = parseUTCDate(dueDate);
|
|
||||||
const dueNormalized = normalizeDate(due);
|
|
||||||
const limitDate = new Date(today);
|
|
||||||
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
|
|
||||||
return dueNormalized >= today && dueNormalized <= limitDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toNum(value: unknown): number {
|
|
||||||
if (typeof value === "number") return value;
|
|
||||||
return Number(value) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Busca todas as notificações do dashboard:
|
* Busca todas as notificações do dashboard:
|
||||||
* - Faturas de cartão atrasadas ou com vencimento próximo
|
* - Faturas de cartão atrasadas ou com vencimento próximo
|
||||||
@@ -150,7 +64,7 @@ export async function fetchDashboardNotifications(
|
|||||||
userId: string,
|
userId: string,
|
||||||
currentPeriod: string,
|
currentPeriod: string,
|
||||||
): Promise<DashboardNotificationsSnapshot> {
|
): Promise<DashboardNotificationsSnapshot> {
|
||||||
const today = normalizeDate(new Date());
|
const today = getBusinessDateString();
|
||||||
const DAYS_THRESHOLD = 5;
|
const DAYS_THRESHOLD = 5;
|
||||||
|
|
||||||
const adminPagadorId = await getAdminPagadorId(userId);
|
const adminPagadorId = await getAdminPagadorId(userId);
|
||||||
@@ -285,8 +199,12 @@ export async function fetchDashboardNotifications(
|
|||||||
// Faturas atrasadas (períodos anteriores)
|
// Faturas atrasadas (períodos anteriores)
|
||||||
for (const invoice of overdueInvoices) {
|
for (const invoice of overdueInvoices) {
|
||||||
if (!invoice.period || !invoice.dueDay) continue;
|
if (!invoice.period || !invoice.dueDay) continue;
|
||||||
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
|
const dueDate = buildDateOnlyStringFromPeriodDay(
|
||||||
const amount = toNum(invoice.totalAmount);
|
invoice.period,
|
||||||
|
invoice.dueDay,
|
||||||
|
);
|
||||||
|
if (!dueDate) continue;
|
||||||
|
const amount = toNumber(invoice.totalAmount);
|
||||||
const notificationId = invoice.invoiceId
|
const notificationId = invoice.invoiceId
|
||||||
? `invoice-${invoice.invoiceId}`
|
? `invoice-${invoice.invoiceId}`
|
||||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
: `invoice-${invoice.cardId}-${invoice.period}`;
|
||||||
@@ -307,8 +225,13 @@ export async function fetchDashboardNotifications(
|
|||||||
// Faturas do período atual
|
// Faturas do período atual
|
||||||
for (const invoice of currentInvoices) {
|
for (const invoice of currentInvoices) {
|
||||||
if (!invoice.period || !invoice.dueDay) continue;
|
if (!invoice.period || !invoice.dueDay) continue;
|
||||||
const amount = toNum(invoice.totalAmount);
|
const dueDate = buildDateOnlyStringFromPeriodDay(
|
||||||
const transactionCount = toNum(invoice.transactionCount);
|
invoice.period,
|
||||||
|
invoice.dueDay,
|
||||||
|
);
|
||||||
|
if (!dueDate) continue;
|
||||||
|
const amount = toNumber(invoice.totalAmount);
|
||||||
|
const transactionCount = toNumber(invoice.transactionCount);
|
||||||
const paymentStatus =
|
const paymentStatus =
|
||||||
invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
|
invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
|
||||||
|
|
||||||
@@ -319,9 +242,12 @@ export async function fetchDashboardNotifications(
|
|||||||
if (!shouldInclude) continue;
|
if (!shouldInclude) continue;
|
||||||
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
|
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
|
||||||
|
|
||||||
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
|
const invoiceIsOverdue = isDateOnlyPast(dueDate, today);
|
||||||
const invoiceIsOverdue = isOverdue(dueDate, today);
|
const invoiceIsDueSoon = isDateOnlyWithinDays(
|
||||||
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
|
dueDate,
|
||||||
|
DAYS_THRESHOLD,
|
||||||
|
today,
|
||||||
|
);
|
||||||
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
||||||
|
|
||||||
const notificationId = invoice.invoiceId
|
const notificationId = invoice.invoiceId
|
||||||
@@ -343,17 +269,18 @@ export async function fetchDashboardNotifications(
|
|||||||
|
|
||||||
// Boletos
|
// Boletos
|
||||||
for (const boleto of boletosRows) {
|
for (const boleto of boletosRows) {
|
||||||
if (!boleto.dueDate) continue;
|
const dueDate = toDateOnlyString(boleto.dueDate);
|
||||||
const dueDate =
|
if (!dueDate) continue;
|
||||||
boleto.dueDate instanceof Date
|
|
||||||
? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}`
|
|
||||||
: boleto.dueDate;
|
|
||||||
|
|
||||||
const boletoIsOverdue = isOverdue(dueDate, today);
|
const boletoIsOverdue = isDateOnlyPast(dueDate, today);
|
||||||
const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
|
const boletoIsDueSoon = isDateOnlyWithinDays(
|
||||||
|
dueDate,
|
||||||
|
DAYS_THRESHOLD,
|
||||||
|
today,
|
||||||
|
);
|
||||||
const isOldPeriod = boleto.period < currentPeriod;
|
const isOldPeriod = boleto.period < currentPeriod;
|
||||||
const isCurrentPeriod = boleto.period === currentPeriod;
|
const isCurrentPeriod = boleto.period === currentPeriod;
|
||||||
const amount = toNum(boleto.amount);
|
const amount = toNumber(boleto.amount);
|
||||||
|
|
||||||
if (isOldPeriod) {
|
if (isOldPeriod) {
|
||||||
notifications.push({
|
notifications.push({
|
||||||
@@ -391,8 +318,8 @@ export async function fetchDashboardNotifications(
|
|||||||
const budgetNotifications: BudgetNotification[] = [];
|
const budgetNotifications: BudgetNotification[] = [];
|
||||||
|
|
||||||
for (const row of budgetRows) {
|
for (const row of budgetRows) {
|
||||||
const budgetAmount = toNum(row.budgetAmount);
|
const budgetAmount = toNumber(row.budgetAmount);
|
||||||
const spentAmount = toNum(row.spentAmount);
|
const spentAmount = toNumber(row.spentAmount);
|
||||||
if (budgetAmount <= 0) continue;
|
if (budgetAmount <= 0) continue;
|
||||||
|
|
||||||
const usedPercentage = (spentAmount / budgetAmount) * 100;
|
const usedPercentage = (spentAmount / budgetAmount) * 100;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
import { lancamentos, pagadores } from "@/db/schema";
|
import { lancamentos, pagadores } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
import { calculatePercentageChange } from "@/lib/utils/math";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||||
|
|
||||||
export type DashboardPagador = {
|
export type DashboardPagador = {
|
||||||
|
|||||||
10
lib/dashboard/payment-breakdown-formatters.ts
Normal file
10
lib/dashboard/payment-breakdown-formatters.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { formatPercentage } from "@/lib/utils/percentage";
|
||||||
|
|
||||||
|
export const formatPaymentBreakdownPercentage = (value: number) =>
|
||||||
|
formatPercentage(value, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatPaymentBreakdownTransactionsLabel = (transactions: number) =>
|
||||||
|
`${transactions} ${transactions === 1 ? "lançamento" : "lançamentos"}`;
|
||||||
11
lib/dashboard/payment-overview-tabs.ts
Normal file
11
lib/dashboard/payment-overview-tabs.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type PaymentOverviewTab = "conditions" | "methods";
|
||||||
|
|
||||||
|
export const DEFAULT_PAYMENT_OVERVIEW_TAB: PaymentOverviewTab = "conditions";
|
||||||
|
|
||||||
|
export const parsePaymentOverviewTab = (value: string): PaymentOverviewTab => {
|
||||||
|
if (value === "methods") {
|
||||||
|
return "methods";
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_PAYMENT_OVERVIEW_TAB;
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { lancamentos } from "@/db/schema";
|
import { lancamentos } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
buildDashboardAdminPeriodFilters,
|
||||||
INITIAL_BALANCE_NOTE,
|
excludeAutoGeneratedEntryNotes,
|
||||||
} from "@/lib/contas/constants";
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
export type PaymentConditionSummary = {
|
export type PaymentConditionSummary = {
|
||||||
condition: string;
|
condition: string;
|
||||||
@@ -37,22 +37,18 @@ export async function fetchPaymentConditions(
|
|||||||
.from(lancamentos)
|
.from(lancamentos)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminPeriodFilters({
|
||||||
eq(lancamentos.period, period),
|
userId,
|
||||||
|
period,
|
||||||
|
adminPagadorId,
|
||||||
|
}),
|
||||||
eq(lancamentos.transactionType, "Despesa"),
|
eq(lancamentos.transactionType, "Despesa"),
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
excludeAutoGeneratedEntryNotes(),
|
||||||
or(
|
|
||||||
isNull(lancamentos.note),
|
|
||||||
and(
|
|
||||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
|
||||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(lancamentos.condition);
|
.groupBy(lancamentos.condition);
|
||||||
|
|
||||||
const summaries = rows.map((row) => {
|
const summaries = rows.map((row: (typeof rows)[number]) => {
|
||||||
const totalAmount = Math.abs(toNumber(row.totalAmount));
|
const totalAmount = Math.abs(toNumber(row.totalAmount));
|
||||||
const transactions = Number(row.transactions ?? 0);
|
const transactions = Number(row.transactions ?? 0);
|
||||||
|
|
||||||
@@ -63,10 +59,13 @@ export async function fetchPaymentConditions(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
|
const overallTotal = summaries.reduce(
|
||||||
|
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
const conditions = summaries
|
const conditions = summaries
|
||||||
.map((item) => ({
|
.map((item: (typeof summaries)[number]) => ({
|
||||||
condition: item.condition,
|
condition: item.condition,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
transactions: item.transactions,
|
transactions: item.transactions,
|
||||||
@@ -75,7 +74,10 @@ export async function fetchPaymentConditions(
|
|||||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||||
: 0,
|
: 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.amount - a.amount);
|
.sort(
|
||||||
|
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
|
||||||
|
b.amount - a.amount,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
conditions,
|
conditions,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { lancamentos } from "@/db/schema";
|
import { lancamentos } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
buildDashboardAdminPeriodFilters,
|
||||||
INITIAL_BALANCE_NOTE,
|
excludeAutoGeneratedEntryNotes,
|
||||||
} from "@/lib/contas/constants";
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
export type PaymentMethodSummary = {
|
export type PaymentMethodSummary = {
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
@@ -37,22 +37,18 @@ export async function fetchPaymentMethods(
|
|||||||
.from(lancamentos)
|
.from(lancamentos)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminPeriodFilters({
|
||||||
eq(lancamentos.period, period),
|
userId,
|
||||||
|
period,
|
||||||
|
adminPagadorId,
|
||||||
|
}),
|
||||||
eq(lancamentos.transactionType, "Despesa"),
|
eq(lancamentos.transactionType, "Despesa"),
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
excludeAutoGeneratedEntryNotes(),
|
||||||
or(
|
|
||||||
isNull(lancamentos.note),
|
|
||||||
and(
|
|
||||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
|
||||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(lancamentos.paymentMethod);
|
.groupBy(lancamentos.paymentMethod);
|
||||||
|
|
||||||
const summaries = rows.map((row) => {
|
const summaries = rows.map((row: (typeof rows)[number]) => {
|
||||||
const amount = Math.abs(toNumber(row.totalAmount));
|
const amount = Math.abs(toNumber(row.totalAmount));
|
||||||
const transactions = Number(row.transactions ?? 0);
|
const transactions = Number(row.transactions ?? 0);
|
||||||
|
|
||||||
@@ -63,10 +59,13 @@ export async function fetchPaymentMethods(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
|
const overallTotal = summaries.reduce(
|
||||||
|
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
const methods = summaries
|
const methods = summaries
|
||||||
.map((item) => ({
|
.map((item: (typeof summaries)[number]) => ({
|
||||||
paymentMethod: item.paymentMethod,
|
paymentMethod: item.paymentMethod,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
transactions: item.transactions,
|
transactions: item.transactions,
|
||||||
@@ -75,7 +74,10 @@ export async function fetchPaymentMethods(
|
|||||||
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
? Number(((item.amount / overallTotal) * 100).toFixed(2))
|
||||||
: 0,
|
: 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.amount - a.amount);
|
.sort(
|
||||||
|
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
|
||||||
|
b.amount - a.amount,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
methods,
|
methods,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, inArray, sql } from "drizzle-orm";
|
||||||
import { lancamentos } from "@/db/schema";
|
import { lancamentos } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
import {
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
buildDashboardAdminPeriodFilters,
|
||||||
|
excludeAutoInvoiceEntries,
|
||||||
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
export type PaymentStatusCategory = {
|
export type PaymentStatusCategory = {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -51,11 +54,13 @@ export async function fetchPaymentStatus(
|
|||||||
.from(lancamentos)
|
.from(lancamentos)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminPeriodFilters({
|
||||||
eq(lancamentos.period, period),
|
userId,
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
period,
|
||||||
|
adminPagadorId,
|
||||||
|
}),
|
||||||
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
|
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
|
||||||
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
|
excludeAutoInvoiceEntries(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(lancamentos.transactionType);
|
.groupBy(lancamentos.transactionType);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
import { cartoes, categorias, contas, lancamentos } from "@/db/schema";
|
import { cartoes, categorias, contas, lancamentos } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
buildDashboardAdminPeriodFilters,
|
||||||
INITIAL_BALANCE_NOTE,
|
excludeAutoGeneratedEntryNotes,
|
||||||
} from "@/lib/contas/constants";
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
export type CategoryOption = {
|
export type CategoryOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,19 +68,13 @@ export async function fetchPurchasesByCategory(
|
|||||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminPeriodFilters({
|
||||||
eq(lancamentos.period, period),
|
userId,
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
period,
|
||||||
|
adminPagadorId,
|
||||||
|
}),
|
||||||
inArray(categorias.type, ["despesa", "receita"]),
|
inArray(categorias.type, ["despesa", "receita"]),
|
||||||
or(
|
excludeAutoGeneratedEntryNotes(),
|
||||||
isNull(lancamentos.note),
|
|
||||||
and(
|
|
||||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
|
||||||
sql`${
|
|
||||||
lancamentos.note
|
|
||||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(lancamentos.purchaseDate));
|
.orderBy(desc(lancamentos.purchaseDate));
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { cartoes, contas, lancamentos } from "@/db/schema";
|
import { cartoes, contas, lancamentos } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
buildDashboardAdminPeriodFilters,
|
||||||
INITIAL_BALANCE_NOTE,
|
excludeAutoGeneratedEntryNotes,
|
||||||
} from "@/lib/contas/constants";
|
} from "@/lib/dashboard/lancamento-filters";
|
||||||
import { toNumber } from "@/lib/dashboard/common";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
|
||||||
export type TopEstablishment = {
|
export type TopEstablishment = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,17 +55,13 @@ export async function fetchTopEstablishments(
|
|||||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(lancamentos.userId, userId),
|
...buildDashboardAdminPeriodFilters({
|
||||||
eq(lancamentos.period, period),
|
userId,
|
||||||
|
period,
|
||||||
|
adminPagadorId,
|
||||||
|
}),
|
||||||
eq(lancamentos.transactionType, "Despesa"),
|
eq(lancamentos.transactionType, "Despesa"),
|
||||||
eq(lancamentos.pagadorId, adminPagadorId),
|
excludeAutoGeneratedEntryNotes(),
|
||||||
or(
|
|
||||||
isNull(lancamentos.note),
|
|
||||||
and(
|
|
||||||
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
|
|
||||||
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(lancamentos.name)
|
.groupBy(lancamentos.name)
|
||||||
@@ -76,9 +72,11 @@ export async function fetchTopEstablishments(
|
|||||||
.limit(10);
|
.limit(10);
|
||||||
|
|
||||||
const establishments = rows
|
const establishments = rows
|
||||||
.filter((row) => shouldIncludeEstablishment(row.name))
|
.filter((row: (typeof rows)[number]) =>
|
||||||
|
shouldIncludeEstablishment(row.name),
|
||||||
|
)
|
||||||
.map(
|
.map(
|
||||||
(row): TopEstablishment => ({
|
(row: (typeof rows)[number]): TopEstablishment => ({
|
||||||
id: row.name,
|
id: row.name,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
amount: Math.abs(toNumber(row.totalAmount)),
|
amount: Math.abs(toNumber(row.totalAmount)),
|
||||||
|
|||||||
46
lib/dashboard/use-bill-widget-controller.ts
Normal file
46
lib/dashboard/use-bill-widget-controller.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
|
import type { DashboardBill } from "@/lib/dashboard/bills";
|
||||||
|
import {
|
||||||
|
type BillDialogState,
|
||||||
|
getCurrentBillDateString,
|
||||||
|
markBillAsSettled,
|
||||||
|
} from "@/lib/dashboard/bills-helpers";
|
||||||
|
import {
|
||||||
|
type PaymentDialogController,
|
||||||
|
usePaymentDialogController,
|
||||||
|
} from "@/lib/dashboard/use-payment-dialog-controller";
|
||||||
|
|
||||||
|
const EMPTY_BILLS: DashboardBill[] = [];
|
||||||
|
|
||||||
|
export type BillWidgetController = Omit<
|
||||||
|
PaymentDialogController<DashboardBill>,
|
||||||
|
"selectedItem"
|
||||||
|
> & {
|
||||||
|
selectedBill: DashboardBill | null;
|
||||||
|
modalState: BillDialogState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useBillWidgetController(
|
||||||
|
bills?: DashboardBill[],
|
||||||
|
): BillWidgetController {
|
||||||
|
const safeBills = bills ?? EMPTY_BILLS;
|
||||||
|
const controller = usePaymentDialogController({
|
||||||
|
items: safeBills,
|
||||||
|
getItemId: (bill) => bill.id,
|
||||||
|
isItemConfirmed: (bill) => bill.isSettled,
|
||||||
|
executeConfirm: (bill) =>
|
||||||
|
toggleLancamentoSettlementAction({
|
||||||
|
id: bill.id,
|
||||||
|
value: true,
|
||||||
|
}),
|
||||||
|
applyConfirmedState: (bill) =>
|
||||||
|
markBillAsSettled(bill, getCurrentBillDateString()),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...controller,
|
||||||
|
selectedBill: controller.selectedItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user