fix(inbox): melhorar filtros e identidade visual

This commit is contained in:
Felipe Coutinho
2026-03-21 19:31:38 +00:00
parent 80de9501f6
commit d3fc81db73
7 changed files with 425 additions and 132 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -1,6 +1,7 @@
import { InboxPage } from "@/features/inbox/components/inbox-page"; import { InboxPage } from "@/features/inbox/components/inbox-page";
import { import {
type ResolvedInboxSearchParams, type ResolvedInboxSearchParams,
resolveInboxApp,
resolveInboxPagination, resolveInboxPagination,
resolveInboxStatus, resolveInboxStatus,
} from "@/features/inbox/page-helpers"; } from "@/features/inbox/page-helpers";
@@ -8,6 +9,7 @@ import {
fetchAppLogoMap, fetchAppLogoMap,
fetchInboxDialogData, fetchInboxDialogData,
fetchInboxItemsPage, fetchInboxItemsPage,
fetchInboxSourceApps,
fetchInboxStatusCounts, fetchInboxStatusCounts,
} from "@/features/inbox/queries"; } from "@/features/inbox/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
@@ -32,21 +34,31 @@ export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const activeStatus = resolveInboxStatus(resolvedSearchParams); const activeStatus = resolveInboxStatus(resolvedSearchParams);
const activeApp = resolveInboxApp(resolvedSearchParams);
const paginationInput = resolveInboxPagination(resolvedSearchParams); const paginationInput = resolveInboxPagination(resolvedSearchParams);
const [itemsPage, counts, dialogData, appLogoMap] = await Promise.all([ const [itemsPage, counts, sourceApps, dialogData, appLogoMap] =
fetchInboxItemsPage(userId, activeStatus, paginationInput), await Promise.all([
fetchInboxStatusCounts(userId), fetchInboxItemsPage(userId, activeStatus, {
activeStatus === "pending" ...paginationInput,
? fetchInboxDialogData(userId) sourceApp: activeApp,
: Promise.resolve(EMPTY_DIALOG_DATA), }),
fetchAppLogoMap(userId), fetchInboxStatusCounts(userId),
]); fetchInboxSourceApps(userId, activeStatus).catch(() => [] as string[]),
activeStatus === "pending"
? fetchInboxDialogData(userId)
: Promise.resolve(EMPTY_DIALOG_DATA),
fetchAppLogoMap(userId),
]);
const normalizedSourceApps = Array.isArray(sourceApps) ? sourceApps : [];
return ( return (
<main className="flex flex-col items-start gap-6"> <main className="flex flex-col items-start gap-6">
<InboxPage <InboxPage
activeStatus={activeStatus} activeStatus={activeStatus}
activeApp={activeApp}
sourceApps={normalizedSourceApps}
items={itemsPage.items} items={itemsPage.items}
counts={counts} counts={counts}
pagination={itemsPage.pagination} pagination={itemsPage.pagination}

View File

@@ -20,12 +20,18 @@ import {
CardTitle, CardTitle,
} from "@/shared/components/ui/card"; } from "@/shared/components/ui/card";
import { Checkbox } from "@/shared/components/ui/checkbox"; import { Checkbox } from "@/shared/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { resolveLogoSrc } from "@/shared/lib/logo"; import { resolveLogoSrc } from "@/shared/lib/logo";
import type { InboxItem } from "./types"; import type { InboxItem } from "./types";
// O timestamp vem do app Android em horário local mas salvo como UTC. // O timestamp vem do app Android em horário local mas salvo como UTC.
// Adicionamos o offset de Brasília para corrigir o cálculo de "há X tempo". // Adicionamos o offset de Brasília para corrigir o cálculo de "há X tempo".
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000; const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
function adjustToBrasilia(date: Date): Date { function adjustToBrasilia(date: Date): Date {
return new Date(date.getTime() + BRASILIA_OFFSET_MS); return new Date(date.getTime() + BRASILIA_OFFSET_MS);
@@ -78,6 +84,7 @@ export function InboxCard({
const matchedLogo = appLogoMap const matchedLogo = appLogoMap
? findMatchingLogo(item.sourceAppName, appLogoMap) ? findMatchingLogo(item.sourceAppName, appLogoMap)
: null; : null;
const displayLogo = matchedLogo ?? DEFAULT_INBOX_APP_LOGO;
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null; const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
@@ -89,6 +96,10 @@ export function InboxCard({
locale: ptBR, locale: ptBR,
}); });
const fullDate = format(notificationDate, "EEE, d 'de' MMM yyyy 'às' HH:mm", {
locale: ptBR,
});
const statusDate = const statusDate =
item.status === "processed" item.status === "processed"
? item.processedAt ? item.processedAt
@@ -107,21 +118,32 @@ export function InboxCard({
<CardHeader className="pt-4"> <CardHeader className="pt-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<CardTitle className="flex min-w-0 items-center gap-1.5 text-sm"> <CardTitle className="flex min-w-0 items-center gap-1.5 text-sm">
{matchedLogo && ( {onSelectToggle && (
<Image <Checkbox
src={matchedLogo} checked={!!selected}
alt="" onCheckedChange={() => onSelectToggle(item.id)}
width={24} aria-label="Selecionar item"
height={24} className="shrink-0"
className="shrink-0 rounded-full"
/> />
)} )}
<Image
src={displayLogo}
alt=""
width={32}
height={32}
className="shrink-0 rounded-full"
/>
<span className="truncate"> <span className="truncate">
{item.sourceAppName || item.sourceApp} {item.sourceAppName || item.sourceApp}
</span> </span>
<span className="shrink-0 text-xs font-normal text-muted-foreground"> <Tooltip>
{timeAgo} <TooltipTrigger asChild>
</span> <span className="shrink-0 cursor-default text-xs font-normal text-muted-foreground underline decoration-dotted underline-offset-2">
{timeAgo}
</span>
</TooltipTrigger>
<TooltipContent>{fullDate}</TooltipContent>
</Tooltip>
</CardTitle> </CardTitle>
{amount !== null && ( {amount !== null && (
<MoneyValues amount={amount} className="shrink-0 text-sm" /> <MoneyValues amount={amount} className="shrink-0 text-sm" />
@@ -174,13 +196,6 @@ 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>
) : ( ) : (
@@ -213,13 +228,6 @@ 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

@@ -52,7 +52,14 @@ export function InboxDetailsDialog({
<div className="grid gap-2 text-sm"> <div className="grid gap-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">App</span> <span className="text-muted-foreground">App</span>
<span>{item.sourceAppName || item.sourceApp}</span> <div className="flex flex-col items-end gap-0.5">
<span>{item.sourceAppName || item.sourceApp}</span>
{item.sourceAppName && (
<span className="font-mono text-xs text-muted-foreground">
{item.sourceApp}
</span>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -109,6 +116,11 @@ export function InboxDetailsDialog({
</div> </div>
<DialogFooter> <DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
{isPending && onProcess && ( {isPending && onProcess && (
<Button <Button
type="button" type="button"
@@ -120,11 +132,6 @@ export function InboxDetailsDialog({
Processar Processar
</Button> </Button>
)} )}
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -6,8 +6,12 @@ import {
RiArrowRightDoubleLine, RiArrowRightDoubleLine,
RiArrowRightSLine, RiArrowRightSLine,
RiAtLine, RiAtLine,
RiCalendarEventLine,
RiDeleteBinLine, RiDeleteBinLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -42,6 +46,7 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@/shared/components/ui/tabs"; } from "@/shared/components/ui/tabs";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { InboxCard } from "./inbox-card"; import { InboxCard } from "./inbox-card";
import { InboxDetailsDialog } from "./inbox-details-dialog"; import { InboxDetailsDialog } from "./inbox-details-dialog";
import type { import type {
@@ -52,8 +57,71 @@ import type {
SelectOption, SelectOption,
} from "./types"; } from "./types";
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
function getDateKey(date: Date): string {
const adjusted = new Date(date.getTime() + BRASILIA_OFFSET_MS);
return adjusted.toISOString().slice(0, 10);
}
function getGroupLabel(dateKey: string): string {
const now = new Date();
const todayKey = getDateKey(now);
const yesterdayKey = getDateKey(
new Date(now.getTime() - 24 * 60 * 60 * 1000),
);
if (dateKey === todayKey) return "Hoje";
if (dateKey === yesterdayKey) return "Ontem";
const [year, month, day] = dateKey.split("-").map(Number);
return format(new Date(year, month - 1, day), "d 'de' MMMM", {
locale: ptBR,
});
}
function groupItemsByDay(
items: InboxItem[],
): { label: string; items: InboxItem[] }[] {
const groups = new Map<string, InboxItem[]>();
for (const item of items) {
const key = getDateKey(new Date(item.notificationTimestamp));
const group = groups.get(key);
if (group) {
group.push(item);
} else {
groups.set(key, [item]);
}
}
const sortedKeys = [...groups.keys()].sort((a, b) => b.localeCompare(a));
return sortedKeys.map((key) => ({
label: getGroupLabel(key),
items: groups.get(key) ?? [],
}));
}
function findMatchingLogo(
sourceAppName: string | null,
appLogoMap: Record<string, string>,
): string | null {
if (!sourceAppName) return null;
const appName = sourceAppName.toLowerCase();
if (appLogoMap[appName]) return resolveLogoSrc(appLogoMap[appName]);
for (const [name, logo] of Object.entries(appLogoMap)) {
if (name.includes(appName) || appName.includes(name)) {
return resolveLogoSrc(logo);
}
}
return null;
}
interface InboxPageProps { interface InboxPageProps {
activeStatus: InboxStatus; activeStatus: InboxStatus;
activeApp: string | null;
sourceApps: string[];
items: InboxItem[]; items: InboxItem[];
counts: InboxStatusCounts; counts: InboxStatusCounts;
pagination: InboxPaginationState; pagination: InboxPaginationState;
@@ -69,6 +137,8 @@ interface InboxPageProps {
export function InboxPage({ export function InboxPage({
activeStatus, activeStatus,
activeApp,
sourceApps = [],
items, items,
counts, counts,
pagination, pagination,
@@ -111,6 +181,38 @@ export function InboxPage({
const [selectionBulkStatus, setSelectionBulkStatus] = const [selectionBulkStatus, setSelectionBulkStatus] =
useState<InboxStatus>("pending"); useState<InboxStatus>("pending");
const normalizedSourceApps = useMemo(() => {
if (!Array.isArray(sourceApps)) {
return [];
}
const uniqueApps = new Set<string>();
for (const app of sourceApps) {
if (typeof app !== "string") {
continue;
}
const trimmedApp = app.trim();
if (!trimmedApp) {
continue;
}
uniqueApps.add(trimmedApp);
}
return [...uniqueApps].sort((left, right) =>
left.localeCompare(right, "pt-BR"),
);
}, [sourceApps]);
const appFilterOptions =
activeApp && !normalizedSourceApps.includes(activeApp)
? [activeApp, ...normalizedSourceApps]
: normalizedSourceApps;
const getAppLogo = (appName: string | null) =>
findMatchingLogo(appName, appLogoMap) ?? DEFAULT_INBOX_APP_LOGO;
const handleProcessOpenChange = (open: boolean) => { const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open); setProcessOpen(open);
if (!open) { if (!open) {
@@ -239,7 +341,6 @@ export function InboxPage({
setSelectedIds([]); setSelectedIds([]);
return; return;
} }
setSelectedIds(items.map((item) => item.id)); setSelectedIds(items.map((item) => item.id));
}; };
@@ -276,8 +377,42 @@ export function InboxPage({
}); });
}; };
const handleAppChange = (nextApp: string) => {
const nextParams = new URLSearchParams(searchParams.toString());
if (nextApp === "all") {
nextParams.delete("app");
} else {
nextParams.set("app", nextApp);
}
nextParams.delete("page");
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
};
const handleTabChange = (nextStatus: string) => { const handleTabChange = (nextStatus: string) => {
updateUrl(nextStatus as InboxStatus, 1, pagination.pageSize); const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete("app");
if (nextStatus === "pending") {
nextParams.delete("status");
} else {
nextParams.set("status", nextStatus);
}
nextParams.delete("page");
if (pagination.pageSize === INBOX_DEFAULT_PAGE_SIZE) {
nextParams.delete("pageSize");
} else {
nextParams.set("pageSize", pagination.pageSize.toString());
}
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
}; };
const handleSelectionBulkRequest = (status: InboxStatus) => { const handleSelectionBulkRequest = (status: InboxStatus) => {
@@ -401,32 +536,105 @@ export function InboxPage({
</Card> </Card>
); );
const renderGrid = (list: InboxItem[], readonly?: boolean) => const renderGroupedGrid = (list: InboxItem[], readonly?: boolean) => {
list.length === 0 ? ( if (list.length === 0) {
renderEmptyState( if (activeApp) {
return renderEmptyState("Nenhuma notificação deste app");
}
return renderEmptyState(
readonly readonly
? "Nenhuma notificação nesta aba" ? "Nenhuma notificação nesta aba"
: "Nenhum pré-lançamento pendente", : "Nenhum pré-lançamento pendente",
) );
) : ( }
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{list.map((item) => ( const groups = groupItemsByDay(list);
<InboxCard
key={item.id} return (
item={item} <div className="space-y-6">
readonly={readonly} {groups.map((group) => (
appLogoMap={appLogoMap} <div key={group.label}>
onProcess={readonly ? undefined : handleProcessRequest} <div className="mb-3 flex items-center gap-1 text-muted-foreground">
onDiscard={readonly ? undefined : handleDiscardRequest} <RiCalendarEventLine className="size-3.5 shrink-0" />
onViewDetails={readonly ? undefined : handleDetailsRequest} <p className="text-sm font-medium">{group.label}</p>
onDelete={readonly ? handleDeleteRequest : undefined} </div>
onRestoreToPending={readonly ? handleRestoreRequest : undefined} <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
selected={selectedIds.includes(item.id)} {group.items.map((item) => (
onSelectToggle={toggleSelection} <InboxCard
/> key={item.id}
item={item}
readonly={readonly}
appLogoMap={appLogoMap}
onProcess={readonly ? undefined : handleProcessRequest}
onDiscard={readonly ? undefined : handleDiscardRequest}
onViewDetails={readonly ? undefined : handleDetailsRequest}
onDelete={readonly ? handleDeleteRequest : undefined}
onRestoreToPending={
readonly ? handleRestoreRequest : undefined
}
selected={selectedIds.includes(item.id)}
onSelectToggle={toggleSelection}
/>
))}
</div>
</div>
))} ))}
</div> </div>
); );
};
const renderAppFilter = () => {
if (appFilterOptions.length === 0) {
return null;
}
return (
<Select value={activeApp ?? "all"} onValueChange={handleAppChange}>
<SelectTrigger className="w-[190px]">
<SelectValue>
<span className="flex min-w-0 items-center gap-2">
<Image
src={activeApp ? getAppLogo(activeApp) : DEFAULT_INBOX_APP_LOGO}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span className="truncate">{activeApp ?? "Todos"}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="flex items-center gap-2">
<Image
src={DEFAULT_INBOX_APP_LOGO}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span>Todos</span>
</span>
</SelectItem>
{appFilterOptions.map((app) => (
<SelectItem key={app} value={app}>
<span className="flex min-w-0 items-center gap-2">
<Image
src={getAppLogo(app)}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span className="truncate">{app}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
return ( return (
<> <>
@@ -463,80 +671,110 @@ export function InboxPage({
</TabsList> </TabsList>
<TabsContent value="pending" className="mt-4"> <TabsContent value="pending" className="mt-4">
{activeStatus === "pending" && items.length > 0 && ( {activeStatus === "pending" &&
<div className="mb-4 flex items-center justify-end gap-2"> (appFilterOptions.length > 0 || items.length > 0) && (
<Button variant="outline" size="sm" onClick={toggleSelectAll}> <div className="mb-4 flex flex-wrap items-center gap-2">
{allSelected ? "Cancelar seleção" : "Selecionar página"} {renderAppFilter()}
</Button> {items.length > 0 ? (
{selectedIds.length > 0 && ( <div className="ml-auto flex items-center gap-2">
<Button <Button
variant="destructive" variant="outline"
size="sm" size="sm"
onClick={() => handleSelectionBulkRequest("pending")} onClick={toggleSelectAll}
> >
<RiDeleteBinLine className="mr-1.5 size-4" /> {allSelected ? "Cancelar seleção" : "Selecionar página"}
Descartar selecionados ({selectedIds.length}) </Button>
</Button> {selectedIds.length > 0 && (
)} <Button
</div> variant="destructive"
)} size="sm"
{activeStatus === "pending" ? renderGrid(items, false) : null} onClick={() => handleSelectionBulkRequest("pending")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Descartar selecionados ({selectedIds.length})
</Button>
)}
</div>
) : null}
</div>
)}
{activeStatus === "pending" ? renderGroupedGrid(items, false) : null}
</TabsContent> </TabsContent>
<TabsContent value="processed" className="mt-4"> <TabsContent value="processed" className="mt-4">
{activeStatus === "processed" && items.length > 0 && ( {activeStatus === "processed" &&
<div className="mb-4 flex items-center justify-end gap-2"> (appFilterOptions.length > 0 || items.length > 0) && (
<Button variant="outline" size="sm" onClick={toggleSelectAll}> <div className="mb-4 flex flex-wrap items-center gap-2">
{allSelected ? "Cancelar seleção" : "Selecionar página"} {renderAppFilter()}
</Button> {items.length > 0 ? (
{selectedIds.length > 0 && ( <div className="ml-auto flex items-center gap-2">
<Button <Button
variant="destructive" variant="outline"
size="sm" size="sm"
onClick={() => handleSelectionBulkRequest("processed")} onClick={toggleSelectAll}
> >
<RiDeleteBinLine className="mr-1.5 size-4" /> {allSelected ? "Cancelar seleção" : "Selecionar página"}
Excluir selecionados ({selectedIds.length}) </Button>
</Button> {selectedIds.length > 0 && (
)} <Button
<Button variant="destructive"
variant="outline" size="sm"
size="sm" onClick={() => handleSelectionBulkRequest("processed")}
onClick={() => handleBulkDeleteRequest("processed")} >
> <RiDeleteBinLine className="mr-1.5 size-4" />
<RiDeleteBinLine className="mr-1.5 size-4" /> Excluir selecionados ({selectedIds.length})
Limpar processados </Button>
</Button> )}
</div> <Button
)} variant="outline"
{activeStatus === "processed" ? renderGrid(items, true) : null} size="sm"
onClick={() => handleBulkDeleteRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar processados
</Button>
</div>
) : null}
</div>
)}
{activeStatus === "processed" ? renderGroupedGrid(items, true) : null}
</TabsContent> </TabsContent>
<TabsContent value="discarded" className="mt-4"> <TabsContent value="discarded" className="mt-4">
{activeStatus === "discarded" && items.length > 0 && ( {activeStatus === "discarded" &&
<div className="mb-4 flex items-center justify-end gap-2"> (appFilterOptions.length > 0 || items.length > 0) && (
<Button variant="outline" size="sm" onClick={toggleSelectAll}> <div className="mb-4 flex flex-wrap items-center gap-2">
{allSelected ? "Cancelar seleção" : "Selecionar página"} {renderAppFilter()}
</Button> {items.length > 0 ? (
{selectedIds.length > 0 && ( <div className="ml-auto flex items-center gap-2">
<Button <Button
variant="destructive" variant="outline"
size="sm" size="sm"
onClick={() => handleSelectionBulkRequest("discarded")} onClick={toggleSelectAll}
> >
<RiDeleteBinLine className="mr-1.5 size-4" /> {allSelected ? "Cancelar seleção" : "Selecionar página"}
Excluir selecionados ({selectedIds.length}) </Button>
</Button> {selectedIds.length > 0 && (
)} <Button
<Button variant="destructive"
variant="outline" size="sm"
size="sm" onClick={() => handleSelectionBulkRequest("discarded")}
onClick={() => handleBulkDeleteRequest("discarded")} >
> <RiDeleteBinLine className="mr-1.5 size-4" />
<RiDeleteBinLine className="mr-1.5 size-4" /> Excluir selecionados ({selectedIds.length})
Limpar descartados </Button>
</Button> )}
</div> <Button
)} variant="outline"
{activeStatus === "discarded" ? renderGrid(items, true) : null} size="sm"
onClick={() => handleBulkDeleteRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar descartados
</Button>
</div>
) : null}
</div>
)}
{activeStatus === "discarded" ? renderGroupedGrid(items, true) : null}
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -31,6 +31,10 @@ export const resolveInboxStatus = (
: "pending"; : "pending";
}; };
export const resolveInboxApp = (
params: ResolvedInboxSearchParams,
): string | null => getSingleParam(params, "app");
export const resolveInboxPagination = ( export const resolveInboxPagination = (
params: ResolvedInboxSearchParams, params: ResolvedInboxSearchParams,
): Pick<InboxPaginationState, "page" | "pageSize"> => { ): Pick<InboxPaginationState, "page" | "pageSize"> => {

View File

@@ -39,18 +39,26 @@ export async function fetchInboxItemsPage(
{ {
page, page,
pageSize, pageSize,
sourceApp,
}: { }: {
page: number; page: number;
pageSize: number; pageSize: number;
sourceApp?: string | null;
}, },
): Promise<{ ): Promise<{
items: InboxItem[]; items: InboxItem[];
pagination: InboxPaginationState; pagination: InboxPaginationState;
}> { }> {
const where = and(
eq(inboxItems.userId, userId),
eq(inboxItems.status, status),
sourceApp ? eq(inboxItems.sourceAppName, sourceApp) : undefined,
);
const [countRow] = await db const [countRow] = await db
.select({ total: count() }) .select({ total: count() })
.from(inboxItems) .from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))); .where(where);
const totalItems = Number(countRow?.total ?? 0); const totalItems = Number(countRow?.total ?? 0);
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1); const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
@@ -60,7 +68,7 @@ export async function fetchInboxItemsPage(
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(where)
.orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt)) .orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt))
.limit(pageSize) .limit(pageSize)
.offset(offset); .offset(offset);
@@ -76,6 +84,22 @@ export async function fetchInboxItemsPage(
}; };
} }
export async function fetchInboxSourceApps(
userId: string,
status: InboxStatus,
): Promise<string[]> {
const rows = await db
.select({ name: inboxItems.sourceAppName })
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)));
const seen = new Set<string>();
for (const row of rows) {
if (row.name) seen.add(row.name);
}
return [...seen].sort();
}
export async function fetchInboxStatusCounts( export async function fetchInboxStatusCounts(
userId: string, userId: string,
): Promise<InboxStatusCounts> { ): Promise<InboxStatusCounts> {