mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat: pagina inbox e valida tokens do companion
This commit is contained in:
@@ -1,29 +1,55 @@
|
|||||||
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
||||||
|
import {
|
||||||
|
type ResolvedInboxSearchParams,
|
||||||
|
resolveInboxPagination,
|
||||||
|
resolveInboxStatus,
|
||||||
|
} from "@/features/inbox/page-helpers";
|
||||||
import {
|
import {
|
||||||
fetchAppLogoMap,
|
fetchAppLogoMap,
|
||||||
fetchInboxDialogData,
|
fetchInboxDialogData,
|
||||||
fetchInboxItems,
|
fetchInboxItemsPage,
|
||||||
|
fetchInboxStatusCounts,
|
||||||
} from "@/features/inbox/queries";
|
} from "@/features/inbox/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
type PageSearchParams = Promise<ResolvedInboxSearchParams>;
|
||||||
const userId = await getUserId();
|
|
||||||
|
|
||||||
const [pendingItems, processedItems, discardedItems, dialogData, appLogoMap] =
|
type PageProps = {
|
||||||
await Promise.all([
|
searchParams?: PageSearchParams;
|
||||||
fetchInboxItems(userId, "pending"),
|
};
|
||||||
fetchInboxItems(userId, "processed"),
|
|
||||||
fetchInboxItems(userId, "discarded"),
|
const EMPTY_DIALOG_DATA = {
|
||||||
fetchInboxDialogData(userId),
|
payerOptions: [],
|
||||||
fetchAppLogoMap(userId),
|
splitPayerOptions: [],
|
||||||
]);
|
defaultPayerId: null,
|
||||||
|
accountOptions: [],
|
||||||
|
cardOptions: [],
|
||||||
|
categoryOptions: [],
|
||||||
|
estabelecimentos: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
const userId = await getUserId();
|
||||||
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
const activeStatus = resolveInboxStatus(resolvedSearchParams);
|
||||||
|
const paginationInput = resolveInboxPagination(resolvedSearchParams);
|
||||||
|
|
||||||
|
const [itemsPage, counts, dialogData, appLogoMap] = await Promise.all([
|
||||||
|
fetchInboxItemsPage(userId, activeStatus, paginationInput),
|
||||||
|
fetchInboxStatusCounts(userId),
|
||||||
|
activeStatus === "pending"
|
||||||
|
? fetchInboxDialogData(userId)
|
||||||
|
: Promise.resolve(EMPTY_DIALOG_DATA),
|
||||||
|
fetchAppLogoMap(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col items-start gap-6">
|
<main className="flex flex-col items-start gap-6">
|
||||||
<InboxPage
|
<InboxPage
|
||||||
pendingItems={pendingItems}
|
activeStatus={activeStatus}
|
||||||
processedItems={processedItems}
|
items={itemsPage.items}
|
||||||
discardedItems={discardedItems}
|
counts={counts}
|
||||||
|
pagination={itemsPage.pagination}
|
||||||
payerOptions={dialogData.payerOptions}
|
payerOptions={dialogData.payerOptions}
|
||||||
splitPayerOptions={dialogData.splitPayerOptions}
|
splitPayerOptions={dialogData.splitPayerOptions}
|
||||||
defaultPayerId={dialogData.defaultPayerId}
|
defaultPayerId={dialogData.defaultPayerId}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +38,7 @@ export async function POST(request: Request) {
|
|||||||
eq(apiTokens.id, payload.tokenId),
|
eq(apiTokens.id, payload.tokenId),
|
||||||
eq(apiTokens.userId, payload.sub),
|
eq(apiTokens.userId, payload.sub),
|
||||||
isNull(apiTokens.revokedAt),
|
isNull(apiTokens.revokedAt),
|
||||||
|
gt(apiTokens.expiresAt, new Date()),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,8 +66,9 @@ export async function POST(request: Request) {
|
|||||||
tokenHash: hashToken(result.accessToken),
|
tokenHash: hashToken(result.accessToken),
|
||||||
lastUsedAt: new Date(),
|
lastUsedAt: new Date(),
|
||||||
lastUsedIp:
|
lastUsedIp:
|
||||||
request.headers.get("x-forwarded-for") ||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
request.headers.get("x-real-ip"),
|
request.headers.get("x-real-ip") ||
|
||||||
|
null,
|
||||||
expiresAt: result.expiresAt,
|
expiresAt: result.expiresAt,
|
||||||
})
|
})
|
||||||
.where(eq(apiTokens.id, payload.tokenId));
|
.where(eq(apiTokens.id, payload.tokenId));
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export async function DELETE(_request: Request, { params }: RouteParams) {
|
|||||||
await db
|
await db
|
||||||
.update(apiTokens)
|
.update(apiTokens)
|
||||||
.set({ revokedAt: new Date() })
|
.set({ revokedAt: new Date() })
|
||||||
.where(eq(apiTokens.id, tokenId));
|
.where(
|
||||||
|
and(eq(apiTokens.id, tokenId), eq(apiTokens.userId, session.user.id)),
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Token revogado com sucesso",
|
message: "Token revogado com sucesso",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
|
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
|
||||||
@@ -33,6 +33,7 @@ export async function POST(request: Request) {
|
|||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.tokenHash, tokenHash),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
isNull(apiTokens.revokedAt),
|
isNull(apiTokens.revokedAt),
|
||||||
|
gt(apiTokens.expiresAt, new Date()),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiTokens, inboxItems } from "@/db/schema";
|
import { apiTokens, inboxItems } from "@/db/schema";
|
||||||
@@ -63,6 +63,7 @@ export async function POST(request: Request) {
|
|||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.tokenHash, tokenHash),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
isNull(apiTokens.revokedAt),
|
isNull(apiTokens.revokedAt),
|
||||||
|
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,10 +112,11 @@ export async function POST(request: Request) {
|
|||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("[API] Error processing batch item:", error);
|
||||||
results.push({
|
results.push({
|
||||||
clientId: item.clientId,
|
clientId: item.clientId,
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
error: "Erro ao processar notificação",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiTokens, inboxItems } from "@/db/schema";
|
import { apiTokens, inboxItems } from "@/db/schema";
|
||||||
@@ -56,6 +56,7 @@ export async function POST(request: Request) {
|
|||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.tokenHash, tokenHash),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
isNull(apiTokens.revokedAt),
|
isNull(apiTokens.revokedAt),
|
||||||
|
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ const bulkDeleteSelectedInboxSchema = z.object({
|
|||||||
inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"),
|
inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"),
|
||||||
});
|
});
|
||||||
|
|
||||||
function revalidateInbox() {
|
function revalidateInbox(userId: string) {
|
||||||
revalidateForEntity("inbox");
|
revalidateForEntity("inbox", userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,7 +85,7 @@ export async function markInboxAsProcessedAction(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
revalidateInbox();
|
revalidateInbox(user.id);
|
||||||
|
|
||||||
return { success: true, message: "Item processado com sucesso!" };
|
return { success: true, message: "Item processado com sucesso!" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -132,7 +132,7 @@ export async function discardInboxItemAction(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
revalidateInbox();
|
revalidateInbox(user.id);
|
||||||
|
|
||||||
return { success: true, message: "Item descartado." };
|
return { success: true, message: "Item descartado." };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -163,7 +163,7 @@ export async function bulkDiscardInboxItemsAction(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
revalidateInbox();
|
revalidateInbox(user.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -214,7 +214,7 @@ export async function restoreDiscardedInboxItemAction(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
revalidateInbox();
|
revalidateInbox(user.id);
|
||||||
|
|
||||||
return { success: true, message: "Item voltou para pendentes." };
|
return { success: true, message: "Item voltou para pendentes." };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -260,7 +260,7 @@ export async function deleteInboxItemAction(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
revalidateInbox();
|
revalidateInbox(user.id);
|
||||||
|
|
||||||
return { success: true, message: "Item excluído." };
|
return { success: true, message: "Item excluído." };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -286,7 +286,7 @@ export async function bulkDeleteSelectedInboxItemsAction(
|
|||||||
)
|
)
|
||||||
.returning({ id: inboxItems.id });
|
.returning({ id: inboxItems.id });
|
||||||
|
|
||||||
revalidateInbox();
|
revalidateInbox(user.id);
|
||||||
|
|
||||||
const count = result.length;
|
const count = result.length;
|
||||||
return {
|
return {
|
||||||
@@ -312,7 +312,7 @@ export async function bulkDeleteInboxItemsAction(
|
|||||||
)
|
)
|
||||||
.returning({ id: inboxItems.id });
|
.returning({ id: inboxItems.id });
|
||||||
|
|
||||||
revalidateInbox();
|
revalidateInbox(user.id);
|
||||||
|
|
||||||
const count = result.length;
|
const count = result.length;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAtLine, RiDeleteBinLine } from "@remixicon/react";
|
import {
|
||||||
import { useMemo, useState } from "react";
|
RiArrowLeftDoubleLine,
|
||||||
|
RiArrowLeftSLine,
|
||||||
|
RiArrowRightDoubleLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiAtLine,
|
||||||
|
RiDeleteBinLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
bulkDeleteInboxItemsAction,
|
bulkDeleteInboxItemsAction,
|
||||||
@@ -12,11 +20,22 @@ import {
|
|||||||
markInboxAsProcessedAction,
|
markInboxAsProcessedAction,
|
||||||
restoreDiscardedInboxItemAction,
|
restoreDiscardedInboxItemAction,
|
||||||
} from "@/features/inbox/actions";
|
} from "@/features/inbox/actions";
|
||||||
|
import {
|
||||||
|
INBOX_DEFAULT_PAGE_SIZE,
|
||||||
|
INBOX_PAGE_SIZE_OPTIONS,
|
||||||
|
} from "@/features/inbox/page-helpers";
|
||||||
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import { EmptyState } from "@/shared/components/empty-state";
|
import { EmptyState } from "@/shared/components/empty-state";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card } from "@/shared/components/ui/card";
|
import { Card } from "@/shared/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -25,12 +44,19 @@ import {
|
|||||||
} from "@/shared/components/ui/tabs";
|
} from "@/shared/components/ui/tabs";
|
||||||
import { InboxCard } from "./inbox-card";
|
import { InboxCard } from "./inbox-card";
|
||||||
import { InboxDetailsDialog } from "./inbox-details-dialog";
|
import { InboxDetailsDialog } from "./inbox-details-dialog";
|
||||||
import type { InboxItem, SelectOption } from "./types";
|
import type {
|
||||||
|
InboxItem,
|
||||||
|
InboxPaginationState,
|
||||||
|
InboxStatus,
|
||||||
|
InboxStatusCounts,
|
||||||
|
SelectOption,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
interface InboxPageProps {
|
interface InboxPageProps {
|
||||||
pendingItems: InboxItem[];
|
activeStatus: InboxStatus;
|
||||||
processedItems: InboxItem[];
|
items: InboxItem[];
|
||||||
discardedItems: InboxItem[];
|
counts: InboxStatusCounts;
|
||||||
|
pagination: InboxPaginationState;
|
||||||
payerOptions: SelectOption[];
|
payerOptions: SelectOption[];
|
||||||
splitPayerOptions: SelectOption[];
|
splitPayerOptions: SelectOption[];
|
||||||
defaultPayerId: string | null;
|
defaultPayerId: string | null;
|
||||||
@@ -42,9 +68,10 @@ interface InboxPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InboxPage({
|
export function InboxPage({
|
||||||
pendingItems,
|
activeStatus,
|
||||||
processedItems,
|
items,
|
||||||
discardedItems,
|
counts,
|
||||||
|
pagination,
|
||||||
payerOptions,
|
payerOptions,
|
||||||
splitPayerOptions,
|
splitPayerOptions,
|
||||||
defaultPayerId,
|
defaultPayerId,
|
||||||
@@ -54,6 +81,10 @@ export function InboxPage({
|
|||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
appLogoMap,
|
appLogoMap,
|
||||||
}: InboxPageProps) {
|
}: InboxPageProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
const [processOpen, setProcessOpen] = useState(false);
|
const [processOpen, setProcessOpen] = useState(false);
|
||||||
const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null);
|
const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null);
|
||||||
|
|
||||||
@@ -74,46 +105,11 @@ export function InboxPage({
|
|||||||
"processed" | "discarded"
|
"processed" | "discarded"
|
||||||
>("processed");
|
>("processed");
|
||||||
|
|
||||||
const [selectedPendingIds, setSelectedPendingIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
const [selectedProcessedIds, setSelectedProcessedIds] = useState<string[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [selectedDiscardedIds, setSelectedDiscardedIds] = useState<string[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectionBulkOpen, setSelectionBulkOpen] = useState(false);
|
const [selectionBulkOpen, setSelectionBulkOpen] = useState(false);
|
||||||
const [selectionBulkStatus, setSelectionBulkStatus] = useState<
|
const [selectionBulkStatus, setSelectionBulkStatus] =
|
||||||
"pending" | "processed" | "discarded"
|
useState<InboxStatus>("pending");
|
||||||
>("pending");
|
|
||||||
|
|
||||||
const sortedPending = useMemo(
|
|
||||||
() =>
|
|
||||||
[...pendingItems].sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.notificationTimestamp).getTime() -
|
|
||||||
new Date(a.notificationTimestamp).getTime(),
|
|
||||||
),
|
|
||||||
[pendingItems],
|
|
||||||
);
|
|
||||||
const sortedProcessed = useMemo(
|
|
||||||
() =>
|
|
||||||
[...processedItems].sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.notificationTimestamp).getTime() -
|
|
||||||
new Date(a.notificationTimestamp).getTime(),
|
|
||||||
),
|
|
||||||
[processedItems],
|
|
||||||
);
|
|
||||||
const sortedDiscarded = useMemo(
|
|
||||||
() =>
|
|
||||||
[...discardedItems].sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.notificationTimestamp).getTime() -
|
|
||||||
new Date(a.notificationTimestamp).getTime(),
|
|
||||||
),
|
|
||||||
[discardedItems],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleProcessOpenChange = (open: boolean) => {
|
const handleProcessOpenChange = (open: boolean) => {
|
||||||
setProcessOpen(open);
|
setProcessOpen(open);
|
||||||
@@ -223,40 +219,72 @@ export function InboxPage({
|
|||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelection = (
|
useEffect(() => {
|
||||||
ids: string[],
|
const visibleIds = new Set(items.map((item) => item.id));
|
||||||
setIds: (v: string[]) => void,
|
setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
|
||||||
id: string,
|
}, [items]);
|
||||||
|
|
||||||
|
const toggleSelection = (id: string) => {
|
||||||
|
setSelectedIds((current) =>
|
||||||
|
current.includes(id)
|
||||||
|
? current.filter((value) => value !== id)
|
||||||
|
: [...current, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allSelected = items.length > 0 && selectedIds.length === items.length;
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedIds(items.map((item) => item.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUrl = (
|
||||||
|
nextStatus: InboxStatus,
|
||||||
|
nextPage: number,
|
||||||
|
nextPageSize: number,
|
||||||
) => {
|
) => {
|
||||||
setIds(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]);
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
if (nextStatus === "pending") {
|
||||||
|
nextParams.delete("status");
|
||||||
|
} else {
|
||||||
|
nextParams.set("status", nextStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPage <= 1) {
|
||||||
|
nextParams.delete("page");
|
||||||
|
} else {
|
||||||
|
nextParams.set("page", nextPage.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPageSize === INBOX_DEFAULT_PAGE_SIZE) {
|
||||||
|
nextParams.delete("pageSize");
|
||||||
|
} else {
|
||||||
|
nextParams.set("pageSize", nextPageSize.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
const target = nextParams.toString()
|
||||||
|
? `${pathname}?${nextParams.toString()}`
|
||||||
|
: pathname;
|
||||||
|
router.replace(target, { scroll: false });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const allPendingSelected =
|
const handleTabChange = (nextStatus: string) => {
|
||||||
sortedPending.length > 0 &&
|
updateUrl(nextStatus as InboxStatus, 1, pagination.pageSize);
|
||||||
selectedPendingIds.length === sortedPending.length;
|
|
||||||
const allProcessedSelected =
|
|
||||||
sortedProcessed.length > 0 &&
|
|
||||||
selectedProcessedIds.length === sortedProcessed.length;
|
|
||||||
const allDiscardedSelected =
|
|
||||||
sortedDiscarded.length > 0 &&
|
|
||||||
selectedDiscardedIds.length === sortedDiscarded.length;
|
|
||||||
|
|
||||||
const toggleSelectAllPending = () => {
|
|
||||||
if (allPendingSelected) setSelectedPendingIds([]);
|
|
||||||
else setSelectedPendingIds(sortedPending.map((item) => item.id));
|
|
||||||
};
|
|
||||||
const toggleSelectAllProcessed = () => {
|
|
||||||
if (allProcessedSelected) setSelectedProcessedIds([]);
|
|
||||||
else setSelectedProcessedIds(sortedProcessed.map((item) => item.id));
|
|
||||||
};
|
|
||||||
const toggleSelectAllDiscarded = () => {
|
|
||||||
if (allDiscardedSelected) setSelectedDiscardedIds([]);
|
|
||||||
else setSelectedDiscardedIds(sortedDiscarded.map((item) => item.id));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectionBulkRequest = (
|
const handleSelectionBulkRequest = (status: InboxStatus) => {
|
||||||
status: "pending" | "processed" | "discarded",
|
if (selectedIds.length === 0) {
|
||||||
) => {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSelectionBulkStatus(status);
|
setSelectionBulkStatus(status);
|
||||||
setSelectionBulkOpen(true);
|
setSelectionBulkOpen(true);
|
||||||
};
|
};
|
||||||
@@ -264,27 +292,22 @@ export function InboxPage({
|
|||||||
const handleSelectionBulkConfirm = async () => {
|
const handleSelectionBulkConfirm = async () => {
|
||||||
if (selectionBulkStatus === "pending") {
|
if (selectionBulkStatus === "pending") {
|
||||||
const result = await bulkDiscardInboxItemsAction({
|
const result = await bulkDiscardInboxItemsAction({
|
||||||
inboxItemIds: selectedPendingIds,
|
inboxItemIds: selectedIds,
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setSelectedPendingIds([]);
|
setSelectedIds([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
} else {
|
} else {
|
||||||
const ids =
|
|
||||||
selectionBulkStatus === "processed"
|
|
||||||
? selectedProcessedIds
|
|
||||||
: selectedDiscardedIds;
|
|
||||||
const result = await bulkDeleteSelectedInboxItemsAction({
|
const result = await bulkDeleteSelectedInboxItemsAction({
|
||||||
inboxItemIds: ids,
|
inboxItemIds: selectedIds,
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
if (selectionBulkStatus === "processed") setSelectedProcessedIds([]);
|
setSelectedIds([]);
|
||||||
else setSelectedDiscardedIds([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
@@ -329,6 +352,9 @@ export function InboxPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canPreviousPage = pagination.page > 1;
|
||||||
|
const canNextPage = pagination.page < pagination.totalPages;
|
||||||
|
|
||||||
// Prepare default values from inbox item
|
// Prepare default values from inbox item
|
||||||
const getDateString = (
|
const getDateString = (
|
||||||
date: Date | string | null | undefined,
|
date: Date | string | null | undefined,
|
||||||
@@ -375,12 +401,7 @@ export function InboxPage({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderGrid = (
|
const renderGrid = (list: InboxItem[], readonly?: boolean) =>
|
||||||
list: InboxItem[],
|
|
||||||
readonly?: boolean,
|
|
||||||
selectedIds?: string[],
|
|
||||||
onToggle?: (id: string) => void,
|
|
||||||
) =>
|
|
||||||
list.length === 0 ? (
|
list.length === 0 ? (
|
||||||
renderEmptyState(
|
renderEmptyState(
|
||||||
readonly
|
readonly
|
||||||
@@ -400,8 +421,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)}
|
selected={selectedIds.includes(item.id)}
|
||||||
onSelectToggle={onToggle}
|
onSelectToggle={toggleSelection}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -409,79 +430,72 @@ export function InboxPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue="pending" className="w-full">
|
<Tabs
|
||||||
|
value={activeStatus}
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
|
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="pending"
|
value="pending"
|
||||||
|
disabled={isPending}
|
||||||
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
|
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
|
||||||
>
|
>
|
||||||
<span>Pendentes</span>
|
<span>Pendentes</span>
|
||||||
<span>({pendingItems.length})</span>
|
<span>({counts.pending})</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="processed"
|
value="processed"
|
||||||
|
disabled={isPending}
|
||||||
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
|
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
|
||||||
>
|
>
|
||||||
<span>Processados</span>
|
<span>Processados</span>
|
||||||
<span>({processedItems.length})</span>
|
<span>({counts.processed})</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="discarded"
|
value="discarded"
|
||||||
|
disabled={isPending}
|
||||||
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
|
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
|
||||||
>
|
>
|
||||||
<span>Descartados</span>
|
<span>Descartados</span>
|
||||||
<span>({discardedItems.length})</span>
|
<span>({counts.discarded})</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="pending" className="mt-4">
|
<TabsContent value="pending" className="mt-4">
|
||||||
{sortedPending.length > 0 && (
|
{activeStatus === "pending" && items.length > 0 && (
|
||||||
<div className="mb-4 flex items-center justify-end gap-2">
|
<div className="mb-4 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
|
||||||
variant="outline"
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
size="sm"
|
|
||||||
onClick={toggleSelectAllPending}
|
|
||||||
>
|
|
||||||
{allPendingSelected
|
|
||||||
? "Desselecionar todos"
|
|
||||||
: "Selecionar todos"}
|
|
||||||
</Button>
|
</Button>
|
||||||
{selectedPendingIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSelectionBulkRequest("pending")}
|
onClick={() => handleSelectionBulkRequest("pending")}
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
<RiDeleteBinLine className="mr-1.5 size-4" />
|
||||||
Descartar selecionados ({selectedPendingIds.length})
|
Descartar selecionados ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderGrid(sortedPending, false, selectedPendingIds, (id) =>
|
{activeStatus === "pending" ? renderGrid(items, false) : null}
|
||||||
toggleSelection(selectedPendingIds, setSelectedPendingIds, id),
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="processed" className="mt-4">
|
<TabsContent value="processed" className="mt-4">
|
||||||
{sortedProcessed.length > 0 && (
|
{activeStatus === "processed" && items.length > 0 && (
|
||||||
<div className="mb-4 flex items-center justify-end gap-2">
|
<div className="mb-4 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
|
||||||
variant="outline"
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
size="sm"
|
|
||||||
onClick={toggleSelectAllProcessed}
|
|
||||||
>
|
|
||||||
{allProcessedSelected
|
|
||||||
? "Desselecionar todos"
|
|
||||||
: "Selecionar todos"}
|
|
||||||
</Button>
|
</Button>
|
||||||
{selectedProcessedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSelectionBulkRequest("processed")}
|
onClick={() => handleSelectionBulkRequest("processed")}
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
<RiDeleteBinLine className="mr-1.5 size-4" />
|
||||||
Excluir selecionados ({selectedProcessedIds.length})
|
Excluir selecionados ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -494,30 +508,22 @@ export function InboxPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderGrid(sortedProcessed, true, selectedProcessedIds, (id) =>
|
{activeStatus === "processed" ? renderGrid(items, true) : null}
|
||||||
toggleSelection(selectedProcessedIds, setSelectedProcessedIds, id),
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="discarded" className="mt-4">
|
<TabsContent value="discarded" className="mt-4">
|
||||||
{sortedDiscarded.length > 0 && (
|
{activeStatus === "discarded" && items.length > 0 && (
|
||||||
<div className="mb-4 flex items-center justify-end gap-2">
|
<div className="mb-4 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
|
||||||
variant="outline"
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
size="sm"
|
|
||||||
onClick={toggleSelectAllDiscarded}
|
|
||||||
>
|
|
||||||
{allDiscardedSelected
|
|
||||||
? "Desselecionar todos"
|
|
||||||
: "Selecionar todos"}
|
|
||||||
</Button>
|
</Button>
|
||||||
{selectedDiscardedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSelectionBulkRequest("discarded")}
|
onClick={() => handleSelectionBulkRequest("discarded")}
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
<RiDeleteBinLine className="mr-1.5 size-4" />
|
||||||
Excluir selecionados ({selectedDiscardedIds.length})
|
Excluir selecionados ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -530,12 +536,99 @@ export function InboxPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderGrid(sortedDiscarded, true, selectedDiscardedIds, (id) =>
|
{activeStatus === "discarded" ? renderGrid(items, true) : null}
|
||||||
toggleSelection(selectedDiscardedIds, setSelectedDiscardedIds, id),
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{pagination.totalItems > 0 ? (
|
||||||
|
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{pagination.totalItems} notificações
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
disabled={isPending}
|
||||||
|
value={pagination.pageSize.toString()}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateUrl(activeStatus, 1, Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-max">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{INBOX_PAGE_SIZE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option} value={option.toString()}>
|
||||||
|
{option} itens
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Página {pagination.page} de {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => updateUrl(activeStatus, 1, pagination.pageSize)}
|
||||||
|
disabled={!canPreviousPage || isPending}
|
||||||
|
aria-label="Primeira página"
|
||||||
|
>
|
||||||
|
<RiArrowLeftDoubleLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() =>
|
||||||
|
updateUrl(
|
||||||
|
activeStatus,
|
||||||
|
pagination.page - 1,
|
||||||
|
pagination.pageSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canPreviousPage || isPending}
|
||||||
|
aria-label="Página anterior"
|
||||||
|
>
|
||||||
|
<RiArrowLeftSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() =>
|
||||||
|
updateUrl(
|
||||||
|
activeStatus,
|
||||||
|
pagination.page + 1,
|
||||||
|
pagination.pageSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canNextPage || isPending}
|
||||||
|
aria-label="Próxima página"
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() =>
|
||||||
|
updateUrl(
|
||||||
|
activeStatus,
|
||||||
|
pagination.totalPages,
|
||||||
|
pagination.pageSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canNextPage || isPending}
|
||||||
|
aria-label="Última página"
|
||||||
|
>
|
||||||
|
<RiArrowRightDoubleLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TransactionDialog
|
<TransactionDialog
|
||||||
mode="create"
|
mode="create"
|
||||||
open={processOpen}
|
open={processOpen}
|
||||||
@@ -617,8 +710,8 @@ export function InboxPage({
|
|||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
selectionBulkStatus === "pending"
|
selectionBulkStatus === "pending"
|
||||||
? `${selectedPendingIds.length} item(s) serão descartados.`
|
? `${selectedIds.length} item(s) serão descartados.`
|
||||||
: `${selectionBulkStatus === "processed" ? selectedProcessedIds.length : selectedDiscardedIds.length} item(s) serão excluídos permanentemente.`
|
: `${selectedIds.length} item(s) serão excluídos permanentemente.`
|
||||||
}
|
}
|
||||||
confirmLabel={
|
confirmLabel={
|
||||||
selectionBulkStatus === "pending" ? "Descartar" : "Excluir"
|
selectionBulkStatus === "pending" ? "Descartar" : "Excluir"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { SelectOption as LancamentoSelectOption } from "@/features/transactions/components/types";
|
import type { SelectOption as LancamentoSelectOption } from "@/features/transactions/components/types";
|
||||||
|
|
||||||
|
export type InboxStatus = "pending" | "processed" | "discarded";
|
||||||
|
|
||||||
export interface InboxItem {
|
export interface InboxItem {
|
||||||
id: string;
|
id: string;
|
||||||
sourceApp: string;
|
sourceApp: string;
|
||||||
@@ -17,5 +19,14 @@ export interface InboxItem {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InboxStatusCounts = Record<InboxStatus, number>;
|
||||||
|
|
||||||
|
export type InboxPaginationState = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
// Re-export the lancamentos SelectOption for use in inbox components
|
// Re-export the lancamentos SelectOption for use in inbox components
|
||||||
export type SelectOption = LancamentoSelectOption;
|
export type SelectOption = LancamentoSelectOption;
|
||||||
|
|||||||
49
src/features/inbox/page-helpers.ts
Normal file
49
src/features/inbox/page-helpers.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { InboxPaginationState, InboxStatus } from "./components/types";
|
||||||
|
|
||||||
|
export type ResolvedInboxSearchParams =
|
||||||
|
| Record<string, string | string[] | undefined>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export const INBOX_DEFAULT_PAGE_SIZE = 12;
|
||||||
|
export const INBOX_PAGE_SIZE_OPTIONS = [12, 24, 48];
|
||||||
|
|
||||||
|
export const INBOX_STATUSES = ["pending", "processed", "discarded"] as const;
|
||||||
|
|
||||||
|
export const getSingleParam = (
|
||||||
|
params: ResolvedInboxSearchParams,
|
||||||
|
key: string,
|
||||||
|
): string | null => {
|
||||||
|
const value = params?.[key];
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveInboxStatus = (
|
||||||
|
params: ResolvedInboxSearchParams,
|
||||||
|
): InboxStatus => {
|
||||||
|
const status = getSingleParam(params, "status");
|
||||||
|
|
||||||
|
return INBOX_STATUSES.includes(status as InboxStatus)
|
||||||
|
? (status as InboxStatus)
|
||||||
|
: "pending";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveInboxPagination = (
|
||||||
|
params: ResolvedInboxSearchParams,
|
||||||
|
): Pick<InboxPaginationState, "page" | "pageSize"> => {
|
||||||
|
const pageParam = Number.parseInt(getSingleParam(params, "page") ?? "", 10);
|
||||||
|
const pageSizeParam = Number.parseInt(
|
||||||
|
getSingleParam(params, "pageSize") ?? "",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1,
|
||||||
|
pageSize: INBOX_PAGE_SIZE_OPTIONS.includes(pageSizeParam)
|
||||||
|
? pageSizeParam
|
||||||
|
: INBOX_DEFAULT_PAGE_SIZE,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, count, desc, eq } from "drizzle-orm";
|
||||||
import { cards, categories, financialAccounts, inboxItems } from "@/db/schema";
|
import { cards, categories, financialAccounts, inboxItems } from "@/db/schema";
|
||||||
import type {
|
import type {
|
||||||
InboxItem,
|
InboxItem,
|
||||||
|
InboxPaginationState,
|
||||||
|
InboxStatus,
|
||||||
|
InboxStatusCounts,
|
||||||
SelectOption,
|
SelectOption,
|
||||||
} from "@/features/inbox/components/types";
|
} from "@/features/inbox/components/types";
|
||||||
import {
|
import {
|
||||||
@@ -16,17 +19,90 @@ import { db } from "@/shared/lib/db";
|
|||||||
|
|
||||||
export async function fetchInboxItems(
|
export async function fetchInboxItems(
|
||||||
userId: string,
|
userId: string,
|
||||||
status: "pending" | "processed" | "discarded" = "pending",
|
status: InboxStatus = "pending",
|
||||||
): Promise<InboxItem[]> {
|
): Promise<InboxItem[]> {
|
||||||
const items = await db
|
const items = await db
|
||||||
.select()
|
.select()
|
||||||
.from(inboxItems)
|
.from(inboxItems)
|
||||||
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
|
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
|
||||||
.orderBy(desc(inboxItems.createdAt));
|
.orderBy(
|
||||||
|
desc(inboxItems.notificationTimestamp),
|
||||||
|
desc(inboxItems.createdAt),
|
||||||
|
);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchInboxItemsPage(
|
||||||
|
userId: string,
|
||||||
|
status: InboxStatus,
|
||||||
|
{
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
items: InboxItem[];
|
||||||
|
pagination: InboxPaginationState;
|
||||||
|
}> {
|
||||||
|
const [countRow] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(inboxItems)
|
||||||
|
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)));
|
||||||
|
|
||||||
|
const totalItems = Number(countRow?.total ?? 0);
|
||||||
|
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
|
||||||
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
const offset = (currentPage - 1) * pageSize;
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(inboxItems)
|
||||||
|
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
|
||||||
|
.orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pagination: {
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInboxStatusCounts(
|
||||||
|
userId: string,
|
||||||
|
): Promise<InboxStatusCounts> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
status: inboxItems.status,
|
||||||
|
total: count(),
|
||||||
|
})
|
||||||
|
.from(inboxItems)
|
||||||
|
.where(eq(inboxItems.userId, userId))
|
||||||
|
.groupBy(inboxItems.status);
|
||||||
|
|
||||||
|
const counts: InboxStatusCounts = {
|
||||||
|
pending: 0,
|
||||||
|
processed: 0,
|
||||||
|
discarded: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.status in counts) {
|
||||||
|
counts[row.status as InboxStatus] = Number(row.total ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchInboxItemById(
|
export async function fetchInboxItemById(
|
||||||
userId: string,
|
userId: string,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
@@ -112,14 +188,14 @@ export async function fetchAppLogoMap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPendingInboxCount(userId: string): Promise<number> {
|
export async function fetchPendingInboxCount(userId: string): Promise<number> {
|
||||||
const items = await db
|
const [result] = await db
|
||||||
.select({ id: inboxItems.id })
|
.select({ total: count() })
|
||||||
.from(inboxItems)
|
.from(inboxItems)
|
||||||
.where(
|
.where(
|
||||||
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
|
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
|
||||||
);
|
);
|
||||||
|
|
||||||
return items.length;
|
return Number(result?.total ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const inboxItemSchema = z.object({
|
export const inboxItemSchema = z.object({
|
||||||
sourceApp: z.string().min(1, "sourceApp é obrigatório"),
|
sourceApp: z.string().min(1, "sourceApp é obrigatório").max(255),
|
||||||
sourceAppName: z.string().optional(),
|
sourceAppName: z.string().max(255).optional(),
|
||||||
originalTitle: z.string().optional(),
|
originalTitle: z.string().max(500).optional(),
|
||||||
originalText: z.string().min(1, "originalText é obrigatório"),
|
originalText: z.string().min(1, "originalText é obrigatório").max(5000),
|
||||||
notificationTimestamp: z.string().transform((val) => new Date(val)),
|
notificationTimestamp: z
|
||||||
parsedName: z.string().optional(),
|
.string()
|
||||||
|
.transform((val) => new Date(val))
|
||||||
|
.refine((d) => !Number.isNaN(d.getTime()), "Data de notificação inválida"),
|
||||||
|
parsedName: z.string().max(500).optional(),
|
||||||
parsedAmount: z.coerce.number().optional(),
|
parsedAmount: z.coerce.number().optional(),
|
||||||
clientId: z.string().optional(), // ID local do app para rastreamento
|
clientId: z.string().max(255).optional(), // ID local do app para rastreamento
|
||||||
});
|
});
|
||||||
|
|
||||||
export const inboxBatchSchema = z.object({
|
export const inboxBatchSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user