feat: implementar melhorias em importação, compartilhamento e contas inativas

- Corrigir cálculo de valor na importação de lançamentos parcelados
    - Exibir valor total (parcela × quantidade) ao invés do valor da parcela individual
    - Permite recriar parcelamentos importados com valor correto

  - Permitir que usuários compartilhados se descompartilhem de pagadores
    - Adicionar componente PagadorLeaveShareCard na aba Perfil
    - Usuário filho pode sair do compartilhamento sem precisar do usuário pai
    - Manter autorização bidirecionada na action de remoção de share

  - Implementar submenu "Inativos" para contas bancárias
    - Criar página /contas/inativos seguindo padrão de cartões
    - Filtrar contas ativas e inativas em páginas separadas
    - Adicionar ícone e navegação no sidebar
This commit is contained in:
Felipe Coutinho
2026-01-11 22:44:20 +00:00
parent 147857c5bd
commit 6a45a5110d
26 changed files with 812 additions and 405 deletions

View File

@@ -5,7 +5,7 @@ import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { RiAddCircleLine, RiBankCardLine } from "@remixicon/react";
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -21,9 +21,15 @@ interface CardsPageProps {
cards: Card[];
accounts: AccountOption[];
logoOptions: string[];
isInativos?: boolean;
}
export function CardsPage({ cards, accounts, logoOptions }: CardsPageProps) {
export function CardsPage({
cards,
accounts,
logoOptions,
isInativos = false,
}: CardsPageProps) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
@@ -102,19 +108,21 @@ export function CardsPage({ cards, accounts, logoOptions }: CardsPageProps) {
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<CardDialog
mode="create"
accounts={accounts}
logoOptions={logoOptions}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Novo cartão
</Button>
}
/>
</div>
{!isInativos && (
<div className="flex justify-start">
<CardDialog
mode="create"
accounts={accounts}
logoOptions={logoOptions}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Novo cartão
</Button>
}
/>
</div>
)}
{hasCards ? (
<div className="flex flex-wrap gap-4">
@@ -141,9 +149,17 @@ export function CardsPage({ cards, accounts, logoOptions }: CardsPageProps) {
) : (
<Card className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiBankCardLine className="size-6 text-primary" />}
title="Nenhum cartão cadastrado"
description="Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
media={<RiBankCard2Line className="size-6 text-primary" />}
title={
isInativos
? "Nenhum cartão inativo"
: "Nenhum cartão cadastrado"
}
description={
isInativos
? "Os cartões inativos aparecerão aqui."
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
}
/>
</Card>
)}

View File

@@ -19,6 +19,7 @@ import type { Account } from "./types";
interface AccountsPageProps {
accounts: Account[];
logoOptions: string[];
isInativos?: boolean;
}
const resolveLogoSrc = (logo: string | null) => {
@@ -30,7 +31,7 @@ const resolveLogoSrc = (logo: string | null) => {
return `/logos/${fileName}`;
};
export function AccountsPage({ accounts, logoOptions }: AccountsPageProps) {
export function AccountsPage({ accounts, logoOptions, isInativos = false }: AccountsPageProps) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
@@ -169,8 +170,8 @@ export function AccountsPage({ accounts, logoOptions }: AccountsPageProps) {
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiBankLine className="size-6 text-primary" />}
title="Nenhuma conta cadastrada"
description="Cadastre sua primeira conta para começar a organizar os lançamentos."
title={isInativos ? "Nenhuma conta inativa" : "Nenhuma conta cadastrada"}
description={isInativos ? "Não há contas inativas no momento." : "Cadastre sua primeira conta para começar a organizar os lançamentos."}
/>
</Card>
)}

View File

