forked from git.gladyson/openmonetis
feat(v1.4.1): tabs de histórico, logo matching e melhorias nos pré-lançamentos
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -5,6 +5,27 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
|||||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||||
|
|
||||||
|
## [1.4.1] - 2026-02-15
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Abas "Pendentes", "Processados" e "Descartados" na página de pré-lançamentos (antes exibia apenas pendentes)
|
||||||
|
- Logo do cartão/conta exibido automaticamente nos cards de pré-lançamento via matching por nome do app
|
||||||
|
- Pre-fill automático do cartão de crédito ao processar pré-lançamento (match pelo nome do app)
|
||||||
|
- Badge de status e data nos cards de itens já processados/descartados (modo readonly)
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- `revalidateTag("dashboard", "max")` para invalidar todas as entradas de cache da tag (antes invalidava apenas a mais recente)
|
||||||
|
- Cor `--warning` ajustada para melhor contraste (mais alaranjada)
|
||||||
|
- `EstabelecimentoLogo` não precisava de `"use client"` — removido
|
||||||
|
- Fallback no cálculo de `fontSize` em `EstabelecimentoLogo`
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Nome do estabelecimento formatado em Title Case ao processar pré-lançamento
|
||||||
|
- Subtítulo da página de pré-lançamentos atualizado
|
||||||
|
|
||||||
## [1.4.0] - 2026-02-07
|
## [1.4.0] - 2026-02-07
|
||||||
|
|
||||||
### Corrigido
|
### Corrigido
|
||||||
|
|||||||
@@ -88,6 +88,31 @@ export async function fetchCartoesForSelect(
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAppLogoMap(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const [userCartoes, userContas] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ name: cartoes.name, logo: cartoes.logo })
|
||||||
|
.from(cartoes)
|
||||||
|
.where(eq(cartoes.userId, userId)),
|
||||||
|
db
|
||||||
|
.select({ name: contas.name, logo: contas.logo })
|
||||||
|
.from(contas)
|
||||||
|
.where(eq(contas.userId, userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const logoMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const item of [...userCartoes, ...userContas]) {
|
||||||
|
if (item.logo) {
|
||||||
|
logoMap[item.name.toLowerCase()] = item.logo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return logoMap;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPendingInboxCount(userId: string): Promise<number> {
|
export async function fetchPendingInboxCount(userId: string): Promise<number> {
|
||||||
const items = await db
|
const items = await db
|
||||||
.select({ id: preLancamentos.id })
|
.select({ id: preLancamentos.id })
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function RootLayout({
|
|||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiInboxLine />}
|
icon={<RiInboxLine />}
|
||||||
title="Pré-Lançamentos"
|
title="Pré-Lançamentos"
|
||||||
subtitle="Notificações capturadas aguardando processamento"
|
subtitle="Notificações capturadas pelo Companion"
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { InboxPage } from "@/components/pre-lancamentos/inbox-page";
|
import { InboxPage } from "@/components/pre-lancamentos/inbox-page";
|
||||||
import { getUserId } from "@/lib/auth/server";
|
import { getUserId } from "@/lib/auth/server";
|
||||||
import { fetchInboxDialogData, fetchInboxItems } from "./data";
|
import { fetchAppLogoMap, fetchInboxDialogData, fetchInboxItems } from "./data";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
|
|
||||||
const [items, dialogData] = await Promise.all([
|
const [pendingItems, processedItems, discardedItems, dialogData, appLogoMap] =
|
||||||
|
await Promise.all([
|
||||||
fetchInboxItems(userId, "pending"),
|
fetchInboxItems(userId, "pending"),
|
||||||
|
fetchInboxItems(userId, "processed"),
|
||||||
|
fetchInboxItems(userId, "discarded"),
|
||||||
fetchInboxDialogData(userId),
|
fetchInboxDialogData(userId),
|
||||||
|
fetchAppLogoMap(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col items-start gap-6">
|
<main className="flex flex-col items-start gap-6">
|
||||||
<InboxPage
|
<InboxPage
|
||||||
items={items}
|
pendingItems={pendingItems}
|
||||||
|
processedItems={processedItems}
|
||||||
|
discardedItems={discardedItems}
|
||||||
pagadorOptions={dialogData.pagadorOptions}
|
pagadorOptions={dialogData.pagadorOptions}
|
||||||
splitPagadorOptions={dialogData.splitPagadorOptions}
|
splitPagadorOptions={dialogData.splitPagadorOptions}
|
||||||
defaultPagadorId={dialogData.defaultPagadorId}
|
defaultPagadorId={dialogData.defaultPagadorId}
|
||||||
@@ -21,6 +27,7 @@ export default async function Page() {
|
|||||||
cartaoOptions={dialogData.cartaoOptions}
|
cartaoOptions={dialogData.cartaoOptions}
|
||||||
categoriaOptions={dialogData.categoriaOptions}
|
categoriaOptions={dialogData.categoriaOptions}
|
||||||
estabelecimentos={dialogData.estabelecimentos}
|
estabelecimentos={dialogData.estabelecimentos}
|
||||||
|
appLogoMap={appLogoMap}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
/* Semantic states */
|
/* Semantic states */
|
||||||
--success: oklch(55% 0.17 150);
|
--success: oklch(55% 0.17 150);
|
||||||
--success-foreground: oklch(98% 0.01 150);
|
--success-foreground: oklch(98% 0.01 150);
|
||||||
--warning: oklch(75.976% 0.16034 71.493);
|
--warning: oklch(69.913% 0.1798 49.649);
|
||||||
--warning-foreground: oklch(20% 0.04 85);
|
--warning-foreground: oklch(20% 0.04 85);
|
||||||
--info: oklch(55% 0.17 250);
|
--info: oklch(55% 0.17 250);
|
||||||
--info-foreground: oklch(98% 0.01 250);
|
--info-foreground: oklch(98% 0.01 250);
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
/* Semantic states */
|
/* Semantic states */
|
||||||
--success: oklch(65% 0.19 150);
|
--success: oklch(65% 0.19 150);
|
||||||
--success-foreground: oklch(15% 0.02 150);
|
--success-foreground: oklch(15% 0.02 150);
|
||||||
--warning: oklch(75.976% 0.16034 71.493);
|
--warning: oklch(69.913% 0.1798 49.649);
|
||||||
--warning-foreground: oklch(15% 0.04 85);
|
--warning-foreground: oklch(15% 0.04 85);
|
||||||
--info: oklch(65% 0.17 250);
|
--info: oklch(65% 0.17 250);
|
||||||
--info-foreground: oklch(15% 0.02 250);
|
--info-foreground: oklch(15% 0.02 250);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
interface EstabelecimentoLogoProps {
|
interface EstabelecimentoLogoProps {
|
||||||
@@ -63,7 +61,7 @@ export function EstabelecimentoLogo({
|
|||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fontSize: size * 0.4,
|
fontSize: (size ?? 32) * 0.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import {
|
|||||||
RiEyeLine,
|
RiEyeLine,
|
||||||
RiMoreLine,
|
RiMoreLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useMemo } from "react";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/money-values";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -28,17 +31,59 @@ import type { InboxItem } from "./types";
|
|||||||
|
|
||||||
interface InboxCardProps {
|
interface InboxCardProps {
|
||||||
item: InboxItem;
|
item: InboxItem;
|
||||||
onProcess: (item: InboxItem) => void;
|
readonly?: boolean;
|
||||||
onDiscard: (item: InboxItem) => void;
|
appLogoMap?: Record<string, string>;
|
||||||
onViewDetails: (item: InboxItem) => void;
|
onProcess?: (item: InboxItem) => void;
|
||||||
|
onDiscard?: (item: InboxItem) => void;
|
||||||
|
onViewDetails?: (item: InboxItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLogoPath(logo: string): string {
|
||||||
|
if (
|
||||||
|
logo.startsWith("http") ||
|
||||||
|
logo.startsWith("data:") ||
|
||||||
|
logo.startsWith("/")
|
||||||
|
) {
|
||||||
|
return logo;
|
||||||
|
}
|
||||||
|
return `/logos/${logo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatchingLogo(
|
||||||
|
sourceAppName: string | null,
|
||||||
|
appLogoMap: Record<string, string>,
|
||||||
|
): string | null {
|
||||||
|
if (!sourceAppName) return null;
|
||||||
|
|
||||||
|
const appName = sourceAppName.toLowerCase();
|
||||||
|
|
||||||
|
// Exact match first
|
||||||
|
if (appLogoMap[appName]) return resolveLogoPath(appLogoMap[appName]);
|
||||||
|
|
||||||
|
// Partial match: card/account name contains app name or vice versa
|
||||||
|
for (const [name, logo] of Object.entries(appLogoMap)) {
|
||||||
|
if (name.includes(appName) || appName.includes(name)) {
|
||||||
|
return resolveLogoPath(logo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InboxCard({
|
export function InboxCard({
|
||||||
item,
|
item,
|
||||||
|
readonly,
|
||||||
|
appLogoMap,
|
||||||
onProcess,
|
onProcess,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
}: InboxCardProps) {
|
}: InboxCardProps) {
|
||||||
|
const matchedLogo = useMemo(
|
||||||
|
() =>
|
||||||
|
appLogoMap ? findMatchingLogo(item.sourceAppName, appLogoMap) : null,
|
||||||
|
[item.sourceAppName, appLogoMap],
|
||||||
|
);
|
||||||
|
|
||||||
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
||||||
|
|
||||||
// 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
|
||||||
@@ -63,12 +108,32 @@ export function InboxCard({
|
|||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
}).format(rawDate);
|
}).format(rawDate);
|
||||||
|
|
||||||
|
const statusDate =
|
||||||
|
item.status === "processed"
|
||||||
|
? item.processedAt
|
||||||
|
: item.status === "discarded"
|
||||||
|
? item.discardedAt
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const formattedStatusDate = statusDate
|
||||||
|
? format(new Date(statusDate), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col gap-0 py-0 h-54">
|
<Card className="flex flex-col gap-0 py-0 h-54">
|
||||||
{/* Header com app e valor */}
|
{/* Header com app e valor */}
|
||||||
<CardHeader className="pt-4">
|
<CardHeader className="pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-md">
|
<CardTitle className="flex items-center gap-1.5 text-md">
|
||||||
|
{matchedLogo && (
|
||||||
|
<Image
|
||||||
|
src={matchedLogo}
|
||||||
|
alt=""
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="shrink-0 rounded-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{item.sourceAppName || item.sourceApp}
|
{item.sourceAppName || item.sourceApp}
|
||||||
{" "}
|
{" "}
|
||||||
<span className="text-xs font-normal text-muted-foreground">
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
@@ -80,6 +145,7 @@ export function InboxCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!readonly && (
|
||||||
<CardAction>
|
<CardAction>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -92,16 +158,16 @@ export function InboxCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => onViewDetails(item)}>
|
<DropdownMenuItem onClick={() => onViewDetails?.(item)}>
|
||||||
<RiEyeLine className="mr-2 size-4" />
|
<RiEyeLine className="mr-2 size-4" />
|
||||||
Ver detalhes
|
Ver detalhes
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onProcess(item)}>
|
<DropdownMenuItem onClick={() => onProcess?.(item)}>
|
||||||
<RiCheckLine className="mr-2 size-4" />
|
<RiCheckLine className="mr-2 size-4" />
|
||||||
Processar
|
Processar
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDiscard(item)}
|
onClick={() => onDiscard?.(item)}
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="mr-2 size-4" />
|
<RiDeleteBinLine className="mr-2 size-4" />
|
||||||
@@ -110,6 +176,7 @@ export function InboxCard({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Conteúdo da notificação */}
|
{/* Conteúdo da notificação */}
|
||||||
@@ -122,21 +189,40 @@ export function InboxCard({
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{/* Botões de ação */}
|
{/* Botões de ação ou badge de status */}
|
||||||
|
{readonly ? (
|
||||||
<CardFooter className="gap-2 pt-3 pb-4">
|
<CardFooter className="gap-2 pt-3 pb-4">
|
||||||
<Button size="sm" className="flex-1" onClick={() => onProcess(item)}>
|
<Badge
|
||||||
|
variant={item.status === "processed" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{item.status === "processed" ? "Processado" : "Descartado"}
|
||||||
|
</Badge>
|
||||||
|
{formattedStatusDate && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formattedStatusDate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
) : (
|
||||||
|
<CardFooter className="gap-2 pt-3 pb-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => onProcess?.(item)}
|
||||||
|
>
|
||||||
<RiCheckLine className="mr-1.5 size-4" />
|
<RiCheckLine className="mr-1.5 size-4" />
|
||||||
Processar
|
Processar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onDiscard(item)}
|
onClick={() => onDiscard?.(item)}
|
||||||
className="text-muted-foreground hover:text-destructive hover:border-destructive"
|
className="text-muted-foreground hover:text-destructive hover:border-destructive"
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="size-4" />
|
<RiDeleteBinLine className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
|||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { InboxCard } from "./inbox-card";
|
import { InboxCard } from "./inbox-card";
|
||||||
import { InboxDetailsDialog } from "./inbox-details-dialog";
|
import { InboxDetailsDialog } from "./inbox-details-dialog";
|
||||||
import type { InboxItem, SelectOption } from "./types";
|
import type { InboxItem, SelectOption } from "./types";
|
||||||
|
|
||||||
interface InboxPageProps {
|
interface InboxPageProps {
|
||||||
items: InboxItem[];
|
pendingItems: InboxItem[];
|
||||||
|
processedItems: InboxItem[];
|
||||||
|
discardedItems: InboxItem[];
|
||||||
pagadorOptions: SelectOption[];
|
pagadorOptions: SelectOption[];
|
||||||
splitPagadorOptions: SelectOption[];
|
splitPagadorOptions: SelectOption[];
|
||||||
defaultPagadorId: string | null;
|
defaultPagadorId: string | null;
|
||||||
@@ -24,10 +27,13 @@ interface InboxPageProps {
|
|||||||
cartaoOptions: SelectOption[];
|
cartaoOptions: SelectOption[];
|
||||||
categoriaOptions: SelectOption[];
|
categoriaOptions: SelectOption[];
|
||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
|
appLogoMap: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InboxPage({
|
export function InboxPage({
|
||||||
items,
|
pendingItems,
|
||||||
|
processedItems,
|
||||||
|
discardedItems,
|
||||||
pagadorOptions,
|
pagadorOptions,
|
||||||
splitPagadorOptions,
|
splitPagadorOptions,
|
||||||
defaultPagadorId,
|
defaultPagadorId,
|
||||||
@@ -35,6 +41,7 @@ export function InboxPage({
|
|||||||
cartaoOptions,
|
cartaoOptions,
|
||||||
categoriaOptions,
|
categoriaOptions,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
|
appLogoMap,
|
||||||
}: InboxPageProps) {
|
}: InboxPageProps) {
|
||||||
const [processOpen, setProcessOpen] = useState(false);
|
const [processOpen, setProcessOpen] = useState(false);
|
||||||
const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null);
|
const [itemToProcess, setItemToProcess] = useState<InboxItem | null>(null);
|
||||||
@@ -45,14 +52,24 @@ export function InboxPage({
|
|||||||
const [discardOpen, setDiscardOpen] = useState(false);
|
const [discardOpen, setDiscardOpen] = useState(false);
|
||||||
const [itemToDiscard, setItemToDiscard] = useState<InboxItem | null>(null);
|
const [itemToDiscard, setItemToDiscard] = useState<InboxItem | null>(null);
|
||||||
|
|
||||||
const sortedItems = useMemo(
|
const sortByTimestamp = (list: InboxItem[]) =>
|
||||||
() =>
|
[...list].sort(
|
||||||
[...items].sort(
|
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.notificationTimestamp).getTime() -
|
new Date(b.notificationTimestamp).getTime() -
|
||||||
new Date(a.notificationTimestamp).getTime(),
|
new Date(a.notificationTimestamp).getTime(),
|
||||||
),
|
);
|
||||||
[items],
|
|
||||||
|
const sortedPending = useMemo(
|
||||||
|
() => sortByTimestamp(pendingItems),
|
||||||
|
[pendingItems],
|
||||||
|
);
|
||||||
|
const sortedProcessed = useMemo(
|
||||||
|
() => sortByTimestamp(processedItems),
|
||||||
|
[processedItems],
|
||||||
|
);
|
||||||
|
const sortedDiscarded = useMemo(
|
||||||
|
() => sortByTimestamp(discardedItems),
|
||||||
|
[discardedItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleProcessOpenChange = useCallback((open: boolean) => {
|
const handleProcessOpenChange = useCallback((open: boolean) => {
|
||||||
@@ -133,37 +150,88 @@ export function InboxPage({
|
|||||||
const defaultPurchaseDate =
|
const defaultPurchaseDate =
|
||||||
getDateString(itemToProcess?.notificationTimestamp) ?? null;
|
getDateString(itemToProcess?.notificationTimestamp) ?? null;
|
||||||
|
|
||||||
const defaultName = itemToProcess?.parsedName ?? null;
|
const defaultName = itemToProcess?.parsedName
|
||||||
|
? itemToProcess.parsedName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||||
|
: null;
|
||||||
|
|
||||||
const defaultAmount = itemToProcess?.parsedAmount
|
const defaultAmount = itemToProcess?.parsedAmount
|
||||||
? String(Math.abs(Number(itemToProcess.parsedAmount)))
|
? String(Math.abs(Number(itemToProcess.parsedAmount)))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
// Match sourceAppName with a cartão to pre-fill card select
|
||||||
<>
|
const matchedCartaoId = useMemo(() => {
|
||||||
<div className="flex w-full flex-col gap-6">
|
const appName = itemToProcess?.sourceAppName?.toLowerCase();
|
||||||
{sortedItems.length === 0 ? (
|
if (!appName) return null;
|
||||||
|
|
||||||
|
for (const option of cartaoOptions) {
|
||||||
|
const label = option.label.toLowerCase();
|
||||||
|
if (label.includes(appName) || appName.includes(label)) {
|
||||||
|
return option.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [itemToProcess?.sourceAppName, cartaoOptions]);
|
||||||
|
|
||||||
|
const renderEmptyState = (message: string) => (
|
||||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
media={<RiInboxLine className="size-6 text-primary" />}
|
media={<RiInboxLine className="size-6 text-primary" />}
|
||||||
title="Nenhum pré-lançamento"
|
title={message}
|
||||||
description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui para você processar. Saiba mais sobre o app em Ajustes > Companion."
|
description="As notificações capturadas pelo app OpenSheets Companion aparecerão aqui. Saiba mais em Ajustes > Companion."
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderGrid = (list: InboxItem[], readonly?: boolean) =>
|
||||||
|
list.length === 0 ? (
|
||||||
|
renderEmptyState(
|
||||||
|
readonly
|
||||||
|
? "Nenhuma notificação nesta aba"
|
||||||
|
: "Nenhum pré-lançamento pendente",
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<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">
|
||||||
{sortedItems.map((item) => (
|
{list.map((item) => (
|
||||||
<InboxCard
|
<InboxCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
onProcess={handleProcessRequest}
|
readonly={readonly}
|
||||||
onDiscard={handleDiscardRequest}
|
appLogoMap={appLogoMap}
|
||||||
onViewDetails={handleDetailsRequest}
|
onProcess={readonly ? undefined : handleProcessRequest}
|
||||||
|
onDiscard={readonly ? undefined : handleDiscardRequest}
|
||||||
|
onViewDetails={readonly ? undefined : handleDetailsRequest}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs defaultValue="pending" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="pending">
|
||||||
|
Pendentes ({pendingItems.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="processed">
|
||||||
|
Processados ({processedItems.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="discarded">
|
||||||
|
Descartados ({discardedItems.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="pending" className="mt-4">
|
||||||
|
{renderGrid(sortedPending)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="processed" className="mt-4">
|
||||||
|
{renderGrid(sortedProcessed, true)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="discarded" className="mt-4">
|
||||||
|
{renderGrid(sortedDiscarded, true)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<LancamentoDialog
|
<LancamentoDialog
|
||||||
mode="create"
|
mode="create"
|
||||||
@@ -179,6 +247,8 @@ export function InboxPage({
|
|||||||
defaultPurchaseDate={defaultPurchaseDate}
|
defaultPurchaseDate={defaultPurchaseDate}
|
||||||
defaultName={defaultName}
|
defaultName={defaultName}
|
||||||
defaultAmount={defaultAmount}
|
defaultAmount={defaultAmount}
|
||||||
|
defaultCartaoId={matchedCartaoId}
|
||||||
|
defaultPaymentMethod={matchedCartaoId ? "Cartão de crédito" : null}
|
||||||
forceShowTransactionType
|
forceShowTransactionType
|
||||||
onSuccess={handleLancamentoSuccess}
|
onSuccess={handleLancamentoSuccess}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function revalidateForEntity(
|
|||||||
|
|
||||||
// Invalidate dashboard cache for financial mutations
|
// Invalidate dashboard cache for financial mutations
|
||||||
if (DASHBOARD_ENTITIES.has(entity)) {
|
if (DASHBOARD_ENTITIES.has(entity)) {
|
||||||
revalidateTag("dashboard");
|
revalidateTag("dashboard", "max");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opensheets",
|
"name": "opensheets",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
Reference in New Issue
Block a user