mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat: adiciona ações em lote ao inbox
This commit is contained in:
@@ -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> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user