feat(dashboard): add quick actions and new overview widgets
This commit is contained in:
@@ -4,7 +4,13 @@ import { SectionCards } from "@/components/dashboard/section-cards";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
||||
import {
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
fetchLancamentoFilterSources,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { getRecentEstablishmentsAction } from "../lancamentos/actions";
|
||||
import { fetchUserDashboardPreferences } from "./data";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
@@ -28,12 +34,26 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||
|
||||
const [data, preferences] = await Promise.all([
|
||||
fetchDashboardData(user.id, selectedPeriod),
|
||||
fetchUserDashboardPreferences(user.id),
|
||||
]);
|
||||
|
||||
const [dashboardData, preferences, filterSources, estabelecimentos] =
|
||||
await Promise.all([
|
||||
fetchDashboardData(user.id, selectedPeriod),
|
||||
fetchUserDashboardPreferences(user.id),
|
||||
fetchLancamentoFilterSources(user.id),
|
||||
getRecentEstablishmentsAction(),
|
||||
]);
|
||||
const { disableMagnetlines, dashboardWidgets } = preferences;
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const {
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
} = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
pagadorRows: filterSources.pagadorRows,
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4">
|
||||
@@ -42,11 +62,20 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
disableMagnetlines={disableMagnetlines}
|
||||
/>
|
||||
<MonthNavigation />
|
||||
<SectionCards metrics={data.metrics} />
|
||||
<SectionCards metrics={dashboardData.metrics} />
|
||||
<DashboardGridEditable
|
||||
data={data}
|
||||
data={dashboardData}
|
||||
period={selectedPeriod}
|
||||
initialPreferences={dashboardWidgets}
|
||||
quickActionOptions={{
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
estabelecimentos,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
23
app/(dashboard)/relatorios/estabelecimentos/layout.tsx
Normal file
23
app/(dashboard)/relatorios/estabelecimentos/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { RiStore2Line } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Top Estabelecimentos | OpenMonetis",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<PageDescription
|
||||
icon={<RiStore2Line />}
|
||||
title="Top Estabelecimentos"
|
||||
subtitle="Análise dos locais onde você mais compra e gasta"
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
58
app/(dashboard)/relatorios/estabelecimentos/loading.tsx
Normal file
58
app/(dashboard)/relatorios/estabelecimentos/loading.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 px-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
76
app/(dashboard)/relatorios/estabelecimentos/page.tsx
Normal file
76
app/(dashboard)/relatorios/estabelecimentos/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { EstablishmentsList } from "@/components/top-estabelecimentos/establishments-list";
|
||||
import { HighlightsCards } from "@/components/top-estabelecimentos/highlights-cards";
|
||||
import { PeriodFilterButtons } from "@/components/top-estabelecimentos/period-filter";
|
||||
import { SummaryCards } from "@/components/top-estabelecimentos/summary-cards";
|
||||
import { TopCategories } from "@/components/top-estabelecimentos/top-categories";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import {
|
||||
fetchTopEstabelecimentosData,
|
||||
type PeriodFilter,
|
||||
} from "@/lib/top-estabelecimentos/fetch-data";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string,
|
||||
) => {
|
||||
const value = params?.[key];
|
||||
if (!value) return null;
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
const validatePeriodFilter = (value: string | null): PeriodFilter => {
|
||||
if (value === "3" || value === "6" || value === "12") {
|
||||
return value;
|
||||
}
|
||||
return "6";
|
||||
};
|
||||
|
||||
export default async function TopEstabelecimentosPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const mesesParam = getSingleParam(resolvedSearchParams, "meses");
|
||||
|
||||
const { period: currentPeriod } = parsePeriodParam(periodoParam);
|
||||
const periodFilter = validatePeriodFilter(mesesParam);
|
||||
|
||||
const data = await fetchTopEstabelecimentosData(
|
||||
user.id,
|
||||
currentPeriod,
|
||||
periodFilter,
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4">
|
||||
<Card className="flex-row items-center justify-between p-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Selecione o intervalo de meses
|
||||
</span>
|
||||
<PeriodFilterButtons currentFilter={periodFilter} />
|
||||
</Card>
|
||||
|
||||
<SummaryCards summary={data.summary} />
|
||||
|
||||
<HighlightsCards summary={data.summary} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<EstablishmentsList establishments={data.establishments} />
|
||||
</div>
|
||||
<div>
|
||||
<TopCategories categories={data.topCategories} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
closestCorners,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
@@ -16,15 +16,21 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowUpLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiDragMove2Line,
|
||||
RiEyeOffLine,
|
||||
RiTodoLine,
|
||||
} from "@remixicon/react";
|
||||
import { useCallback, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { NoteDialog } from "@/components/anotacoes/note-dialog";
|
||||
import { SortableWidget } from "@/components/dashboard/sortable-widget";
|
||||
import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog";
|
||||
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
||||
import type { SelectOption } from "@/components/lancamentos/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import WidgetCard from "@/components/widget-card";
|
||||
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
||||
@@ -42,12 +48,22 @@ type DashboardGridEditableProps = {
|
||||
data: DashboardData;
|
||||
period: string;
|
||||
initialPreferences: WidgetPreferences | null;
|
||||
quickActionOptions: {
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId: string | null;
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export function DashboardGridEditable({
|
||||
data,
|
||||
period,
|
||||
initialPreferences,
|
||||
quickActionOptions,
|
||||
}: DashboardGridEditableProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -183,53 +199,112 @@ export function DashboardGridEditable({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelEditing}
|
||||
disabled={isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiCheckLine className="size-4" />
|
||||
Salvar
|
||||
</Button>
|
||||
</>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{!isEditing ? (
|
||||
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Ação rápida
|
||||
</span>
|
||||
<div className="-mb-1 flex items-center gap-2 overflow-x-auto pb-1 sm:mb-0 sm:overflow-visible sm:pb-0">
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
pagadorOptions={quickActionOptions.pagadorOptions}
|
||||
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
|
||||
defaultPagadorId={quickActionOptions.defaultPagadorId}
|
||||
contaOptions={quickActionOptions.contaOptions}
|
||||
cartaoOptions={quickActionOptions.cartaoOptions}
|
||||
categoriaOptions={quickActionOptions.categoriaOptions}
|
||||
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||
defaultPeriod={period}
|
||||
defaultTransactionType="Receita"
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<RiArrowUpLine className="size-4 text-success/80" />
|
||||
Nova receita
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
pagadorOptions={quickActionOptions.pagadorOptions}
|
||||
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
|
||||
defaultPagadorId={quickActionOptions.defaultPagadorId}
|
||||
contaOptions={quickActionOptions.contaOptions}
|
||||
cartaoOptions={quickActionOptions.cartaoOptions}
|
||||
categoriaOptions={quickActionOptions.categoriaOptions}
|
||||
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||
defaultPeriod={period}
|
||||
defaultTransactionType="Despesa"
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<RiArrowDownLine className="size-4 text-destructive/80" />
|
||||
Nova despesa
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<NoteDialog
|
||||
mode="create"
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<RiTodoLine className="size-4 text-info/80" />
|
||||
Nova anotação
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<WidgetSettingsDialog
|
||||
hiddenWidgets={hiddenWidgets}
|
||||
onToggleWidget={handleToggleWidget}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStartEditing}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiDragMove2Line className="size-4" />
|
||||
Reordenar
|
||||
</Button>
|
||||
</>
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelEditing}
|
||||
disabled={isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiCheckLine className="size-4" />
|
||||
Salvar
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WidgetSettingsDialog
|
||||
hiddenWidgets={hiddenWidgets}
|
||||
onToggleWidget={handleToggleWidget}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStartEditing}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiDragMove2Line className="size-4" />
|
||||
Reordenar
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
collisionDetection={closestCorners}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
|
||||
146
components/dashboard/goals-progress-widget.tsx
Normal file
146
components/dashboard/goals-progress-widget.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"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 { WidgetEmptyState } from "../widget-empty-state";
|
||||
|
||||
type GoalsProgressWidgetProps = {
|
||||
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) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
||||
|
||||
const categories = useMemo<BudgetCategory[]>(
|
||||
() =>
|
||||
data.categories.map((category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
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 (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col">
|
||||
{data.items.map((item, index) => {
|
||||
const statusColor =
|
||||
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}
|
||||
defaultPeriod={defaultPeriod}
|
||||
open={editOpen && !!selectedBudget}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
components/dashboard/notes-widget.tsx
Normal file
157
components/dashboard/notes-widget.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { RiEyeLine, 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 { Badge } from "../ui/badge";
|
||||
import { WidgetEmptyState } from "../widget-empty-state";
|
||||
|
||||
type NotesWidgetProps = {
|
||||
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) {
|
||||
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
|
||||
const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]);
|
||||
|
||||
const handleOpenEdit = useCallback((note: Note) => {
|
||||
setNoteToEdit(note);
|
||||
setIsEditOpen(true);
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
{mappedNotes.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma anotação ativa"
|
||||
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
|
||||
/>
|
||||
) : (
|
||||
<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="secondary"
|
||||
className="h-5 px-1.5 text-[10px]"
|
||||
>
|
||||
{getTasksSummary(note)}
|
||||
</Badge>
|
||||
<p className="truncate text-xs 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,
|
||||
)}`}
|
||||
>
|
||||
<RiEyeLine 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiExternalLinkLine,
|
||||
RiGroupLine,
|
||||
RiVerifiedBadgeFill,
|
||||
@@ -17,6 +19,10 @@ type PagadoresWidgetProps = {
|
||||
pagadores: DashboardPagador[];
|
||||
};
|
||||
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${Math.abs(value).toFixed(0)}%`;
|
||||
};
|
||||
|
||||
const buildInitials = (value: string) => {
|
||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
@@ -44,6 +50,12 @@ export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
|
||||
<ul className="flex flex-col">
|
||||
{pagadores.map((pagador) => {
|
||||
const initials = buildInitials(pagador.name);
|
||||
const hasValidPercentageChange =
|
||||
typeof pagador.percentageChange === "number" &&
|
||||
Number.isFinite(pagador.percentageChange);
|
||||
const percentageChange = hasValidPercentageChange
|
||||
? pagador.percentageChange
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -87,6 +99,25 @@ export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={pagador.totalExpenses} />
|
||||
{percentageChange !== null && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
percentageChange > 0
|
||||
? "text-destructive"
|
||||
: percentageChange < 0
|
||||
? "text-success"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{percentageChange > 0 && (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
)}
|
||||
{percentageChange < 0 && (
|
||||
<RiArrowDownSFill className="size-3" />
|
||||
)}
|
||||
{formatPercentage(percentageChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
50
components/dashboard/payment-overview-widget.tsx
Normal file
50
components/dashboard/payment-overview-widget.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
|
||||
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { PaymentConditionsWidget } from "./payment-conditions-widget";
|
||||
import { PaymentMethodsWidget } from "./payment-methods-widget";
|
||||
|
||||
type PaymentOverviewWidgetProps = {
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidget({
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
}: PaymentOverviewWidgetProps) {
|
||||
const [activeTab, setActiveTab] = useState<"conditions" | "methods">(
|
||||
"conditions",
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as "conditions" | "methods")}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,8 @@ export function SortableWidget({
|
||||
className={cn(
|
||||
"relative",
|
||||
isDragging && "z-50 opacity-90",
|
||||
isEditing && "cursor-grab active:cursor-grabbing",
|
||||
isEditing &&
|
||||
"cursor-grab active:cursor-grabbing touch-none select-none",
|
||||
)}
|
||||
{...(isEditing ? { ...attributes, ...listeners } : {})}
|
||||
>
|
||||
|
||||
57
components/dashboard/spending-overview-widget.tsx
Normal file
57
components/dashboard/spending-overview-widget.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowUpDoubleLine, RiStore2Line } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import type { TopExpensesData } from "@/lib/dashboard/expenses/top-expenses";
|
||||
import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { TopEstablishmentsWidget } from "./top-establishments-widget";
|
||||
import { TopExpensesWidget } from "./top-expenses-widget";
|
||||
|
||||
type SpendingOverviewWidgetProps = {
|
||||
topExpensesAll: TopExpensesData;
|
||||
topExpensesCardOnly: TopExpensesData;
|
||||
topEstablishmentsData: TopEstablishmentsData;
|
||||
};
|
||||
|
||||
export function SpendingOverviewWidget({
|
||||
topExpensesAll,
|
||||
topExpensesCardOnly,
|
||||
topEstablishmentsData,
|
||||
}: SpendingOverviewWidgetProps) {
|
||||
const [activeTab, setActiveTab] = useState<"expenses" | "establishments">(
|
||||
"expenses",
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(value as "expenses" | "establishments")
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="expenses" className="text-xs">
|
||||
<RiArrowUpDoubleLine className="mr-1 size-3.5" />
|
||||
Top gastos
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="establishments" className="text-xs">
|
||||
<RiStore2Line className="mr-1 size-3.5" />
|
||||
Estabelecimentos
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="expenses" className="mt-2">
|
||||
<TopExpensesWidget
|
||||
allExpenses={topExpensesAll}
|
||||
cardOnlyExpenses={topExpensesCardOnly}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="establishments" className="mt-2">
|
||||
<TopEstablishmentsWidget data={topEstablishmentsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RiGroupLine,
|
||||
RiPriceTag3Line,
|
||||
RiSparklingLine,
|
||||
RiStore2Line,
|
||||
RiTodoLine,
|
||||
} from "@remixicon/react";
|
||||
|
||||
@@ -110,6 +111,11 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
icon: <RiBankCard2Line className="size-4" />,
|
||||
preservePeriod: true,
|
||||
},
|
||||
{
|
||||
href: "/relatorios/estabelecimentos",
|
||||
label: "estabelecimentos",
|
||||
icon: <RiStore2Line className="size-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { CategoryIcon } from "@/components/categorias/category-icon";
|
||||
import { PeriodPicker } from "@/components/period-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CurrencyInput } from "@/components/ui/currency-input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/hooks/use-form-state";
|
||||
|
||||
@@ -54,6 +54,12 @@ const buildInitialValues = ({
|
||||
amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "",
|
||||
});
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(value);
|
||||
|
||||
export function BudgetDialog({
|
||||
mode,
|
||||
trigger,
|
||||
@@ -164,6 +170,15 @@ export function BudgetDialog({
|
||||
const submitLabel =
|
||||
mode === "create" ? "Salvar orçamento" : "Atualizar orçamento";
|
||||
const disabled = categories.length === 0;
|
||||
const parsedAmount = Number.parseFloat(formState.amount);
|
||||
const sliderValue = Number.isFinite(parsedAmount)
|
||||
? Math.max(0, parsedAmount)
|
||||
: 0;
|
||||
const baseForSlider = Math.max(budget?.spent ?? 0, sliderValue, 1000);
|
||||
const sliderMax = Math.max(
|
||||
1000,
|
||||
Math.ceil((baseForSlider * 1.5) / 100) * 100,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
@@ -215,7 +230,7 @@ export function BudgetDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-period">Período</Label>
|
||||
<PeriodPicker
|
||||
@@ -227,12 +242,30 @@ export function BudgetDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-amount">Valor limite</Label>
|
||||
<CurrencyInput
|
||||
id="budget-amount"
|
||||
placeholder="R$ 0,00"
|
||||
value={formState.amount}
|
||||
onValueChange={(value) => updateField("amount", value)}
|
||||
/>
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Limite atual</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatCurrency(sliderValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
id="budget-amount"
|
||||
value={[sliderValue]}
|
||||
min={0}
|
||||
max={sliderMax}
|
||||
step={10}
|
||||
onValueChange={(value) =>
|
||||
updateField("amount", value[0]?.toFixed(2) ?? "0.00")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{formatCurrency(0)}</span>
|
||||
<span>{formatCurrency(sliderMax)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function PeriodFilterButtons({ currentFilter }: PeriodFilterProps) {
|
||||
const handleFilterChange = (filter: PeriodFilter) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("meses", filter);
|
||||
router.push(`/top-estabelecimentos?${params.toString()}`);
|
||||
router.push(`/relatorios/estabelecimentos?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
37
components/ui/slider.tsx
Normal file
37
components/ui/slider.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="bg-muted relative h-2 w-full grow overflow-hidden rounded-full"
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className="bg-primary absolute h-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-colors focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
@@ -27,7 +27,7 @@ export const revalidateConfig = {
|
||||
estabelecimentos: ["/estabelecimentos", "/lancamentos"],
|
||||
orcamentos: ["/orcamentos"],
|
||||
pagadores: ["/pagadores"],
|
||||
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
|
||||
anotacoes: ["/anotacoes", "/anotacoes/arquivadas", "/dashboard"],
|
||||
lancamentos: ["/lancamentos", "/contas"],
|
||||
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
|
||||
} as const;
|
||||
@@ -39,6 +39,7 @@ const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
|
||||
"cartoes",
|
||||
"orcamentos",
|
||||
"pagadores",
|
||||
"anotacoes",
|
||||
"inbox",
|
||||
]);
|
||||
|
||||
|
||||
147
lib/dashboard/goals-progress.ts
Normal file
147
lib/dashboard/goals-progress.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { categorias, lancamentos, orcamentos } from "@/db/schema";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
|
||||
|
||||
const BUDGET_CRITICAL_THRESHOLD = 80;
|
||||
|
||||
export type GoalProgressStatus = "on-track" | "critical" | "exceeded";
|
||||
|
||||
export type GoalProgressItem = {
|
||||
id: string;
|
||||
categoryId: string | null;
|
||||
categoryName: string;
|
||||
categoryIcon: string | null;
|
||||
period: string;
|
||||
createdAt: string;
|
||||
budgetAmount: number;
|
||||
spentAmount: number;
|
||||
usedPercentage: number;
|
||||
status: GoalProgressStatus;
|
||||
};
|
||||
|
||||
export type GoalProgressCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export type GoalsProgressData = {
|
||||
items: GoalProgressItem[];
|
||||
categories: GoalProgressCategory[];
|
||||
totalBudgets: number;
|
||||
exceededCount: number;
|
||||
criticalCount: number;
|
||||
};
|
||||
|
||||
const resolveStatus = (usedPercentage: number): GoalProgressStatus => {
|
||||
if (usedPercentage >= 100) {
|
||||
return "exceeded";
|
||||
}
|
||||
if (usedPercentage >= BUDGET_CRITICAL_THRESHOLD) {
|
||||
return "critical";
|
||||
}
|
||||
return "on-track";
|
||||
};
|
||||
|
||||
export async function fetchGoalsProgressData(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<GoalsProgressData> {
|
||||
const adminPagadorId = await getAdminPagadorId(userId);
|
||||
|
||||
if (!adminPagadorId) {
|
||||
return {
|
||||
items: [],
|
||||
categories: [],
|
||||
totalBudgets: 0,
|
||||
exceededCount: 0,
|
||||
criticalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [rows, categoryRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
orcamentoId: orcamentos.id,
|
||||
categoryId: categorias.id,
|
||||
categoryName: categorias.name,
|
||||
categoryIcon: categorias.icon,
|
||||
period: orcamentos.period,
|
||||
createdAt: orcamentos.createdAt,
|
||||
budgetAmount: orcamentos.amount,
|
||||
spentAmount: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
|
||||
})
|
||||
.from(orcamentos)
|
||||
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.categoriaId, orcamentos.categoriaId),
|
||||
eq(lancamentos.userId, orcamentos.userId),
|
||||
eq(lancamentos.period, orcamentos.period),
|
||||
eq(lancamentos.pagadorId, adminPagadorId),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
ne(lancamentos.condition, "cancelado"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
|
||||
.groupBy(
|
||||
orcamentos.id,
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
orcamentos.period,
|
||||
orcamentos.createdAt,
|
||||
orcamentos.amount,
|
||||
),
|
||||
db.query.categorias.findMany({
|
||||
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
|
||||
orderBy: (category, { asc }) => [asc(category.name)],
|
||||
}),
|
||||
]);
|
||||
|
||||
const categories: GoalProgressCategory[] = categoryRows.map((category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
}));
|
||||
|
||||
const items: GoalProgressItem[] = rows
|
||||
.map((row) => {
|
||||
const budgetAmount = toNumber(row.budgetAmount);
|
||||
const spentAmount = toNumber(row.spentAmount);
|
||||
const usedPercentage =
|
||||
budgetAmount > 0 ? (spentAmount / budgetAmount) * 100 : 0;
|
||||
|
||||
return {
|
||||
id: row.orcamentoId,
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
period: row.period,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
budgetAmount,
|
||||
spentAmount,
|
||||
usedPercentage,
|
||||
status: resolveStatus(usedPercentage),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.usedPercentage - a.usedPercentage);
|
||||
|
||||
const exceededCount = items.filter(
|
||||
(item) => item.status === "exceeded",
|
||||
).length;
|
||||
const criticalCount = items.filter(
|
||||
(item) => item.status === "critical",
|
||||
).length;
|
||||
|
||||
return {
|
||||
items,
|
||||
categories,
|
||||
totalBudgets: items.length,
|
||||
exceededCount,
|
||||
criticalCount,
|
||||
};
|
||||
}
|
||||
73
lib/dashboard/notes.ts
Normal file
73
lib/dashboard/notes.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { anotacoes } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export type DashboardTask = {
|
||||
id: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
export type DashboardNote = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: "nota" | "tarefa";
|
||||
tasks?: DashboardTask[];
|
||||
arquivada: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const parseTasks = (value: string | null): DashboardTask[] | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed
|
||||
.filter((item): item is DashboardTask => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = item as Partial<DashboardTask>;
|
||||
return (
|
||||
typeof candidate.id === "string" &&
|
||||
typeof candidate.text === "string" &&
|
||||
typeof candidate.completed === "boolean"
|
||||
);
|
||||
})
|
||||
.map((task) => ({
|
||||
id: task.id,
|
||||
text: task.text,
|
||||
completed: task.completed,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to parse dashboard note tasks", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchDashboardNotes(
|
||||
userId: string,
|
||||
): Promise<DashboardNote[]> {
|
||||
const notes = await db.query.anotacoes.findMany({
|
||||
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
|
||||
orderBy: (note, { desc }) => [desc(note.createdAt)],
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
return notes.map((note) => ({
|
||||
id: note.id,
|
||||
title: (note.title ?? "").trim(),
|
||||
description: (note.description ?? "").trim(),
|
||||
type: (note.type ?? "nota") as "nota" | "tarefa",
|
||||
tasks: parseTasks(note.tasks),
|
||||
arquivada: note.arquivada,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { lancamentos, pagadores } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { calculatePercentageChange } from "@/lib/utils/math";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
|
||||
export type DashboardPagador = {
|
||||
id: string;
|
||||
@@ -11,6 +13,8 @@ export type DashboardPagador = {
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
totalExpenses: number;
|
||||
previousExpenses: number;
|
||||
percentageChange: number | null;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
@@ -23,6 +27,8 @@ export async function fetchDashboardPagadores(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardPagadoresSnapshot> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: pagadores.id,
|
||||
@@ -30,6 +36,7 @@ export async function fetchDashboardPagadores(
|
||||
email: pagadores.email,
|
||||
avatarUrl: pagadores.avatarUrl,
|
||||
role: pagadores.role,
|
||||
period: lancamentos.period,
|
||||
totalExpenses: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
@@ -37,7 +44,7 @@ export async function fetchDashboardPagadores(
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
inArray(lancamentos.period, [period, previousPeriod]),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
@@ -51,19 +58,60 @@ export async function fetchDashboardPagadores(
|
||||
pagadores.email,
|
||||
pagadores.avatarUrl,
|
||||
pagadores.role,
|
||||
lancamentos.period,
|
||||
)
|
||||
.orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`));
|
||||
|
||||
const pagadoresList = rows
|
||||
.map((row) => ({
|
||||
const groupedPagadores = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
isAdmin: boolean;
|
||||
currentExpenses: number;
|
||||
previousExpenses: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const entry = groupedPagadores.get(row.id) ?? {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl,
|
||||
totalExpenses: toNumber(row.totalExpenses),
|
||||
isAdmin: row.role === PAGADOR_ROLE_ADMIN,
|
||||
currentExpenses: 0,
|
||||
previousExpenses: 0,
|
||||
};
|
||||
|
||||
const amount = toNumber(row.totalExpenses);
|
||||
if (row.period === period) {
|
||||
entry.currentExpenses = amount;
|
||||
} else {
|
||||
entry.previousExpenses = amount;
|
||||
}
|
||||
|
||||
groupedPagadores.set(row.id, entry);
|
||||
}
|
||||
|
||||
const pagadoresList = Array.from(groupedPagadores.values())
|
||||
.filter((p) => p.currentExpenses > 0)
|
||||
.map((pagador) => ({
|
||||
id: pagador.id,
|
||||
name: pagador.name,
|
||||
email: pagador.email,
|
||||
avatarUrl: pagador.avatarUrl,
|
||||
totalExpenses: pagador.currentExpenses,
|
||||
previousExpenses: pagador.previousExpenses,
|
||||
percentageChange: calculatePercentageChange(
|
||||
pagador.currentExpenses,
|
||||
pagador.previousExpenses,
|
||||
),
|
||||
isAdmin: pagador.isAdmin,
|
||||
}))
|
||||
.filter((p) => p.totalExpenses > 0);
|
||||
.sort((a, b) => b.totalExpenses - a.totalExpenses);
|
||||
|
||||
const totalExpenses = pagadoresList.reduce(
|
||||
(sum, p) => sum + p.totalExpenses,
|
||||
|
||||
@@ -69,7 +69,10 @@ export async function fetchTopEstablishments(
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.name)
|
||||
.orderBy(sql`ABS(sum(${lancamentos.amount})) DESC`)
|
||||
.orderBy(
|
||||
sql`count(${lancamentos.id}) DESC`,
|
||||
sql`ABS(sum(${lancamentos.amount})) DESC`,
|
||||
)
|
||||
.limit(10);
|
||||
|
||||
const establishments = rows
|
||||
|
||||
@@ -7,33 +7,30 @@ import {
|
||||
RiExchangeLine,
|
||||
RiGroupLine,
|
||||
RiLineChartLine,
|
||||
RiMoneyDollarCircleLine,
|
||||
RiNumbersLine,
|
||||
RiPieChartLine,
|
||||
RiRefreshLine,
|
||||
RiSlideshowLine,
|
||||
RiStore2Line,
|
||||
RiStore3Line,
|
||||
RiTodoLine,
|
||||
RiWallet3Line,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { BoletosWidget } from "@/components/dashboard/boletos-widget";
|
||||
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
|
||||
import { GoalsProgressWidget } from "@/components/dashboard/goals-progress-widget";
|
||||
import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart";
|
||||
import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget";
|
||||
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
|
||||
import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
|
||||
import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget";
|
||||
import { NotesWidget } from "@/components/dashboard/notes-widget";
|
||||
import { PagadoresWidget } from "@/components/dashboard/pagadores-widget";
|
||||
import { PaymentConditionsWidget } from "@/components/dashboard/payment-conditions-widget";
|
||||
import { PaymentMethodsWidget } from "@/components/dashboard/payment-methods-widget";
|
||||
import { PaymentOverviewWidget } from "@/components/dashboard/payment-overview-widget";
|
||||
import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget";
|
||||
import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget";
|
||||
import { RecentTransactionsWidget } from "@/components/dashboard/recent-transactions-widget";
|
||||
import { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget";
|
||||
import { TopEstablishmentsWidget } from "@/components/dashboard/top-establishments-widget";
|
||||
import { TopExpensesWidget } from "@/components/dashboard/top-expenses-widget";
|
||||
import { SpendingOverviewWidget } from "@/components/dashboard/spending-overview-widget";
|
||||
import type { DashboardData } from "./fetch-dashboard-data";
|
||||
|
||||
export type WidgetConfig = {
|
||||
@@ -114,30 +111,49 @@ export const widgetsConfig: WidgetConfig[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "recent-transactions",
|
||||
title: "Lançamentos Recentes",
|
||||
subtitle: "Últimas 5 despesas registradas",
|
||||
id: "notes",
|
||||
title: "Anotações",
|
||||
subtitle: "Últimas anotações ativas",
|
||||
icon: <RiTodoLine className="size-4" />,
|
||||
component: ({ data }) => <NotesWidget notes={data.notesData} />,
|
||||
action: (
|
||||
<Link
|
||||
href="/anotacoes"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Ver todas
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "goals-progress",
|
||||
title: "Progresso de Orçamentos",
|
||||
subtitle: "Orçamentos por categoria no período",
|
||||
icon: <RiExchangeLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<RecentTransactionsWidget data={data.recentTransactionsData} />
|
||||
<GoalsProgressWidget data={data.goalsProgressData} />
|
||||
),
|
||||
action: (
|
||||
<Link
|
||||
href="/orcamentos"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Ver todos
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payment-conditions",
|
||||
title: "Condições de Pagamentos",
|
||||
subtitle: "Análise das condições",
|
||||
icon: <RiSlideshowLine className="size-4" />,
|
||||
id: "payment-overview",
|
||||
title: "Comportamento de Pagamento",
|
||||
subtitle: "Despesas por condição e forma de pagamento",
|
||||
icon: <RiWallet3Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentConditionsWidget data={data.paymentConditionsData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "payment-methods",
|
||||
title: "Formas de Pagamento",
|
||||
subtitle: "Distribuição das despesas",
|
||||
icon: <RiMoneyDollarCircleLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<PaymentMethodsWidget data={data.paymentMethodsData} />
|
||||
<PaymentOverviewWidget
|
||||
paymentConditionsData={data.paymentConditionsData}
|
||||
paymentMethodsData={data.paymentMethodsData}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -168,35 +184,18 @@ export const widgetsConfig: WidgetConfig[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "top-expenses",
|
||||
title: "Maiores Gastos do Mês",
|
||||
subtitle: "Top 10 Despesas",
|
||||
id: "spending-overview",
|
||||
title: "Panorama de Gastos",
|
||||
subtitle: "Principais despesas e frequência por local",
|
||||
icon: <RiArrowUpDoubleLine className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<TopExpensesWidget
|
||||
allExpenses={data.topExpensesAll}
|
||||
cardOnlyExpenses={data.topExpensesCardOnly}
|
||||
<SpendingOverviewWidget
|
||||
topExpensesAll={data.topExpensesAll}
|
||||
topExpensesCardOnly={data.topExpensesCardOnly}
|
||||
topEstablishmentsData={data.topEstablishmentsData}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "top-establishments",
|
||||
title: "Top Estabelecimentos",
|
||||
subtitle: "Frequência de gastos no período",
|
||||
icon: <RiStore2Line className="size-4" />,
|
||||
component: ({ data }) => (
|
||||
<TopEstablishmentsWidget data={data.topEstablishmentsData} />
|
||||
),
|
||||
action: (
|
||||
<Link
|
||||
href="/top-estabelecimentos"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Ver mais
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "purchases-by-category",
|
||||
title: "Lançamentos por Categorias",
|
||||
|
||||
Reference in New Issue
Block a user