mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 03:11:46 +00:00
refactor(lista): componentizar inbox e tabela de lançamentos
This commit is contained in:
153
src/features/inbox/components/inbox-bulk-actions.tsx
Normal file
153
src/features/inbox/components/inbox-bulk-actions.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { RiDeleteBinLine } from "@remixicon/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
|
import type { InboxItem, InboxStatus } from "./types";
|
||||||
|
|
||||||
|
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboxBulkActionsProps = {
|
||||||
|
status: InboxStatus;
|
||||||
|
items: InboxItem[];
|
||||||
|
activeApp: string | null;
|
||||||
|
appFilterOptions: string[];
|
||||||
|
selectedIds: string[];
|
||||||
|
allSelected: boolean;
|
||||||
|
appLogoMap: Record<string, string>;
|
||||||
|
onAppChange: (app: string) => void;
|
||||||
|
onToggleSelectAll: () => void;
|
||||||
|
onSelectionBulkRequest: (status: InboxStatus) => void;
|
||||||
|
onBulkDeleteRequest: (status: "processed" | "discarded") => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InboxBulkActions({
|
||||||
|
status,
|
||||||
|
items,
|
||||||
|
activeApp,
|
||||||
|
appFilterOptions,
|
||||||
|
selectedIds,
|
||||||
|
allSelected,
|
||||||
|
appLogoMap,
|
||||||
|
onAppChange,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onSelectionBulkRequest,
|
||||||
|
onBulkDeleteRequest,
|
||||||
|
}: InboxBulkActionsProps) {
|
||||||
|
const getAppLogo = (appName: string | null) =>
|
||||||
|
findMatchingLogo(appName, appLogoMap) ?? DEFAULT_INBOX_APP_LOGO;
|
||||||
|
|
||||||
|
const appFilter =
|
||||||
|
appFilterOptions.length > 0 ? (
|
||||||
|
<Select value={activeApp ?? "all"} onValueChange={onAppChange}>
|
||||||
|
<SelectTrigger className="w-[190px]">
|
||||||
|
<SelectValue>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<div className="relative size-5 shrink-0 overflow-hidden rounded-full">
|
||||||
|
<Image
|
||||||
|
src={
|
||||||
|
activeApp ? getAppLogo(activeApp) : DEFAULT_INBOX_APP_LOGO
|
||||||
|
}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="20px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="truncate">{activeApp ?? "Todos"}</span>
|
||||||
|
</span>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<div className="relative size-5 shrink-0 overflow-hidden rounded-full">
|
||||||
|
<Image
|
||||||
|
src={DEFAULT_INBOX_APP_LOGO}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="20px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>Todos</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{appFilterOptions.map((app) => (
|
||||||
|
<SelectItem key={app} value={app}>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<div className="relative size-5 shrink-0 overflow-hidden rounded-full">
|
||||||
|
<Image
|
||||||
|
src={getAppLogo(app)}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="20px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="truncate">{app}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
{appFilter}
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onToggleSelectAll}>
|
||||||
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
|
</Button>
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSelectionBulkRequest(status)}
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="mr-1.5 size-4" />
|
||||||
|
{status === "pending"
|
||||||
|
? `Descartar selecionados (${selectedIds.length})`
|
||||||
|
: `Excluir selecionados (${selectedIds.length})`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(status === "processed" || status === "discarded") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onBulkDeleteRequest(status)}
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="mr-1.5 size-4" />
|
||||||
|
{status === "processed"
|
||||||
|
? "Limpar processados"
|
||||||
|
: "Limpar descartados"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -117,13 +117,15 @@ export const InboxCard = memo(function InboxCard({
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Image
|
<div className="relative size-8 shrink-0 overflow-hidden rounded-full">
|
||||||
src={displayLogo}
|
<Image
|
||||||
alt=""
|
src={displayLogo}
|
||||||
width={32}
|
alt=""
|
||||||
height={32}
|
fill
|
||||||
className="shrink-0 rounded-full"
|
sizes="32px"
|
||||||
/>
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{item.sourceAppName || item.sourceApp}
|
{item.sourceAppName || item.sourceApp}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
133
src/features/inbox/components/inbox-items-list.tsx
Normal file
133
src/features/inbox/components/inbox-items-list.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { RiAtLine, RiCalendarEventLine } from "@remixicon/react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import { EmptyState } from "@/shared/components/empty-state";
|
||||||
|
import { Card } from "@/shared/components/ui/card";
|
||||||
|
import { InboxCard } from "./inbox-card";
|
||||||
|
import type { InboxItem } from "./types";
|
||||||
|
|
||||||
|
// O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
|
||||||
|
// Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
|
||||||
|
// Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
|
||||||
|
function getItemDateKey(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
|
||||||
|
function getBrasiliaDateKey(date: Date): string {
|
||||||
|
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||||
|
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupLabel(dateKey: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const todayKey = getBrasiliaDateKey(now);
|
||||||
|
const yesterdayKey = getBrasiliaDateKey(
|
||||||
|
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) - 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 = getItemDateKey(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) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboxItemsListProps = {
|
||||||
|
items: InboxItem[];
|
||||||
|
readonly?: boolean;
|
||||||
|
activeApp: string | null;
|
||||||
|
appLogoMap: Record<string, string>;
|
||||||
|
selectedIds: string[];
|
||||||
|
onProcess?: (item: InboxItem) => void;
|
||||||
|
onDiscard?: (item: InboxItem) => void;
|
||||||
|
onViewDetails?: (item: InboxItem) => void;
|
||||||
|
onDelete?: (item: InboxItem) => void;
|
||||||
|
onRestoreToPending?: (item: InboxItem) => void;
|
||||||
|
onSelectToggle: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InboxItemsList({
|
||||||
|
items,
|
||||||
|
readonly,
|
||||||
|
activeApp,
|
||||||
|
appLogoMap,
|
||||||
|
selectedIds,
|
||||||
|
onProcess,
|
||||||
|
onDiscard,
|
||||||
|
onViewDetails,
|
||||||
|
onDelete,
|
||||||
|
onRestoreToPending,
|
||||||
|
onSelectToggle,
|
||||||
|
}: InboxItemsListProps) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
const message = activeApp
|
||||||
|
? "Nenhuma notificação deste app"
|
||||||
|
: readonly
|
||||||
|
? "Nenhuma notificação nesta aba"
|
||||||
|
: "Nenhum pré-lançamento pendente";
|
||||||
|
return (
|
||||||
|
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||||
|
<EmptyState
|
||||||
|
media={<RiAtLine className="size-6 text-primary" />}
|
||||||
|
title={message}
|
||||||
|
description="As notificações capturadas pelo app OpenMonetis Companion aparecerão aqui. Saiba mais em Ajustes > Companion."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupItemsByDay(items);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<InboxCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
readonly={readonly}
|
||||||
|
appLogoMap={appLogoMap}
|
||||||
|
onProcess={readonly ? undefined : onProcess}
|
||||||
|
onDiscard={readonly ? undefined : onDiscard}
|
||||||
|
onViewDetails={readonly ? undefined : onViewDetails}
|
||||||
|
onDelete={readonly ? onDelete : undefined}
|
||||||
|
onRestoreToPending={readonly ? onRestoreToPending : undefined}
|
||||||
|
selected={selectedIds.includes(item.id)}
|
||||||
|
onSelectToggle={onSelectToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
RiArrowLeftDoubleLine,
|
|
||||||
RiArrowLeftSLine,
|
|
||||||
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 { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -30,31 +18,15 @@ import {
|
|||||||
markInboxAsProcessedAction,
|
markInboxAsProcessedAction,
|
||||||
restoreDiscardedInboxItemAction,
|
restoreDiscardedInboxItemAction,
|
||||||
} from "@/features/inbox/actions";
|
} from "@/features/inbox/actions";
|
||||||
import {
|
import { INBOX_DEFAULT_PAGE_SIZE } from "@/features/inbox/page-helpers";
|
||||||
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 { Tabs, TabsContent } from "@/shared/components/ui/tabs";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { InboxBulkActions } from "./inbox-bulk-actions";
|
||||||
import { Card } from "@/shared/components/ui/card";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select";
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
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 { InboxDetailsDialog } from "./inbox-details-dialog";
|
||||||
|
import { InboxItemsList } from "./inbox-items-list";
|
||||||
|
import { InboxPagination } from "./inbox-pagination";
|
||||||
|
import { InboxTabs } from "./inbox-tabs";
|
||||||
import type {
|
import type {
|
||||||
InboxItem,
|
InboxItem,
|
||||||
InboxPaginationState,
|
InboxPaginationState,
|
||||||
@@ -63,76 +35,6 @@ import type {
|
|||||||
SelectOption,
|
SelectOption,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
|
|
||||||
|
|
||||||
// O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
|
|
||||||
// Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
|
|
||||||
// Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
|
|
||||||
function getItemDateKey(date: Date): string {
|
|
||||||
return date.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
|
|
||||||
function getBrasiliaDateKey(date: Date): string {
|
|
||||||
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
|
|
||||||
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGroupLabel(dateKey: string): string {
|
|
||||||
const now = new Date();
|
|
||||||
const todayKey = getBrasiliaDateKey(now);
|
|
||||||
const yesterdayKey = getBrasiliaDateKey(
|
|
||||||
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 = getItemDateKey(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;
|
activeApp: string | null;
|
||||||
@@ -197,24 +99,14 @@ export function InboxPage({
|
|||||||
useState<InboxStatus>("pending");
|
useState<InboxStatus>("pending");
|
||||||
|
|
||||||
const normalizedSourceApps = useMemo(() => {
|
const normalizedSourceApps = useMemo(() => {
|
||||||
if (!Array.isArray(sourceApps)) {
|
if (!Array.isArray(sourceApps)) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueApps = new Set<string>();
|
const uniqueApps = new Set<string>();
|
||||||
for (const app of sourceApps) {
|
for (const app of sourceApps) {
|
||||||
if (typeof app !== "string") {
|
if (typeof app !== "string") continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedApp = app.trim();
|
const trimmedApp = app.trim();
|
||||||
if (!trimmedApp) {
|
if (!trimmedApp) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
uniqueApps.add(trimmedApp);
|
uniqueApps.add(trimmedApp);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...uniqueApps].sort((left, right) =>
|
return [...uniqueApps].sort((left, right) =>
|
||||||
left.localeCompare(right, "pt-BR"),
|
left.localeCompare(right, "pt-BR"),
|
||||||
);
|
);
|
||||||
@@ -225,28 +117,19 @@ export function InboxPage({
|
|||||||
? [activeApp, ...normalizedSourceApps]
|
? [activeApp, ...normalizedSourceApps]
|
||||||
: 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) setItemToProcess(null);
|
||||||
setItemToProcess(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDetailsOpenChange = (open: boolean) => {
|
const handleDetailsOpenChange = (open: boolean) => {
|
||||||
setDetailsOpen(open);
|
setDetailsOpen(open);
|
||||||
if (!open) {
|
if (!open) setItemDetails(null);
|
||||||
setItemDetails(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscardOpenChange = (open: boolean) => {
|
const handleDiscardOpenChange = (open: boolean) => {
|
||||||
setDiscardOpen(open);
|
setDiscardOpen(open);
|
||||||
if (!open) {
|
if (!open) setItemToDiscard(null);
|
||||||
setItemToDiscard(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProcessRequest = useCallback((item: InboxItem) => {
|
const handleProcessRequest = useCallback((item: InboxItem) => {
|
||||||
@@ -266,25 +149,20 @@ export function InboxPage({
|
|||||||
|
|
||||||
const handleDiscardConfirm = async () => {
|
const handleDiscardConfirm = async () => {
|
||||||
if (!itemToDiscard) return;
|
if (!itemToDiscard) return;
|
||||||
|
|
||||||
const result = await discardInboxItemAction({
|
const result = await discardInboxItemAction({
|
||||||
inboxItemId: itemToDiscard.id,
|
inboxItemId: itemToDiscard.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteOpenChange = (open: boolean) => {
|
const handleDeleteOpenChange = (open: boolean) => {
|
||||||
setDeleteOpen(open);
|
setDeleteOpen(open);
|
||||||
if (!open) {
|
if (!open) setItemToDelete(null);
|
||||||
setItemToDelete(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRequest = useCallback((item: InboxItem) => {
|
const handleDeleteRequest = useCallback((item: InboxItem) => {
|
||||||
@@ -294,25 +172,20 @@ export function InboxPage({
|
|||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!itemToDelete) return;
|
if (!itemToDelete) return;
|
||||||
|
|
||||||
const result = await deleteInboxItemAction({
|
const result = await deleteInboxItemAction({
|
||||||
inboxItemId: itemToDelete.id,
|
inboxItemId: itemToDelete.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreOpenChange = (open: boolean) => {
|
const handleRestoreOpenChange = (open: boolean) => {
|
||||||
setRestoreOpen(open);
|
setRestoreOpen(open);
|
||||||
if (!open) {
|
if (!open) setItemToRestore(null);
|
||||||
setItemToRestore(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreRequest = useCallback((item: InboxItem) => {
|
const handleRestoreRequest = useCallback((item: InboxItem) => {
|
||||||
@@ -322,16 +195,13 @@ export function InboxPage({
|
|||||||
|
|
||||||
const handleRestoreToPendingConfirm = async () => {
|
const handleRestoreToPendingConfirm = async () => {
|
||||||
if (!itemToRestore) return;
|
if (!itemToRestore) return;
|
||||||
|
|
||||||
const result = await restoreDiscardedInboxItemAction({
|
const result = await restoreDiscardedInboxItemAction({
|
||||||
inboxItemId: itemToRestore.id,
|
inboxItemId: itemToRestore.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
};
|
};
|
||||||
@@ -365,25 +235,21 @@ export function InboxPage({
|
|||||||
nextPageSize: number,
|
nextPageSize: number,
|
||||||
) => {
|
) => {
|
||||||
const nextParams = new URLSearchParams(searchParams.toString());
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
if (nextStatus === "pending") {
|
if (nextStatus === "pending") {
|
||||||
nextParams.delete("status");
|
nextParams.delete("status");
|
||||||
} else {
|
} else {
|
||||||
nextParams.set("status", nextStatus);
|
nextParams.set("status", nextStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextPage <= 1) {
|
if (nextPage <= 1) {
|
||||||
nextParams.delete("page");
|
nextParams.delete("page");
|
||||||
} else {
|
} else {
|
||||||
nextParams.set("page", nextPage.toString());
|
nextParams.set("page", nextPage.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextPageSize === INBOX_DEFAULT_PAGE_SIZE) {
|
if (nextPageSize === INBOX_DEFAULT_PAGE_SIZE) {
|
||||||
nextParams.delete("pageSize");
|
nextParams.delete("pageSize");
|
||||||
} else {
|
} else {
|
||||||
nextParams.set("pageSize", nextPageSize.toString());
|
nextParams.set("pageSize", nextPageSize.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
const target = nextParams.toString()
|
const target = nextParams.toString()
|
||||||
? `${pathname}?${nextParams.toString()}`
|
? `${pathname}?${nextParams.toString()}`
|
||||||
@@ -431,10 +297,7 @@ export function InboxPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectionBulkRequest = (status: InboxStatus) => {
|
const handleSelectionBulkRequest = (status: InboxStatus) => {
|
||||||
if (selectedIds.length === 0) {
|
if (selectedIds.length === 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectionBulkStatus(status);
|
setSelectionBulkStatus(status);
|
||||||
setSelectionBulkOpen(true);
|
setSelectionBulkOpen(true);
|
||||||
};
|
};
|
||||||
@@ -465,10 +328,6 @@ export function InboxPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkDeleteOpenChange = (open: boolean) => {
|
|
||||||
setBulkDeleteOpen(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
|
const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
|
||||||
setBulkDeleteStatus(status);
|
setBulkDeleteStatus(status);
|
||||||
setBulkDeleteOpen(true);
|
setBulkDeleteOpen(true);
|
||||||
@@ -478,23 +337,19 @@ export function InboxPage({
|
|||||||
const result = await bulkDeleteInboxItemsAction({
|
const result = await bulkDeleteInboxItemsAction({
|
||||||
status: bulkDeleteStatus,
|
status: bulkDeleteStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLancamentoSuccess = async () => {
|
const handleLancamentoSuccess = async () => {
|
||||||
if (!itemToProcess) return;
|
if (!itemToProcess) return;
|
||||||
|
|
||||||
const result = await markInboxAsProcessedAction({
|
const result = await markInboxAsProcessedAction({
|
||||||
inboxItemId: itemToProcess.id,
|
inboxItemId: itemToProcess.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Notificação processada!");
|
toast.success("Notificação processada!");
|
||||||
} else {
|
} else {
|
||||||
@@ -502,10 +357,6 @@ export function InboxPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canPreviousPage = pagination.page > 1;
|
|
||||||
const canNextPage = pagination.page < pagination.totalPages;
|
|
||||||
|
|
||||||
// Prepare default values from inbox item
|
|
||||||
const getDateString = (
|
const getDateString = (
|
||||||
date: Date | string | null | undefined,
|
date: Date | string | null | undefined,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
@@ -516,140 +367,29 @@ export function InboxPage({
|
|||||||
|
|
||||||
const defaultPurchaseDate =
|
const defaultPurchaseDate =
|
||||||
getDateString(itemToProcess?.notificationTimestamp) ?? null;
|
getDateString(itemToProcess?.notificationTimestamp) ?? null;
|
||||||
|
|
||||||
const defaultName = itemToProcess?.parsedName
|
const defaultName = itemToProcess?.parsedName
|
||||||
? itemToProcess.parsedName
|
? itemToProcess.parsedName
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const defaultAmount = itemToProcess?.parsedAmount
|
const defaultAmount = itemToProcess?.parsedAmount
|
||||||
? String(Math.abs(Number(itemToProcess.parsedAmount)))
|
? String(Math.abs(Number(itemToProcess.parsedAmount)))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Match sourceAppName with a cartão to pre-fill card select
|
|
||||||
const matchedCartaoId = useMemo(() => {
|
const matchedCartaoId = useMemo(() => {
|
||||||
const appName = itemToProcess?.sourceAppName?.toLowerCase();
|
const appName = itemToProcess?.sourceAppName?.toLowerCase();
|
||||||
if (!appName) return null;
|
if (!appName) return null;
|
||||||
|
|
||||||
for (const option of cardOptions) {
|
for (const option of cardOptions) {
|
||||||
const label = option.label.toLowerCase();
|
const label = option.label.toLowerCase();
|
||||||
if (label.includes(appName) || appName.includes(label)) {
|
if (label.includes(appName) || appName.includes(label))
|
||||||
return option.value;
|
return option.value;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [itemToProcess?.sourceAppName, cardOptions]);
|
}, [itemToProcess?.sourceAppName, cardOptions]);
|
||||||
|
|
||||||
const renderEmptyState = (message: string) => (
|
const showTabActions = (status: InboxStatus) =>
|
||||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
activeStatus === status &&
|
||||||
<EmptyState
|
(appFilterOptions.length > 0 || items.length > 0);
|
||||||
media={<RiAtLine className="size-6 text-primary" />}
|
|
||||||
title={message}
|
|
||||||
description="As notificações capturadas pelo app OpenMonetis Companion aparecerão aqui. Saiba mais em Ajustes > Companion."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
|
||||||
{group.items.map((item) => (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
||||||
<>
|
<>
|
||||||
@@ -658,229 +398,106 @@ export function InboxPage({
|
|||||||
onValueChange={handleTabChange}
|
onValueChange={handleTabChange}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
|
<InboxTabs counts={counts} isPending={isPending} />
|
||||||
<TabsTrigger
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span>Pendentes</span>
|
|
||||||
<span>({counts.pending})</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span>Processados</span>
|
|
||||||
<span>({counts.processed})</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span>Descartados</span>
|
|
||||||
<span>({counts.discarded})</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="pending" className="mt-4">
|
<TabsContent value="pending" className="mt-4">
|
||||||
{activeStatus === "pending" &&
|
{showTabActions("pending") && (
|
||||||
(appFilterOptions.length > 0 || items.length > 0) && (
|
<InboxBulkActions
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
status="pending"
|
||||||
{renderAppFilter()}
|
items={items}
|
||||||
{items.length > 0 ? (
|
activeApp={activeApp}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
appFilterOptions={appFilterOptions}
|
||||||
<Button
|
selectedIds={selectedIds}
|
||||||
variant="outline"
|
allSelected={allSelected}
|
||||||
size="sm"
|
appLogoMap={appLogoMap}
|
||||||
onClick={toggleSelectAll}
|
onAppChange={handleAppChange}
|
||||||
>
|
onToggleSelectAll={toggleSelectAll}
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
onSelectionBulkRequest={handleSelectionBulkRequest}
|
||||||
</Button>
|
onBulkDeleteRequest={handleBulkDeleteRequest}
|
||||||
{selectedIds.length > 0 && (
|
/>
|
||||||
<Button
|
)}
|
||||||
variant="destructive"
|
{activeStatus === "pending" && (
|
||||||
size="sm"
|
<InboxItemsList
|
||||||
onClick={() => handleSelectionBulkRequest("pending")}
|
items={items}
|
||||||
>
|
readonly={false}
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
activeApp={activeApp}
|
||||||
Descartar selecionados ({selectedIds.length})
|
appLogoMap={appLogoMap}
|
||||||
</Button>
|
selectedIds={selectedIds}
|
||||||
)}
|
onProcess={handleProcessRequest}
|
||||||
</div>
|
onDiscard={handleDiscardRequest}
|
||||||
) : null}
|
onViewDetails={handleDetailsRequest}
|
||||||
</div>
|
onSelectToggle={toggleSelection}
|
||||||
)}
|
/>
|
||||||
{activeStatus === "pending" ? renderGroupedGrid(items, false) : null}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="processed" className="mt-4">
|
<TabsContent value="processed" className="mt-4">
|
||||||
{activeStatus === "processed" &&
|
{showTabActions("processed") && (
|
||||||
(appFilterOptions.length > 0 || items.length > 0) && (
|
<InboxBulkActions
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
status="processed"
|
||||||
{renderAppFilter()}
|
items={items}
|
||||||
{items.length > 0 ? (
|
activeApp={activeApp}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
appFilterOptions={appFilterOptions}
|
||||||
<Button
|
selectedIds={selectedIds}
|
||||||
variant="outline"
|
allSelected={allSelected}
|
||||||
size="sm"
|
appLogoMap={appLogoMap}
|
||||||
onClick={toggleSelectAll}
|
onAppChange={handleAppChange}
|
||||||
>
|
onToggleSelectAll={toggleSelectAll}
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
onSelectionBulkRequest={handleSelectionBulkRequest}
|
||||||
</Button>
|
onBulkDeleteRequest={handleBulkDeleteRequest}
|
||||||
{selectedIds.length > 0 && (
|
/>
|
||||||
<Button
|
)}
|
||||||
variant="destructive"
|
{activeStatus === "processed" && (
|
||||||
size="sm"
|
<InboxItemsList
|
||||||
onClick={() => handleSelectionBulkRequest("processed")}
|
items={items}
|
||||||
>
|
readonly
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
activeApp={activeApp}
|
||||||
Excluir selecionados ({selectedIds.length})
|
appLogoMap={appLogoMap}
|
||||||
</Button>
|
selectedIds={selectedIds}
|
||||||
)}
|
onDelete={handleDeleteRequest}
|
||||||
<Button
|
onRestoreToPending={handleRestoreRequest}
|
||||||
variant="outline"
|
onSelectToggle={toggleSelection}
|
||||||
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" &&
|
{showTabActions("discarded") && (
|
||||||
(appFilterOptions.length > 0 || items.length > 0) && (
|
<InboxBulkActions
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
status="discarded"
|
||||||
{renderAppFilter()}
|
items={items}
|
||||||
{items.length > 0 ? (
|
activeApp={activeApp}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
appFilterOptions={appFilterOptions}
|
||||||
<Button
|
selectedIds={selectedIds}
|
||||||
variant="outline"
|
allSelected={allSelected}
|
||||||
size="sm"
|
appLogoMap={appLogoMap}
|
||||||
onClick={toggleSelectAll}
|
onAppChange={handleAppChange}
|
||||||
>
|
onToggleSelectAll={toggleSelectAll}
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
onSelectionBulkRequest={handleSelectionBulkRequest}
|
||||||
</Button>
|
onBulkDeleteRequest={handleBulkDeleteRequest}
|
||||||
{selectedIds.length > 0 && (
|
/>
|
||||||
<Button
|
)}
|
||||||
variant="destructive"
|
{activeStatus === "discarded" && (
|
||||||
size="sm"
|
<InboxItemsList
|
||||||
onClick={() => handleSelectionBulkRequest("discarded")}
|
items={items}
|
||||||
>
|
readonly
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
activeApp={activeApp}
|
||||||
Excluir selecionados ({selectedIds.length})
|
appLogoMap={appLogoMap}
|
||||||
</Button>
|
selectedIds={selectedIds}
|
||||||
)}
|
onDelete={handleDeleteRequest}
|
||||||
<Button
|
onRestoreToPending={handleRestoreRequest}
|
||||||
variant="outline"
|
onSelectToggle={toggleSelection}
|
||||||
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>
|
||||||
|
|
||||||
{pagination.totalItems > 0 ? (
|
<InboxPagination
|
||||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
pagination={pagination}
|
||||||
<div className="flex items-center gap-2">
|
activeStatus={activeStatus}
|
||||||
<span className="text-sm text-muted-foreground">
|
isPending={isPending}
|
||||||
{pagination.totalItems} notificações
|
onNavigate={updateUrl}
|
||||||
</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"
|
||||||
@@ -944,7 +561,7 @@ export function InboxPage({
|
|||||||
|
|
||||||
<ConfirmActionDialog
|
<ConfirmActionDialog
|
||||||
open={bulkDeleteOpen}
|
open={bulkDeleteOpen}
|
||||||
onOpenChange={handleBulkDeleteOpenChange}
|
onOpenChange={setBulkDeleteOpen}
|
||||||
title={`Limpar ${bulkDeleteStatus === "processed" ? "processados" : "descartados"}?`}
|
title={`Limpar ${bulkDeleteStatus === "processed" ? "processados" : "descartados"}?`}
|
||||||
description={`Todos os itens ${bulkDeleteStatus === "processed" ? "processados" : "descartados"} serão excluídos permanentemente.`}
|
description={`Todos os itens ${bulkDeleteStatus === "processed" ? "processados" : "descartados"} serão excluídos permanentemente.`}
|
||||||
confirmLabel="Limpar tudo"
|
confirmLabel="Limpar tudo"
|
||||||
|
|||||||
122
src/features/inbox/components/inbox-pagination.tsx
Normal file
122
src/features/inbox/components/inbox-pagination.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
RiArrowLeftDoubleLine,
|
||||||
|
RiArrowLeftSLine,
|
||||||
|
RiArrowRightDoubleLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import {
|
||||||
|
INBOX_DEFAULT_PAGE_SIZE,
|
||||||
|
INBOX_PAGE_SIZE_OPTIONS,
|
||||||
|
} from "@/features/inbox/page-helpers";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import type { InboxPaginationState, InboxStatus } from "./types";
|
||||||
|
|
||||||
|
type InboxPaginationProps = {
|
||||||
|
pagination: InboxPaginationState;
|
||||||
|
activeStatus: InboxStatus;
|
||||||
|
isPending: boolean;
|
||||||
|
onNavigate: (status: InboxStatus, page: number, pageSize: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InboxPagination({
|
||||||
|
pagination,
|
||||||
|
activeStatus,
|
||||||
|
isPending,
|
||||||
|
onNavigate,
|
||||||
|
}: InboxPaginationProps) {
|
||||||
|
if (pagination.totalItems === 0) return null;
|
||||||
|
|
||||||
|
const canPreviousPage = pagination.page > 1;
|
||||||
|
const canNextPage = pagination.page < pagination.totalPages;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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) => {
|
||||||
|
onNavigate(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={() => onNavigate(activeStatus, 1, pagination.pageSize)}
|
||||||
|
disabled={!canPreviousPage || isPending}
|
||||||
|
aria-label="Primeira página"
|
||||||
|
>
|
||||||
|
<RiArrowLeftDoubleLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() =>
|
||||||
|
onNavigate(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={() =>
|
||||||
|
onNavigate(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={() =>
|
||||||
|
onNavigate(
|
||||||
|
activeStatus,
|
||||||
|
pagination.totalPages,
|
||||||
|
pagination.pageSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canNextPage || isPending}
|
||||||
|
aria-label="Última página"
|
||||||
|
>
|
||||||
|
<RiArrowRightDoubleLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export para facilitar uso externo
|
||||||
|
export { INBOX_DEFAULT_PAGE_SIZE };
|
||||||
40
src/features/inbox/components/inbox-tabs.tsx
Normal file
40
src/features/inbox/components/inbox-tabs.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { TabsList, TabsTrigger } from "@/shared/components/ui/tabs";
|
||||||
|
import type { InboxStatus, InboxStatusCounts } from "./types";
|
||||||
|
|
||||||
|
type InboxTabsProps = {
|
||||||
|
counts: InboxStatusCounts;
|
||||||
|
isPending: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InboxTabs({ counts, isPending }: InboxTabsProps) {
|
||||||
|
return (
|
||||||
|
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
|
||||||
|
<TabsTrigger
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span>Pendentes</span>
|
||||||
|
<span>({counts.pending})</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span>Processados</span>
|
||||||
|
<span>({counts.processed})</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span>Descartados</span>
|
||||||
|
<span>({counts.discarded})</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { InboxStatus, InboxStatusCounts };
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { RiDeleteBin5Line, RiFileCopyLine } from "@remixicon/react";
|
||||||
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
|
type TransactionsBulkBarProps = {
|
||||||
|
selectedCount: number;
|
||||||
|
selectedTotal: number;
|
||||||
|
mode: "delete" | "import";
|
||||||
|
onAction: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionsBulkBar({
|
||||||
|
selectedCount,
|
||||||
|
selectedTotal,
|
||||||
|
mode,
|
||||||
|
onAction,
|
||||||
|
}: TransactionsBulkBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2">
|
||||||
|
<div className="flex flex-col text-sm text-muted-foreground sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<span>
|
||||||
|
{selectedCount}{" "}
|
||||||
|
{selectedCount === 1 ? "item selecionado" : "itens selecionados"}
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline" aria-hidden>
|
||||||
|
-
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Total:{" "}
|
||||||
|
<MoneyValues
|
||||||
|
amount={selectedTotal}
|
||||||
|
className="inline font-medium text-foreground"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{mode === "delete" ? (
|
||||||
|
<Button
|
||||||
|
onClick={onAction}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
<RiDeleteBin5Line className="size-4" />
|
||||||
|
Remover selecionados
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={onAction}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
<RiFileCopyLine className="size-4" />
|
||||||
|
Importar selecionados
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
import {
|
||||||
|
RiAttachment2,
|
||||||
|
RiBankCard2Line,
|
||||||
|
RiChat1Line,
|
||||||
|
RiCheckboxBlankCircleLine,
|
||||||
|
RiCheckboxCircleFill,
|
||||||
|
RiCheckLine,
|
||||||
|
RiDeleteBin5Line,
|
||||||
|
RiFileCopyLine,
|
||||||
|
RiFileList2Line,
|
||||||
|
RiGroupLine,
|
||||||
|
RiHistoryLine,
|
||||||
|
RiMoreFill,
|
||||||
|
RiPencilLine,
|
||||||
|
RiTimeLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/features/transactions/column-order";
|
||||||
|
import {
|
||||||
|
CategoryIconBadge,
|
||||||
|
EstablishmentLogo,
|
||||||
|
} from "@/shared/components/entity-avatar";
|
||||||
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
|
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/shared/components/ui/avatar";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu";
|
||||||
|
import { Spinner } from "@/shared/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
|
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||||
|
import { formatDate } from "@/shared/utils/date";
|
||||||
|
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
import type { TransactionItem } from "../types";
|
||||||
|
|
||||||
|
export type BuildColumnsArgs = {
|
||||||
|
currentUserId: string;
|
||||||
|
noteAsColumn: boolean;
|
||||||
|
onEdit?: (item: TransactionItem) => void;
|
||||||
|
onCopy?: (item: TransactionItem) => void;
|
||||||
|
onImport?: (item: TransactionItem) => void;
|
||||||
|
onConfirmDelete?: (item: TransactionItem) => void;
|
||||||
|
onViewDetails?: (item: TransactionItem) => void;
|
||||||
|
onToggleSettlement?: (item: TransactionItem) => void;
|
||||||
|
onAnticipate?: (item: TransactionItem) => void;
|
||||||
|
onViewAnticipationHistory?: (item: TransactionItem) => void;
|
||||||
|
isSettlementLoading: (id: string) => boolean;
|
||||||
|
showActions: boolean;
|
||||||
|
columnOrder?: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPaymentMethodTableLabel(method: string) {
|
||||||
|
if (method === "Transferência bancária") return "Transf. bancária";
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIXED_START_IDS = ["select", "purchaseDate"];
|
||||||
|
const FIXED_END_IDS = ["actions"];
|
||||||
|
|
||||||
|
function getColumnId(col: ColumnDef<TransactionItem>): string {
|
||||||
|
const c = col as { id?: string; accessorKey?: string };
|
||||||
|
return c.id ?? c.accessorKey ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderColumnsByPreference<T>(
|
||||||
|
columns: ColumnDef<T>[],
|
||||||
|
orderPreference: string[] | null | undefined,
|
||||||
|
): ColumnDef<T>[] {
|
||||||
|
if (!orderPreference || orderPreference.length === 0) return columns;
|
||||||
|
|
||||||
|
const order = orderPreference;
|
||||||
|
const fixedStart: ColumnDef<T>[] = [];
|
||||||
|
const reorderable: ColumnDef<T>[] = [];
|
||||||
|
const fixedEnd: ColumnDef<T>[] = [];
|
||||||
|
|
||||||
|
for (const col of columns) {
|
||||||
|
const id = getColumnId(col as ColumnDef<TransactionItem>);
|
||||||
|
if (FIXED_START_IDS.includes(id)) fixedStart.push(col);
|
||||||
|
else if (FIXED_END_IDS.includes(id)) fixedEnd.push(col);
|
||||||
|
else reorderable.push(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...reorderable].sort((a, b) => {
|
||||||
|
const idA = getColumnId(a as ColumnDef<TransactionItem>);
|
||||||
|
const idB = getColumnId(b as ColumnDef<TransactionItem>);
|
||||||
|
const indexA = order.indexOf(idA);
|
||||||
|
const indexB = order.indexOf(idB);
|
||||||
|
if (indexA === -1 && indexB === -1) return 0;
|
||||||
|
if (indexA === -1) return 1;
|
||||||
|
if (indexB === -1) return -1;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...fixedStart, ...sorted, ...fixedEnd];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumns({
|
||||||
|
currentUserId,
|
||||||
|
noteAsColumn,
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onImport,
|
||||||
|
onConfirmDelete,
|
||||||
|
onViewDetails,
|
||||||
|
onToggleSettlement,
|
||||||
|
onAnticipate,
|
||||||
|
onViewAnticipationHistory,
|
||||||
|
isSettlementLoading,
|
||||||
|
showActions,
|
||||||
|
}: BuildColumnsArgs): ColumnDef<TransactionItem>[] {
|
||||||
|
const noop = () => undefined;
|
||||||
|
const handleEdit = onEdit ?? noop;
|
||||||
|
const handleCopy = onCopy ?? noop;
|
||||||
|
const handleImport = onImport ?? noop;
|
||||||
|
const handleConfirmDelete = onConfirmDelete ?? noop;
|
||||||
|
const handleViewDetails = onViewDetails ?? noop;
|
||||||
|
const handleToggleSettlement = onToggleSettlement ?? noop;
|
||||||
|
const handleAnticipate = onAnticipate ?? noop;
|
||||||
|
const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop;
|
||||||
|
|
||||||
|
const columns: ColumnDef<TransactionItem>[] = [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Selecionar todos"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Selecionar linha"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "purchaseDate",
|
||||||
|
accessorKey: "purchaseDate",
|
||||||
|
header: () => null,
|
||||||
|
cell: () => null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: "Estabelecimento",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
purchaseDate,
|
||||||
|
installmentCount,
|
||||||
|
currentInstallment,
|
||||||
|
paymentMethod,
|
||||||
|
dueDate,
|
||||||
|
note,
|
||||||
|
isDivided,
|
||||||
|
isAnticipated,
|
||||||
|
hasAttachments,
|
||||||
|
} = row.original;
|
||||||
|
|
||||||
|
const installmentBadge =
|
||||||
|
currentInstallment && installmentCount
|
||||||
|
? `${currentInstallment} de ${installmentCount}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isBoleto = paymentMethod === "Boleto" && dueDate;
|
||||||
|
const dueDateLabel =
|
||||||
|
isBoleto && dueDate ? `venc. ${formatDate(dueDate)}` : null;
|
||||||
|
const hasNote = Boolean(note?.trim().length);
|
||||||
|
const isLastInstallment =
|
||||||
|
currentInstallment === installmentCount &&
|
||||||
|
installmentCount &&
|
||||||
|
installmentCount > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<EstablishmentLogo name={name} size={28} />
|
||||||
|
<span className="flex flex-col py-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
{formatDate(purchaseDate)}
|
||||||
|
{dueDateLabel ? (
|
||||||
|
<span className="text-primary">{dueDateLabel}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="line-clamp-2 max-w-[180px] font-medium truncate">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
{name}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{isDivided && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex rounded-full p-1">
|
||||||
|
<RiGroupLine
|
||||||
|
size={14}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only">
|
||||||
|
Dividido entre pagadores
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
Dividido entre pagadores
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLastInstallment ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex">
|
||||||
|
<Image
|
||||||
|
src="/icons/party.svg"
|
||||||
|
alt="Última parcela"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Última parcela</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Última parcela!</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{installmentBadge ? (
|
||||||
|
<Badge variant="outline" className="px-2 text-xs">
|
||||||
|
{installmentBadge}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isAnticipated && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex rounded-full p-1">
|
||||||
|
<RiTimeLine
|
||||||
|
size={14}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Parcela antecipada</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
Parcela antecipada
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!noteAsColumn && hasNote ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
|
||||||
|
<RiChat1Line
|
||||||
|
className="h-4 w-4 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Ver anotação</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
className="max-w-xs whitespace-pre-line"
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasAttachments ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex rounded-full p-1">
|
||||||
|
<RiAttachment2
|
||||||
|
className="h-4 w-4 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Possui anexos</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Possui anexos</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "transactionType",
|
||||||
|
header: "Transação",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const type =
|
||||||
|
row.original.categoriaName === "Saldo inicial"
|
||||||
|
? "Saldo inicial"
|
||||||
|
: row.original.transactionType;
|
||||||
|
return (
|
||||||
|
<TransactionTypeBadge
|
||||||
|
kind={
|
||||||
|
type as "Despesa" | "Receita" | "Transferência" | "Saldo inicial"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "amount",
|
||||||
|
header: "Valor",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isReceita = row.original.transactionType === "Receita";
|
||||||
|
const isTransfer = row.original.transactionType === "Transferência";
|
||||||
|
return (
|
||||||
|
<MoneyValues
|
||||||
|
amount={row.original.amount}
|
||||||
|
showPositiveSign={isReceita}
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap",
|
||||||
|
isReceita ? "text-success" : "text-foreground",
|
||||||
|
isTransfer && "text-info",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "condition",
|
||||||
|
header: "Condição",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const condition = row.original.condition;
|
||||||
|
const icon = getConditionIcon(condition);
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<span>{condition}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "paymentMethod",
|
||||||
|
header: "Forma de Pagamento",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const method = row.original.paymentMethod;
|
||||||
|
const icon = getPaymentMethodIcon(method);
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<span>{getPaymentMethodTableLabel(method)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "categoriaName",
|
||||||
|
header: "Categoria",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { categoriaName, categoriaIcon } = row.original;
|
||||||
|
if (!categoriaName) {
|
||||||
|
return <span className="text-muted-foreground">—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CategoryIconBadge
|
||||||
|
icon={categoriaIcon}
|
||||||
|
name={categoriaName}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span>{categoriaName}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "pagadorName",
|
||||||
|
header: "Pagador",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { payerId, pagadorName, pagadorAvatar } = row.original;
|
||||||
|
const label = pagadorName?.trim() || "Sem pagador";
|
||||||
|
const displayName = label.split(/\s+/)[0] ?? label;
|
||||||
|
const avatarSrc = getAvatarSrc(pagadorAvatar);
|
||||||
|
const initial = displayName.charAt(0).toUpperCase() || "?";
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<Avatar className="size-7">
|
||||||
|
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
||||||
|
<AvatarFallback className="text-[10px] font-medium uppercase">
|
||||||
|
{initial}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="truncate">{displayName}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (!payerId) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2">{content}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/payers/${payerId}`}
|
||||||
|
className="inline-flex items-center gap-2 hover:underline"
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contaCartao",
|
||||||
|
header: "Conta/Cartão",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const {
|
||||||
|
cartaoName,
|
||||||
|
contaName,
|
||||||
|
cartaoLogo,
|
||||||
|
contaLogo,
|
||||||
|
cardId,
|
||||||
|
accountId,
|
||||||
|
userId,
|
||||||
|
} = row.original;
|
||||||
|
const isCartao = Boolean(cartaoName);
|
||||||
|
const label = cartaoName ?? contaName;
|
||||||
|
const logoSrc = resolveLogoSrc(cartaoLogo ?? contaLogo);
|
||||||
|
const href = cardId
|
||||||
|
? `/cards/${cardId}/invoice`
|
||||||
|
: accountId
|
||||||
|
? `/accounts/${accountId}/statement`
|
||||||
|
: null;
|
||||||
|
const isOwnData = userId === currentUserId;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
{logoSrc && (
|
||||||
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt={`Logo de ${label}`}
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOwnData || !href) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{isCartao ? "Cartão" : "Conta"}: {label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="inline-flex items-center gap-2 hover:underline"
|
||||||
|
>
|
||||||
|
{logoSrc && (
|
||||||
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt={`Logo de ${label}`}
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{isCartao ? "Cartão" : "Conta"}: {label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (noteAsColumn) {
|
||||||
|
const accountCardIndex = columns.findIndex((c) => c.id === "contaCartao");
|
||||||
|
const noteColumn: ColumnDef<TransactionItem> = {
|
||||||
|
accessorKey: "note",
|
||||||
|
header: "Anotação",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const note = row.original.note;
|
||||||
|
if (!note?.trim())
|
||||||
|
return <span className="text-muted-foreground">—</span>;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="max-w-[200px] truncate whitespace-pre-line text-sm"
|
||||||
|
title={note}
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
columns.splice(accountCardIndex, 0, noteColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showActions) {
|
||||||
|
columns.push({
|
||||||
|
id: "actions",
|
||||||
|
header: "Ações",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(() => {
|
||||||
|
const paymentMethod = row.original.paymentMethod;
|
||||||
|
const showSettlementButton = [
|
||||||
|
"Pix",
|
||||||
|
"Boleto",
|
||||||
|
"Cartão de crédito",
|
||||||
|
"Dinheiro",
|
||||||
|
"Cartão de débito",
|
||||||
|
"Transferência bancária",
|
||||||
|
"Pré-Pago | VR/VA",
|
||||||
|
].includes(paymentMethod);
|
||||||
|
|
||||||
|
if (!showSettlementButton) return null;
|
||||||
|
|
||||||
|
const canToggleSettlement =
|
||||||
|
paymentMethod === "Pix" ||
|
||||||
|
paymentMethod === "Boleto" ||
|
||||||
|
paymentMethod === "Dinheiro" ||
|
||||||
|
paymentMethod === "Cartão de débito" ||
|
||||||
|
paymentMethod === "Transferência bancária" ||
|
||||||
|
paymentMethod === "Pré-Pago | VR/VA";
|
||||||
|
|
||||||
|
if (!canToggleSettlement)
|
||||||
|
return (
|
||||||
|
<span className="flex size-7 shrink-0 items-center justify-center">
|
||||||
|
<RiBankCard2Line className="size-4 text-muted-foreground/30" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const readOnly = row.original.readonly;
|
||||||
|
const loading = isSettlementLoading(row.original.id);
|
||||||
|
const settled = Boolean(row.original.isSettled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => handleToggleSettlement(row.original)}
|
||||||
|
disabled={loading || readOnly}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
settled
|
||||||
|
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : settled ? (
|
||||||
|
<RiCheckboxCircleFill className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RiCheckboxBlankCircleLine className="size-4" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">
|
||||||
|
{settled ? "Desfazer pagamento" : "Marcar como pago"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{settled ? "Desfazer pagamento" : "Marcar como pago"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm">
|
||||||
|
<RiMoreFill className="size-4" />
|
||||||
|
<span className="sr-only">Abrir ações do lançamento</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => handleViewDetails(row.original)}
|
||||||
|
>
|
||||||
|
<RiFileList2Line className="size-4" />
|
||||||
|
Detalhes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{row.original.userId === currentUserId && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => handleEdit(row.original)}
|
||||||
|
disabled={row.original.readonly}
|
||||||
|
>
|
||||||
|
<RiPencilLine className="size-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{row.original.categoriaName !== "Pagamentos" &&
|
||||||
|
row.original.userId === currentUserId && (
|
||||||
|
<DropdownMenuItem onSelect={() => handleCopy(row.original)}>
|
||||||
|
<RiFileCopyLine className="size-4" />
|
||||||
|
Copiar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{row.original.categoriaName !== "Pagamentos" &&
|
||||||
|
row.original.userId !== currentUserId && (
|
||||||
|
<DropdownMenuItem onSelect={() => handleImport(row.original)}>
|
||||||
|
<RiFileCopyLine className="size-4" />
|
||||||
|
Importar para Minha Conta
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{row.original.userId === currentUserId && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => handleConfirmDelete(row.original)}
|
||||||
|
disabled={row.original.readonly}
|
||||||
|
>
|
||||||
|
<RiDeleteBin5Line className="size-4" />
|
||||||
|
Remover
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Opções de Antecipação */}
|
||||||
|
{row.original.userId === currentUserId &&
|
||||||
|
row.original.condition === "Parcelado" &&
|
||||||
|
row.original.seriesId && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{!row.original.isAnticipated && onAnticipate && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => handleAnticipate(row.original)}
|
||||||
|
>
|
||||||
|
<RiTimeLine className="size-4" />
|
||||||
|
Antecipar Parcelas
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onViewAnticipationHistory && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
handleViewAnticipationHistory(row.original)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RiHistoryLine className="size-4" />
|
||||||
|
Histórico de Antecipações
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.original.isAnticipated && (
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<RiCheckLine className="size-4 text-success" />
|
||||||
|
Parcela Antecipada
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTransactionColumns(
|
||||||
|
args: BuildColumnsArgs,
|
||||||
|
): ColumnDef<TransactionItem>[] {
|
||||||
|
const built = buildColumns(args);
|
||||||
|
const order = args.columnOrder?.length
|
||||||
|
? args.columnOrder
|
||||||
|
: DEFAULT_LANCAMENTOS_COLUMN_ORDER;
|
||||||
|
return reorderColumnsByPreference(built, order);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
RiArrowLeftDoubleLine,
|
||||||
|
RiArrowLeftSLine,
|
||||||
|
RiArrowRightDoubleLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
|
||||||
|
type TransactionsPaginationProps = {
|
||||||
|
totalRows: number;
|
||||||
|
currentPage: number;
|
||||||
|
currentPageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
canPreviousPage: boolean;
|
||||||
|
canNextPage: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionsPagination({
|
||||||
|
totalRows,
|
||||||
|
currentPage,
|
||||||
|
currentPageSize,
|
||||||
|
totalPages,
|
||||||
|
canPreviousPage,
|
||||||
|
canNextPage,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: TransactionsPaginationProps) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex 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">
|
||||||
|
{totalRows} lançamentos
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={currentPageSize.toString()}
|
||||||
|
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-max">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5 linhas</SelectItem>
|
||||||
|
<SelectItem value="10">10 linhas</SelectItem>
|
||||||
|
<SelectItem value="20">20 linhas</SelectItem>
|
||||||
|
<SelectItem value="30">30 linhas</SelectItem>
|
||||||
|
<SelectItem value="40">40 linhas</SelectItem>
|
||||||
|
<SelectItem value="50">50 linhas</SelectItem>
|
||||||
|
<SelectItem value="100">100 linhas</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Página {currentPage} de {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
disabled={!canPreviousPage}
|
||||||
|
aria-label="Primeira página"
|
||||||
|
>
|
||||||
|
<RiArrowLeftDoubleLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={!canPreviousPage}
|
||||||
|
aria-label="Página anterior"
|
||||||
|
>
|
||||||
|
<RiArrowLeftSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={!canNextPage}
|
||||||
|
aria-label="Próxima página"
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
disabled={!canNextPage}
|
||||||
|
aria-label="Última página"
|
||||||
|
>
|
||||||
|
<RiArrowRightDoubleLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user