@@ -1,7 +1,7 @@
import MoneyValues from "@/components/money-values";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { RiBankCardLine, RiMoneyDollarCircleLine } from "@remixicon/react";
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
@@ -28,7 +28,7 @@ const resolveIcon = (paymentMethod: string | null | undefined) => {
return icon;
}
return <RiBankCardLine className="size-5" aria-hidden />;
return <RiBankCard2Line className="size-5" aria-hidden />;
};
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {

View File

@@ -130,6 +130,7 @@ interface LancamentosFiltersProps {
contaCartaoOptions: ContaCartaoFilterOption[];
className?: string;
exportButton?: ReactNode;
hideAdvancedFilters?: boolean;
}
export function LancamentosFilters({
@@ -138,6 +139,7 @@ export function LancamentosFilters({
contaCartaoOptions,
className,
exportButton,
hideAdvancedFilters = false,
}: LancamentosFiltersProps) {
const router = useRouter();
const pathname = usePathname();
@@ -277,22 +279,31 @@ export function LancamentosFilters({
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar"
aria-label="Buscar lançamentos"
className="w-[250px] text-sm border-dashed"
/>
{exportButton}
<Drawer direction="right" open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerTrigger asChild>
<Button
variant="outline"
className="text-sm border-dashed relative"
aria-label="Abrir filtros"
>
<RiFilter3Line className="size-4" />
Filtros
{hasActiveFilters && (
<span className="absolute -top-1 -right-1 size-2 rounded-full bg-primary" />
)}
</Button>
</DrawerTrigger>
{!hideAdvancedFilters && (
<Drawer direction="right" open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerTrigger asChild>
<Button
variant="outline"
className="text-sm border-dashed relative"
aria-label="Abrir filtros"
>
<RiFilter3Line className="size-4" />
Filtros
{hasActiveFilters && (
<span className="absolute -top-1 -right-1 size-2 rounded-full bg-primary" />
)}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Filtros</DrawerTitle>
@@ -319,7 +330,9 @@ export function LancamentosFilters({
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Condição</label>
<label className="text-sm font-medium">
Condição de Lançamento
</label>
<FilterSelect
param="condicao"
placeholder="Todas"
@@ -335,7 +348,7 @@ export function LancamentosFilters({
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Pagamento</label>
<label className="text-sm font-medium">Forma de Pagamento</label>
<FilterSelect
param="pagamento"
placeholder="Todos"
@@ -532,15 +545,8 @@ export function LancamentosFilters({
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar"
aria-label="Buscar lançamentos"
className="w-[250px] text-sm border-dashed"
/>
</Drawer>
)}
</div>
);
}

View File

@@ -734,9 +734,8 @@ export function LancamentosTable({
0
);
// Check if all data belongs to current user to determine if filters should be shown
const isOwnData = data.every((item) => item.userId === currentUserId);
const shouldShowFilters = showFilters && isOwnData;
// Check if there's any data from other users
const hasOtherUserData = data.some((item) => item.userId !== currentUserId);
const handleBulkDelete = () => {
if (onBulkDelete && selectedCount > 0) {
@@ -755,7 +754,7 @@ export function LancamentosTable({
};
const showTopControls =
Boolean(onCreate) || Boolean(onMassAdd) || shouldShowFilters;
Boolean(onCreate) || Boolean(onMassAdd) || showFilters;
return (
<TooltipProvider>
@@ -791,15 +790,16 @@ export function LancamentosTable({
) : null}
</div>
) : (
<span className={shouldShowFilters ? "hidden sm:block" : ""} />
<span className={showFilters ? "hidden sm:block" : ""} />
)}
{shouldShowFilters ? (
{showFilters ? (
<LancamentosFilters
pagadorOptions={pagadorFilterOptions}
categoriaOptions={categoriaFilterOptions}
contaCartaoOptions={contaCartaoFilterOptions}
className="w-full lg:flex-1 lg:justify-end"
hideAdvancedFilters={hasOtherUserData}
exportButton={
selectedPeriod ? (
<LancamentosExport

View File

@@ -2,7 +2,7 @@ import MoneyValues from "@/components/money-values";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { PagadorCardUsageItem } from "@/lib/pagadores/details";
import { RiBankCardLine } from "@remixicon/react";
import { RiBankCard2Line } from "@remixicon/react";
import Image from "next/image";
const resolveLogoPath = (logo?: string | null) => {
@@ -36,7 +36,7 @@ export function PagadorCardUsageCard({ items }: PagadorCardUsageCardProps) {
<CardContent className="space-y-3 pt-2">
{items.length === 0 ? (
<WidgetEmptyState
icon={<RiBankCardLine className="size-6 text-muted-foreground" />}
icon={<RiBankCard2Line className="size-6 text-muted-foreground" />}
title="Nenhum lançamento com cartão de crédito"
description="Quando houver despesas registradas com cartão, elas aparecerão aqui."
/>

View File

@@ -0,0 +1,114 @@
"use client";
import { deletePagadorShareAction } from "@/app/(dashboard)/pagadores/actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RiLogoutBoxLine } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
interface PagadorLeaveShareCardProps {
shareId: string;
pagadorName: string;
createdAt: string;
}
export function PagadorLeaveShareCard({
shareId,
pagadorName,
createdAt,
}: PagadorLeaveShareCardProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [showConfirm, setShowConfirm] = useState(false);
const handleLeave = () => {
startTransition(async () => {
const result = await deletePagadorShareAction({ shareId });
if (!result.success) {
toast.error(result.error);
return;
}
toast.success("Você saiu do compartilhamento.");
router.push("/pagadores");
});
};
const formattedDate = new Date(createdAt).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
});
return (
<Card className="border">
<CardHeader>
<CardTitle className="text-base font-semibold">
Acesso Compartilhado
</CardTitle>
<p className="text-sm text-muted-foreground">
Você tem acesso somente leitura aos dados de{" "}
<strong>{pagadorName}</strong>.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2 rounded-lg border border-dashed p-4 text-sm">
<span className="text-xs font-semibold uppercase text-muted-foreground/80">
Informações do compartilhamento
</span>
<div className="flex flex-col gap-1">
<p className="text-sm">
<span className="text-muted-foreground">Acesso desde:</span>{" "}
<strong>{formattedDate}</strong>
</p>
<p className="text-sm text-muted-foreground">
Você pode visualizar os lançamentos, mas não pode criar ou editar.
</p>
</div>
</div>
{!showConfirm ? (
<Button
type="button"
variant="outline"
onClick={() => setShowConfirm(true)}
className="w-full"
>
<RiLogoutBoxLine className="size-4" />
Sair do compartilhamento
</Button>
) : (
<div className="space-y-2">
<p className="text-sm font-medium text-destructive">
Tem certeza que deseja sair? Você perderá o acesso aos dados de{" "}
{pagadorName}.
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setShowConfirm(false)}
disabled={isPending}
className="flex-1"
>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
onClick={handleLeave}
disabled={isPending}
className="flex-1"
>
{isPending ? "Saindo..." : "Confirmar saída"}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,46 +1,46 @@
"use client";
import { formatCurrency, formatPercentageChange } from "@/lib/relatorios/utils";
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import { cn } from "@/lib/utils/ui";
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
interface CategoryCellProps {
value: number;
previousValue: number;
categoryType: "despesa" | "receita";
isFirstMonth: boolean;
value: number;
previousValue: number;
categoryType: "despesa" | "receita";
isFirstMonth: boolean;
}
export function CategoryCell({
value,
previousValue,
categoryType,
isFirstMonth,
value,
previousValue,
categoryType,
isFirstMonth,
}: CategoryCellProps) {
const percentageChange =
!isFirstMonth && previousValue !== 0
? ((value - previousValue) / previousValue) * 100
: null;
const percentageChange =
!isFirstMonth && previousValue !== 0
? ((value - previousValue) / previousValue) * 100
: null;
const isIncrease = percentageChange !== null && percentageChange > 0;
const isDecrease = percentageChange !== null && percentageChange < 0;
const isIncrease = percentageChange !== null && percentageChange > 0;
const isDecrease = percentageChange !== null && percentageChange < 0;
return (
<div className="flex flex-col items-end gap-0.5">
<span className="font-medium">{formatCurrency(value)}</span>
{!isFirstMonth && percentageChange !== null && (
<div
className={cn(
"flex items-center gap-0.5 text-xs",
isIncrease && "text-red-600 dark:text-red-400",
isDecrease && "text-green-600 dark:text-green-400"
)}
>
{isIncrease && <RiArrowUpLine className="h-3 w-3" />}
{isDecrease && <RiArrowDownLine className="h-3 w-3" />}
<span>{formatPercentageChange(percentageChange)}</span>
</div>
)}
return (
<div className="flex flex-col items-end gap-0.5 min-h-9">
<span className="font-medium">{formatCurrency(value)}</span>
{!isFirstMonth && percentageChange !== null && (
<div
className={cn(
"flex items-center gap-0.5 text-xs",
isIncrease && "text-red-600 dark:text-red-400",
isDecrease && "text-green-600 dark:text-green-400"
)}
>
{isIncrease && <RiArrowUpLine className="h-3 w-3" />}
{isDecrease && <RiArrowDownLine className="h-3 w-3" />}
<span>{formatPercentageChange(percentageChange)}</span>
</div>
);
)}
</div>
);
}

View File

@@ -9,13 +9,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodLabel } from "@/lib/relatorios/utils";
import type { CategoryReportData } from "@/lib/relatorios/types";
import { CategoryCell } from "./category-cell";
import { formatCurrency } from "@/lib/relatorios/utils";
import { Card } from "../ui/card";
import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils";
import { getIconComponent } from "@/lib/utils/icons";
import DotIcon from "../dot-icon";
import { Card } from "../ui/card";
import { CategoryCell } from "./category-cell";
interface CategoryReportTableProps {
data: CategoryReportData;
@@ -88,16 +87,19 @@ export function CategoryReportTable({ data }: CategoryReportTableProps) {
<TableFooter>
<TableRow>
<TableCell>Total Geral</TableCell>
<TableCell className="min-h-[2.5rem]">Total Geral</TableCell>
{periods.map((period) => {
const periodTotal = totals.get(period) ?? 0;
return (
<TableCell key={period} className="text-right font-semibold">
<TableCell
key={period}
className="text-right font-semibold min-h-8"
>
{formatCurrency(periodTotal)}
</TableCell>
);
})}
<TableCell className="text-right font-semibold">
<TableCell className="text-right font-semibold min-h-8">
{formatCurrency(grandTotal)}
</TableCell>
</TableRow>

View File

@@ -1,17 +1,19 @@
import {
RiArchiveLine,
RiArrowLeftRightLine,
RiBankCardLine,
RiBankCard2Line,
RiBankLine,
RiCalendarEventLine,
RiDashboardLine,
RiFileChartLine,
RiFundsLine,
RiGroupLine,
RiNoCreditCardLine,
RiPriceTag3Line,
RiSettingsLine,
RiSparklingLine,
RiTodoLine,
RiEyeOffLine,
type RemixiconComponentType,
} from "@remixicon/react";
@@ -98,12 +100,28 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
{
title: "Cartões",
url: "/cartoes",
icon: RiBankCardLine,
icon: RiBankCard2Line,
items: [
{
title: "Inativos",
url: "/cartoes/inativos",
key: "cartoes-inativos",
icon: RiNoCreditCardLine,
},
],
},
{
title: "Contas",
url: "/contas",
icon: RiBankLine,
items: [
{
title: "Inativas",
url: "/contas/inativos",
key: "contas-inativos",
icon: RiEyeOffLine,
},
],
},
{
title: "Orçamentos",

View File

@@ -21,7 +21,7 @@ import {
import { getAvatarSrc } from "@/lib/pagadores/utils";
import {
RiArrowRightSLine,
RiStackshareLine,
RiUserSharedLine,
type RemixiconComponentType,
} from "@remixicon/react";
import Link from "next/link";
@@ -182,7 +182,7 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
) : null}
<span>{subItem.title}</span>
{subItem.isShared ? (
<RiStackshareLine className="size-3.5 text-muted-foreground" />
<RiUserSharedLine className="size-3.5 text-muted-foreground" />
) : null}
</Link>
</SidebarMenuSubButton>