mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
fix(inbox): melhorar filtros e identidade visual
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -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([
|
||||||
|
fetchInboxItemsPage(userId, activeStatus, {
|
||||||
|
...paginationInput,
|
||||||
|
sourceApp: activeApp,
|
||||||
|
}),
|
||||||
fetchInboxStatusCounts(userId),
|
fetchInboxStatusCounts(userId),
|
||||||
|
fetchInboxSourceApps(userId, activeStatus).catch(() => [] as string[]),
|
||||||
activeStatus === "pending"
|
activeStatus === "pending"
|
||||||
? fetchInboxDialogData(userId)
|
? fetchInboxDialogData(userId)
|
||||||
: Promise.resolve(EMPTY_DIALOG_DATA),
|
: Promise.resolve(EMPTY_DIALOG_DATA),
|
||||||
fetchAppLogoMap(userId),
|
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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="shrink-0 cursor-default text-xs font-normal text-muted-foreground underline decoration-dotted underline-offset-2">
|
||||||
{timeAgo}
|
{timeAgo}
|
||||||
</span>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div className="flex flex-col items-end gap-0.5">
|
||||||
<span>{item.sourceAppName || item.sourceApp}</span>
|
<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>
|
||||||
|
|||||||
@@ -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,16 +536,30 @@ 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",
|
||||||
)
|
);
|
||||||
) : (
|
}
|
||||||
|
|
||||||
|
const groups = groupItemsByDay(list);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<div className="mb-3 flex items-center gap-1 text-muted-foreground">
|
||||||
|
<RiCalendarEventLine className="size-3.5 shrink-0" />
|
||||||
|
<p className="text-sm font-medium">{group.label}</p>
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{list.map((item) => (
|
{group.items.map((item) => (
|
||||||
<InboxCard
|
<InboxCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -420,13 +569,72 @@ export function InboxPage({
|
|||||||
onDiscard={readonly ? undefined : handleDiscardRequest}
|
onDiscard={readonly ? undefined : handleDiscardRequest}
|
||||||
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={toggleSelection}
|
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,9 +671,17 @@ 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">
|
||||||
|
{renderAppFilter()}
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
>
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
</Button>
|
</Button>
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
@@ -479,13 +695,23 @@ export function InboxPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeStatus === "pending" ? renderGrid(items, false) : null}
|
{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">
|
||||||
|
{renderAppFilter()}
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
>
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
</Button>
|
</Button>
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
@@ -507,13 +733,23 @@ export function InboxPage({
|
|||||||
Limpar processados
|
Limpar processados
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeStatus === "processed" ? renderGrid(items, true) : null}
|
{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">
|
||||||
|
{renderAppFilter()}
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
>
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
</Button>
|
</Button>
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
@@ -535,8 +771,10 @@ export function InboxPage({
|
|||||||
Limpar descartados
|
Limpar descartados
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeStatus === "discarded" ? renderGrid(items, true) : null}
|
{activeStatus === "discarded" ? renderGroupedGrid(items, true) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -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"> => {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user