feat: adiciona ações em lote ao inbox

This commit is contained in:
Felipe Coutinho
2026-03-15 23:24:00 +00:00
parent 1823b6be56
commit 173fc86920
3 changed files with 192 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray, ne } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { inboxItems } from "@/db/schema"; import { inboxItems } from "@/db/schema";
import { import {
@@ -35,6 +35,10 @@ const bulkDeleteInboxSchema = z.object({
status: z.enum(["processed", "discarded"]), status: z.enum(["processed", "discarded"]),
}); });
const bulkDeleteSelectedInboxSchema = z.object({
inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"),
});
function revalidateInbox() { function revalidateInbox() {
revalidateForEntity("inbox"); revalidateForEntity("inbox");
} }
@@ -264,6 +268,36 @@ export async function deleteInboxItemAction(
} }
} }
export async function bulkDeleteSelectedInboxItemsAction(
input: z.infer<typeof bulkDeleteSelectedInboxSchema>,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = bulkDeleteSelectedInboxSchema.parse(input);
const result = await db
.delete(inboxItems)
.where(
and(
inArray(inboxItems.id, data.inboxItemIds),
eq(inboxItems.userId, user.id),
ne(inboxItems.status, "pending"),
),
)
.returning({ id: inboxItems.id });
revalidateInbox();
const count = result.length;
return {
success: true,
message: `${count} item(s) excluído(s).`,
};
} catch (error) {
return handleActionError(error);
}
}
export async function bulkDeleteInboxItemsAction( export async function bulkDeleteInboxItemsAction(
input: z.infer<typeof bulkDeleteInboxSchema>, input: z.infer<typeof bulkDeleteInboxSchema>,
): Promise<ActionResult> { ): Promise<ActionResult> {

View File

@@ -21,6 +21,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/shared/components/ui/card"; } from "@/shared/components/ui/card";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -39,6 +40,8 @@ interface InboxCardProps {
onViewDetails?: (item: InboxItem) => void; onViewDetails?: (item: InboxItem) => void;
onDelete?: (item: InboxItem) => void; onDelete?: (item: InboxItem) => void;
onRestoreToPending?: (item: InboxItem) => void | Promise<void>; onRestoreToPending?: (item: InboxItem) => void | Promise<void>;
selected?: boolean;
onSelectToggle?: (id: string) => void;
} }
function findMatchingLogo( function findMatchingLogo(
@@ -71,6 +74,8 @@ export function InboxCard({
onViewDetails, onViewDetails,
onDelete, onDelete,
onRestoreToPending, onRestoreToPending,
selected,
onSelectToggle,
}: InboxCardProps) { }: InboxCardProps) {
const matchedLogo = appLogoMap const matchedLogo = appLogoMap
? findMatchingLogo(item.sourceAppName, appLogoMap) ? findMatchingLogo(item.sourceAppName, appLogoMap)
@@ -112,7 +117,9 @@ export function InboxCard({
: null; : null;
return ( return (
<Card className="flex flex-col gap-0 py-0 h-54"> <Card
className={`flex flex-col gap-0 py-0 h-54 transition-colors ${selected ? "ring-2 ring-primary" : ""}`}
>
{/* Header com app e valor */} {/* Header com app e valor */}
<CardHeader className="pt-4"> <CardHeader className="pt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -217,6 +224,13 @@ export function InboxCard({
<RiDeleteBinLine className="size-4" /> <RiDeleteBinLine className="size-4" />
</Button> </Button>
)} )}
{onSelectToggle && (
<Checkbox
checked={!!selected}
onCheckedChange={() => onSelectToggle(item.id)}
aria-label="Selecionar item"
/>
)}
</div> </div>
</CardFooter> </CardFooter>
) : ( ) : (
@@ -239,6 +253,13 @@ export function InboxCard({
> >
<RiDeleteBinLine className="size-4" /> <RiDeleteBinLine className="size-4" />
</Button> </Button>
{onSelectToggle && (
<Checkbox
checked={!!selected}
onCheckedChange={() => onSelectToggle(item.id)}
aria-label="Selecionar item"
/>
)}
</CardFooter> </CardFooter>
)} )}
</Card> </Card>

View File

