feat(anexos): adiciona filtro por pessoa

This commit is contained in:
Felipe Coutinho
2026-06-21 11:54:15 -03:00
parent d363662548
commit 1660f68a4b
4 changed files with 192 additions and 41 deletions

View File

@@ -1,6 +1,6 @@
import { connection } from "next/server";
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
import { fetchAttachmentsPageData } from "@/features/attachments/queries";
import { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -26,11 +26,14 @@ export default async function Page({ searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period } = parsePeriodParam(periodoParam);
const attachments = await fetchAttachmentsForPeriod(userId, period);
const data = await fetchAttachmentsPageData(userId, period);
return (
<main className="flex flex-col gap-6">
<AttachmentsPage attachments={attachments} />
<AttachmentsPage
attachments={data?.attachments ?? []}
adminPayerId={data?.adminPayerId ?? ""}
/>
</main>
);
}

View File

@@ -162,9 +162,15 @@ export function AttachmentGridItem({
</div>
{/* Data */}
<span className="text-xs text-muted-foreground">
<div className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
<span className="shrink-0">
{formatDate(attachment.purchaseDate)}
</span>
<span aria-hidden>·</span>
<span className="truncate" title={attachment.payerName}>
{attachment.payerName}
</span>
</div>
{/* Transação e Valor */}
<div className="flex items-start justify-between gap-2">

View File

@@ -4,6 +4,8 @@ import {
RiAttachmentLine,
RiFilePdf2Line,
RiImageLine,
RiUserLine,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import type React from "react";
@@ -17,9 +19,17 @@ import type { TransactionDialogOptions } from "@/features/transactions/actions/f
import { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { PayerSelectContent } from "@/features/transactions/components/select-items";
import type { TransactionItem } from "@/features/transactions/components/types";
import { EmptyState } from "@/shared/components/feedback/empty-state";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { cn } from "@/shared/utils/ui";
type FilterType = "all" | "images" | "pdfs";
@@ -73,11 +83,18 @@ const FILTERS: {
interface AttachmentsPageProps {
attachments: AttachmentForPeriod[];
adminPayerId: string;
}
export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
const ALL_PAYERS = "all";
export function AttachmentsPage({
attachments,
adminPayerId,
}: AttachmentsPageProps) {
const router = useRouter();
const [filter, setFilter] = useState<FilterType>("all");
const [payerFilter, setPayerFilter] = useState(adminPayerId);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [transactionDetails, setTransactionDetails] =
useState<TransactionItem | null>(null);
@@ -93,21 +110,44 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
const [dialogOptions, setDialogOptions] =
useState<TransactionDialogOptions | null>(null);
const filteredAttachments = attachments.filter((a) => {
const payerOptions = Array.from(
new Map(
attachments.map((attachment) => [
attachment.payerId,
{
value: attachment.payerId,
label: attachment.payerName,
avatarUrl: attachment.payerAvatarUrl,
},
]),
).values(),
).sort((a, b) =>
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
);
const payerAttachments = attachments.filter(
(attachment) =>
payerFilter === ALL_PAYERS || attachment.payerId === payerFilter,
);
const selectedPayer = payerOptions.find(
(option) => option.value === payerFilter,
);
const filteredAttachments = payerAttachments.filter((a) => {
if (filter === "images") return a.mimeType.startsWith("image/");
if (filter === "pdfs") return a.mimeType === "application/pdf";
return true;
});
const imageCount = attachments.filter((a) =>
const imageCount = payerAttachments.filter((a) =>
a.mimeType.startsWith("image/"),
).length;
const pdfCount = attachments.filter(
const pdfCount = payerAttachments.filter(
(a) => a.mimeType === "application/pdf",
).length;
const counts: Record<FilterType, number> = {
all: attachments.length,
all: payerAttachments.length,
images: imageCount,
pdfs: pdfCount,
};
@@ -161,6 +201,67 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
{filter !== "all" &&
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
</p>
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Select
value={payerFilter}
onValueChange={(value) => {
setPayerFilter(value);
setSelectedIndex(-1);
}}
>
<SelectTrigger
size="sm"
className="min-w-44 flex-1 sm:flex-none"
>
<SelectValue placeholder="Pessoa">
{payerFilter === ALL_PAYERS ? (
<span className="flex items-center gap-2">
<RiUserLine className="size-4" />
Todas as pessoas
</span>
) : selectedPayer ? (
<span className="flex items-center gap-1.5">
<PayerSelectContent
label={selectedPayer.label}
avatarUrl={selectedPayer.avatarUrl}
/>
{selectedPayer.value === adminPayerId && (
<RiVerifiedBadgeFill
className="size-4 text-blue-500"
aria-label="Pessoa principal"
/>
)}
</span>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={ALL_PAYERS}>
<span className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-muted">
<RiUserLine className="size-3.5" />
</span>
Todas as pessoas
</span>
</SelectItem>
{payerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex items-center gap-1.5">
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
{option.value === adminPayerId && (
<RiVerifiedBadgeFill
className="size-4 text-blue-500"
aria-label="Pessoa principal"
/>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 rounded-lg border p-1">
{FILTERS.map(({ value, label, icon }) => (
<button
@@ -193,13 +294,18 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
))}
</div>
</div>
</div>
{filteredAttachments.length === 0 ? (
<div className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiAttachmentLine className="size-6 text-primary" />}
title="Nenhum anexo encontrado"
description="Não há anexos do tipo selecionado neste mês."
description={
payerAttachments.length === 0
? "Não há anexos desta pessoa neste mês."
: "Não há anexos do tipo selecionado neste mês."
}
/>
</div>
) : (

View File

@@ -3,6 +3,7 @@ import { cacheLife, cacheTag } from "next/cache";
import {
attachments,
categories,
payers,
transactionAttachments,
transactions,
} from "@/db/schema";
@@ -21,11 +22,20 @@ export type AttachmentForPeriod = {
purchaseDate: Date;
categoryName: string | null;
categoryIcon: string | null;
payerId: string;
payerName: string;
payerAvatarUrl: string | null;
};
export type AttachmentsPageData = {
attachments: AttachmentForPeriod[];
adminPayerId: string;
};
export async function fetchAttachmentsForPeriod(
userId: string,
period: string,
payerScope?: string | "all",
): Promise<AttachmentForPeriod[]> {
"use cache";
cacheTag(`dashboard-${userId}`);
@@ -33,8 +43,9 @@ export async function fetchAttachmentsForPeriod(
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) return [];
const payerId = payerScope ?? adminPayerId;
return db
const rows = await db
.select({
attachmentId: attachments.id,
fileName: attachments.fileName,
@@ -47,6 +58,9 @@ export async function fetchAttachmentsForPeriod(
purchaseDate: transactions.purchaseDate,
categoryName: categories.name,
categoryIcon: categories.icon,
payerId: payers.id,
payerName: payers.name,
payerAvatarUrl: payers.avatarUrl,
})
.from(transactionAttachments)
.innerJoin(
@@ -61,10 +75,32 @@ export async function fetchAttachmentsForPeriod(
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, userId),
eq(transactions.payerId, adminPayerId),
eq(transactions.period, period),
payerId === "all" ? undefined : eq(transactions.payerId, payerId),
),
)
.innerJoin(
payers,
and(eq(transactions.payerId, payers.id), eq(payers.userId, userId)),
)
.leftJoin(
categories,
and(
eq(transactions.categoryId, categories.id),
eq(categories.userId, userId),
),
)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.orderBy(desc(transactions.purchaseDate), desc(attachments.id));
return rows;
}
export async function fetchAttachmentsPageData(
userId: string,
period: string,
): Promise<AttachmentsPageData | null> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) return null;
const rows = await fetchAttachmentsForPeriod(userId, period, "all");
return { attachments: rows, adminPayerId };
}