diff --git a/public/avatars/default_icon.png b/public/avatars/default_icon.png index 87cb80e..45ae426 100644 Binary files a/public/avatars/default_icon.png and b/public/avatars/default_icon.png differ diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index 47e9c6a..644c721 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -1,6 +1,7 @@ import { InboxPage } from "@/features/inbox/components/inbox-page"; import { type ResolvedInboxSearchParams, + resolveInboxApp, resolveInboxPagination, resolveInboxStatus, } from "@/features/inbox/page-helpers"; @@ -8,6 +9,7 @@ import { fetchAppLogoMap, fetchInboxDialogData, fetchInboxItemsPage, + fetchInboxSourceApps, fetchInboxStatusCounts, } from "@/features/inbox/queries"; import { getUserId } from "@/shared/lib/auth/server"; @@ -32,21 +34,31 @@ export default async function Page({ searchParams }: PageProps) { const userId = await getUserId(); const resolvedSearchParams = searchParams ? await searchParams : undefined; const activeStatus = resolveInboxStatus(resolvedSearchParams); + const activeApp = resolveInboxApp(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), - ]); + const [itemsPage, counts, sourceApps, dialogData, appLogoMap] = + await Promise.all([ + fetchInboxItemsPage(userId, activeStatus, { + ...paginationInput, + sourceApp: activeApp, + }), + 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 (
- {matchedLogo && ( - onSelectToggle(item.id)} + aria-label="Selecionar item" + className="shrink-0" /> )} + {item.sourceAppName || item.sourceApp} - - {timeAgo} - + + + + {timeAgo} + + + {fullDate} + {amount !== null && ( @@ -174,13 +196,6 @@ export function InboxCard({ )} - {onSelectToggle && ( - onSelectToggle(item.id)} - aria-label="Selecionar item" - /> - )}
) : ( @@ -213,13 +228,6 @@ export function InboxCard({ > - {onSelectToggle && ( - onSelectToggle(item.id)} - aria-label="Selecionar item" - /> - )} )} diff --git a/src/features/inbox/components/inbox-details-dialog.tsx b/src/features/inbox/components/inbox-details-dialog.tsx index c712118..b7ae91a 100644 --- a/src/features/inbox/components/inbox-details-dialog.tsx +++ b/src/features/inbox/components/inbox-details-dialog.tsx @@ -52,7 +52,14 @@ export function InboxDetailsDialog({
App - {item.sourceAppName || item.sourceApp} +
+ {item.sourceAppName || item.sourceApp} + {item.sourceAppName && ( + + {item.sourceApp} + + )} +
@@ -109,6 +116,11 @@ export function InboxDetailsDialog({ + + + {isPending && onProcess && ( - diff --git a/src/features/inbox/components/inbox-page.tsx b/src/features/inbox/components/inbox-page.tsx index d2c3339..dd02bdb 100644 --- a/src/features/inbox/components/inbox-page.tsx +++ b/src/features/inbox/components/inbox-page.tsx @@ -6,8 +6,12 @@ import { RiArrowRightDoubleLine, RiArrowRightSLine, RiAtLine, + RiCalendarEventLine, RiDeleteBinLine, } 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 { useEffect, useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -42,6 +46,7 @@ import { TabsList, TabsTrigger, } from "@/shared/components/ui/tabs"; +import { resolveLogoSrc } from "@/shared/lib/logo"; import { InboxCard } from "./inbox-card"; import { InboxDetailsDialog } from "./inbox-details-dialog"; import type { @@ -52,8 +57,71 @@ import type { SelectOption, } 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(); + 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 | 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 { activeStatus: InboxStatus; + activeApp: string | null; + sourceApps: string[]; items: InboxItem[]; counts: InboxStatusCounts; pagination: InboxPaginationState; @@ -69,6 +137,8 @@ interface InboxPageProps { export function InboxPage({ activeStatus, + activeApp, + sourceApps = [], items, counts, pagination, @@ -111,6 +181,38 @@ export function InboxPage({ const [selectionBulkStatus, setSelectionBulkStatus] = useState("pending"); + const normalizedSourceApps = useMemo(() => { + if (!Array.isArray(sourceApps)) { + return []; + } + + const uniqueApps = new Set(); + 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) => { setProcessOpen(open); if (!open) { @@ -239,7 +341,6 @@ export function InboxPage({ setSelectedIds([]); return; } - 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) => { - 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) => { @@ -401,32 +536,105 @@ export function InboxPage({ ); - const renderGrid = (list: InboxItem[], readonly?: boolean) => - list.length === 0 ? ( - renderEmptyState( + const renderGroupedGrid = (list: InboxItem[], readonly?: boolean) => { + if (list.length === 0) { + if (activeApp) { + return renderEmptyState("Nenhuma notificação deste app"); + } + return renderEmptyState( readonly ? "Nenhuma notificação nesta aba" : "Nenhum pré-lançamento pendente", - ) - ) : ( -
- {list.map((item) => ( - + ); + } + + const groups = groupItemsByDay(list); + + return ( +
+ {groups.map((group) => ( +
+
+ +

{group.label}

+
+
+ {group.items.map((item) => ( + + ))} +
+
))}
); + }; + + const renderAppFilter = () => { + if (appFilterOptions.length === 0) { + return null; + } + + return ( + + ); + }; return ( <> @@ -463,80 +671,110 @@ export function InboxPage({ - {activeStatus === "pending" && items.length > 0 && ( -
- - {selectedIds.length > 0 && ( - - )} -
- )} - {activeStatus === "pending" ? renderGrid(items, false) : null} + {activeStatus === "pending" && + (appFilterOptions.length > 0 || items.length > 0) && ( +
+ {renderAppFilter()} + {items.length > 0 ? ( +
+ + {selectedIds.length > 0 && ( + + )} +
+ ) : null} +
+ )} + {activeStatus === "pending" ? renderGroupedGrid(items, false) : null}
- {activeStatus === "processed" && items.length > 0 && ( -
- - {selectedIds.length > 0 && ( - - )} - -
- )} - {activeStatus === "processed" ? renderGrid(items, true) : null} + {activeStatus === "processed" && + (appFilterOptions.length > 0 || items.length > 0) && ( +
+ {renderAppFilter()} + {items.length > 0 ? ( +
+ + {selectedIds.length > 0 && ( + + )} + +
+ ) : null} +
+ )} + {activeStatus === "processed" ? renderGroupedGrid(items, true) : null}
- {activeStatus === "discarded" && items.length > 0 && ( -
- - {selectedIds.length > 0 && ( - - )} - -
- )} - {activeStatus === "discarded" ? renderGrid(items, true) : null} + {activeStatus === "discarded" && + (appFilterOptions.length > 0 || items.length > 0) && ( +
+ {renderAppFilter()} + {items.length > 0 ? ( +
+ + {selectedIds.length > 0 && ( + + )} + +
+ ) : null} +
+ )} + {activeStatus === "discarded" ? renderGroupedGrid(items, true) : null}
diff --git a/src/features/inbox/page-helpers.ts b/src/features/inbox/page-helpers.ts index 2f23f81..592a14e 100644 --- a/src/features/inbox/page-helpers.ts +++ b/src/features/inbox/page-helpers.ts @@ -31,6 +31,10 @@ export const resolveInboxStatus = ( : "pending"; }; +export const resolveInboxApp = ( + params: ResolvedInboxSearchParams, +): string | null => getSingleParam(params, "app"); + export const resolveInboxPagination = ( params: ResolvedInboxSearchParams, ): Pick => { diff --git a/src/features/inbox/queries.ts b/src/features/inbox/queries.ts index 4cbc3dc..a4fb5cb 100644 --- a/src/features/inbox/queries.ts +++ b/src/features/inbox/queries.ts @@ -39,18 +39,26 @@ export async function fetchInboxItemsPage( { page, pageSize, + sourceApp, }: { page: number; pageSize: number; + sourceApp?: string | null; }, ): Promise<{ items: InboxItem[]; pagination: InboxPaginationState; }> { + const where = and( + eq(inboxItems.userId, userId), + eq(inboxItems.status, status), + sourceApp ? eq(inboxItems.sourceAppName, sourceApp) : undefined, + ); + const [countRow] = await db .select({ total: count() }) .from(inboxItems) - .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))); + .where(where); const totalItems = Number(countRow?.total ?? 0); const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1); @@ -60,7 +68,7 @@ export async function fetchInboxItemsPage( const items = await db .select() .from(inboxItems) - .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))) + .where(where) .orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt)) .limit(pageSize) .offset(offset); @@ -76,6 +84,22 @@ export async function fetchInboxItemsPage( }; } +export async function fetchInboxSourceApps( + userId: string, + status: InboxStatus, +): Promise { + const rows = await db + .select({ name: inboxItems.sourceAppName }) + .from(inboxItems) + .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))); + + const seen = new Set(); + for (const row of rows) { + if (row.name) seen.add(row.name); + } + return [...seen].sort(); +} + export async function fetchInboxStatusCounts( userId: string, ): Promise {