@@ -5,6 +5,8 @@ import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
bulkDeleteInboxItemsAction, bulkDeleteInboxItemsAction,
bulkDeleteSelectedInboxItemsAction,
bulkDiscardInboxItemsAction,
deleteInboxItemAction, deleteInboxItemAction,
discardInboxItemAction, discardInboxItemAction,
markInboxAsProcessedAction, markInboxAsProcessedAction,
@@ -72,6 +74,19 @@ export function InboxPage({
"processed" | "discarded" "processed" | "discarded"
>("processed"); >("processed");
const [selectedPendingIds, setSelectedPendingIds] = useState<string[]>([]);
const [selectedProcessedIds, setSelectedProcessedIds] = useState<string[]>(
[],
);
const [selectedDiscardedIds, setSelectedDiscardedIds] = useState<string[]>(
[],
);
const [selectionBulkOpen, setSelectionBulkOpen] = useState(false);
const [selectionBulkStatus, setSelectionBulkStatus] = useState<
"pending" | "processed" | "discarded"
>("pending");
const sortedPending = useMemo( const sortedPending = useMemo(
() => () =>
[...pendingItems].sort( [...pendingItems].sort(
@@ -208,6 +223,52 @@ export function InboxPage({
throw new Error(result.error); throw new Error(result.error);
}; };
const toggleSelection = (
ids: string[],
setIds: (v: string[]) => void,
id: string,
) => {
setIds(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]);
};
const handleSelectionBulkRequest = (
status: "pending" | "processed" | "discarded",
) => {
setSelectionBulkStatus(status);
setSelectionBulkOpen(true);
};
const handleSelectionBulkConfirm = async () => {
if (selectionBulkStatus === "pending") {
const result = await bulkDiscardInboxItemsAction({
inboxItemIds: selectedPendingIds,
});
if (result.success) {
toast.success(result.message);
setSelectedPendingIds([]);
return;
}
toast.error(result.error);
throw new Error(result.error);
} else {
const ids =
selectionBulkStatus === "processed"
? selectedProcessedIds
: selectedDiscardedIds;
const result = await bulkDeleteSelectedInboxItemsAction({
inboxItemIds: ids,
});
if (result.success) {
toast.success(result.message);
if (selectionBulkStatus === "processed") setSelectedProcessedIds([]);
else setSelectedDiscardedIds([]);
return;
}
toast.error(result.error);
throw new Error(result.error);
}
};
const handleBulkDeleteOpenChange = (open: boolean) => { const handleBulkDeleteOpenChange = (open: boolean) => {
setBulkDeleteOpen(open); setBulkDeleteOpen(open);
}; };
@@ -291,7 +352,12 @@ export function InboxPage({
</Card> </Card>
); );
const renderGrid = (list: InboxItem[], readonly?: boolean) => const renderGrid = (
list: InboxItem[],
readonly?: boolean,
selectedIds?: string[],
onToggle?: (id: string) => void,
) =>
list.length === 0 ? ( list.length === 0 ? (
renderEmptyState( renderEmptyState(
readonly readonly
@@ -311,6 +377,8 @@ export function InboxPage({
onViewDetails={readonly ? undefined : handleDetailsRequest} onViewDetails={readonly ? undefined : handleDetailsRequest}
onDelete={readonly ? handleDeleteRequest : undefined} onDelete={readonly ? handleDeleteRequest : undefined}
onRestoreToPending={readonly ? handleRestoreRequest : undefined} onRestoreToPending={readonly ? handleRestoreRequest : undefined}
selected={selectedIds?.includes(item.id)}
onSelectToggle={onToggle}
/> />
))} ))}
</div> </div>
@@ -344,11 +412,35 @@ export function InboxPage({
</TabsList> </TabsList>
<TabsContent value="pending" className="mt-4"> <TabsContent value="pending" className="mt-4">
{renderGrid(sortedPending)} {selectedPendingIds.length > 0 && (
<div className="mb-4 flex justify-end">
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("pending")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Descartar selecionados ({selectedPendingIds.length})
</Button>
</div>
)}
{renderGrid(sortedPending, false, selectedPendingIds, (id) =>
toggleSelection(selectedPendingIds, setSelectedPendingIds, id),
)}
</TabsContent> </TabsContent>
<TabsContent value="processed" className="mt-4"> <TabsContent value="processed" className="mt-4">
{sortedProcessed.length > 0 && ( {sortedProcessed.length > 0 && (
<div className="mb-4 flex justify-end"> <div className="mb-4 flex items-center justify-end gap-2">
{selectedProcessedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedProcessedIds.length})
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -359,11 +451,23 @@ export function InboxPage({
</Button> </Button>
</div> </div>
)} )}
{renderGrid(sortedProcessed, true)} {renderGrid(sortedProcessed, true, selectedProcessedIds, (id) =>
toggleSelection(selectedProcessedIds, setSelectedProcessedIds, id),
)}
</TabsContent> </TabsContent>
<TabsContent value="discarded" className="mt-4"> <TabsContent value="discarded" className="mt-4">
{sortedDiscarded.length > 0 && ( {sortedDiscarded.length > 0 && (
<div className="mb-4 flex justify-end"> <div className="mb-4 flex items-center justify-end gap-2">
{selectedDiscardedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedDiscardedIds.length})
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -374,7 +478,9 @@ export function InboxPage({
</Button> </Button>
</div> </div>
)} )}
{renderGrid(sortedDiscarded, true)} {renderGrid(sortedDiscarded, true, selectedDiscardedIds, (id) =>
toggleSelection(selectedDiscardedIds, setSelectedDiscardedIds, id),
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -446,6 +552,29 @@ export function InboxPage({
pendingLabel="Excluindo..." pendingLabel="Excluindo..."
onConfirm={handleBulkDeleteConfirm} onConfirm={handleBulkDeleteConfirm}
/> />
<ConfirmActionDialog
open={selectionBulkOpen}
onOpenChange={setSelectionBulkOpen}
title={
selectionBulkStatus === "pending"
? "Descartar selecionados?"
: "Excluir selecionados?"
}
description={
selectionBulkStatus === "pending"
? `${selectedPendingIds.length} item(s) serão descartados.`
: `${selectionBulkStatus === "processed" ? selectedProcessedIds.length : selectedDiscardedIds.length} item(s) serão excluídos permanentemente.`
}
confirmLabel={
selectionBulkStatus === "pending" ? "Descartar" : "Excluir"
}
confirmVariant="destructive"
pendingLabel={
selectionBulkStatus === "pending" ? "Descartando..." : "Excluindo..."
}
onConfirm={handleSelectionBulkConfirm}
/>
</> </>
); );
} }