chore: prepara versão 2.5.5

Filtros multi-seleção em lançamentos (condição, forma de pagamento, pessoa,
categoria, conta/cartão), changelog redesenhado como timeline colapsável com
detecção de bump e resumo, e diálogos migrados para as animações utilitárias
do tw-animate-css. Inclui ajustes de label no BulkActionDialog, refinamentos
visuais na landing page e atualização da navbar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-07 13:11:59 +00:00
parent 18893bfe02
commit a6fba5f953
20 changed files with 873 additions and 505 deletions

View File

@@ -76,11 +76,11 @@ const capitalize = (value: string) =>
const EMPTY_FILTERS: TransactionSearchFilters = {
transactionFilter: null,
conditionFilter: null,
paymentFilter: null,
payerFilter: null,
categoryFilter: null,
accountCardFilter: null,
conditionFilters: [],
paymentFilters: [],
payerFilters: [],
categoryFilters: [],
accountCardFilters: [],
searchFilter: null,
settledFilter: null,
attachmentFilter: null,

View File

@@ -208,7 +208,7 @@ export default async function Page() {
</section>
{/* Features Section */}
<section id="funcionalidades" className="py-12 md:py-24 bg-muted/40">
<section id="funcionalidades" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-6xl">
<AnimateOnScroll>
@@ -447,7 +447,7 @@ export default async function Page() {
</section>
{/* Tech Stack Section */}
<section id="stack" className="py-12 md:py-24 bg-muted/40">
<section id="stack" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-6xl">
<AnimateOnScroll>
@@ -535,7 +535,7 @@ export default async function Page() {
</section>
{/* Who is this for Section */}
<section id="para-quem-e" className="py-12 md:py-24 bg-muted/40">
<section id="para-quem-e" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-4xl">
<AnimateOnScroll>

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@@ -269,54 +270,6 @@
mix-blend-mode: normal;
}
@keyframes dialog-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlay-out {
from { opacity: 1; }
to { opacity: 0; }
}
[data-slot="dialog-overlay"][data-state="open"] {
animation: overlay-in 0.2s ease-out;
}
[data-slot="dialog-overlay"][data-state="closed"] {
animation: overlay-out 0.15s ease-in;
}
[data-slot="dialog-content"][data-state="open"] {
animation: dialog-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
[data-slot="dialog-content"][data-state="closed"] {
animation: dialog-out 0.15s ease-in;
}
@keyframes blink-in {
0%, 40% { opacity: 1; }
50%, 90% { opacity: 0; }

View File

@@ -1,20 +1,26 @@
import Link from "next/link";
import type { ChangelogVersion } from "@/features/settings/lib/parse-changelog";
import { Badge } from "@/shared/components/ui/badge";
import { Card } from "@/shared/components/ui/card";
"use client";
/** Converte "[texto](url)" em link; texto simples fica como está */
function parseContributorLine(content: string) {
const linkMatch = content.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/);
if (linkMatch) {
return { label: linkMatch[1], url: linkMatch[2] };
}
return { label: content, url: null };
}
import { RiArrowDownSLine } from "@remixicon/react";
import { format, parseISO } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useEffect, useMemo, useState } from "react";
import type {
BumpType,
ChangelogVersion,
} from "@/features/settings/lib/changelog-types";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible";
import { cn } from "@/shared/utils/ui";
const sectionBadgeVariant: Record<
string,
"success" | "info" | "destructive" | "secondary" | "outline"
"success" | "info" | "destructive" | "outline" | "secondary"
> = {
Adicionado: "success",
Alterado: "info",
@@ -22,75 +28,211 @@ const sectionBadgeVariant: Record<
Removido: "destructive",
};
function getSectionVariant(type: string) {
return sectionBadgeVariant[type] ?? "secondary";
const dotByBump: Record<BumpType, string> = {
major: "size-4 bg-primary",
minor: "size-3 bg-primary/80",
patch: "size-2.5 bg-muted-foreground/40",
};
const bumpLabel: Record<BumpType, string> = {
major: "Major",
minor: "Minor",
patch: "Patch",
};
function versionAnchorId(version: string) {
return `v${version.replace(/\./g, "-")}`;
}
function anchorIdToVersion(id: string): string | null {
if (!id.startsWith("v")) return null;
return id.slice(1).replace(/-/g, ".");
}
function groupByMonth(versions: ChangelogVersion[]) {
const groups: { key: string; label: string; items: ChangelogVersion[] }[] =
[];
for (const v of versions) {
const date = parseISO(v.isoDate);
const key = Number.isNaN(date.getTime())
? v.isoDate.slice(0, 7)
: format(date, "yyyy-MM");
const label = Number.isNaN(date.getTime())
? key
: format(date, "MMMM 'de' yyyy", { locale: ptBR });
const last = groups.at(-1);
if (last?.key === key) last.items.push(v);
else groups.push({ key, label, items: [v] });
}
return groups;
}
function VersionDetails({ version }: { version: ChangelogVersion }) {
return (
<Card className="space-y-4 p-4 bg-primary/5 dark:bg-primary/5">
{version.sections.map((section) => (
<div key={section.type}>
<Badge
variant={sectionBadgeVariant[section.type] ?? "secondary"}
className="mb-2"
>
{section.type}
</Badge>
<ul className="space-y-2 text-muted-foreground">
{section.items.map((item) => (
<li key={item} className="flex gap-2">
<span className="text-primary">&bull;</span>
<span className="text-sm">{item}</span>
</li>
))}
</ul>
</div>
))}
</Card>
);
}
type TimelineItemProps = {
version: ChangelogVersion;
open: boolean;
onOpenChange: (open: boolean) => void;
isLatest: boolean;
};
function TimelineItem({
version,
open,
onOpenChange,
isLatest,
}: TimelineItemProps) {
const hasDetails = version.sections.length > 0;
const date = parseISO(version.isoDate);
const validDate = !Number.isNaN(date.getTime());
return (
<div className="flex gap-4" id={versionAnchorId(version.version)}>
<div className="flex flex-col items-center pt-1.5">
<span
className={cn(
"rounded-full ring-4 ring-background shrink-0",
dotByBump[version.bump],
)}
aria-label={`Versão ${bumpLabel[version.bump].toLowerCase()}`}
/>
<span className="w-px flex-1 bg-border mt-2" aria-hidden="true" />
</div>
<div className="flex-1 pb-6 space-y-3 min-w-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-semibold font-mono">v{version.version}</h3>
{isLatest ? (
<Badge variant="default" className="text-xs">
Atual
</Badge>
) : null}
<time
className="font-mono text-xs uppercase tracking-wider text-muted-foreground"
dateTime={version.isoDate}
>
{validDate
? format(date, "dd MMM, yyyy", { locale: ptBR }).toUpperCase()
: version.date}
</time>
</div>
{version.summary ? (
<Card className="p-4">
<blockquote className="pl-2 text-sm text-muted-foreground leading-relaxed italic">
{version.summary}
</blockquote>
</Card>
) : null}
{hasDetails ? (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<Button
variant="link"
size="sm"
className="text-muted-foreground hover:text-foreground text-xs px-0"
>
<RiArrowDownSLine
className={cn(
"size-4 transition-transform",
open && "rotate-180",
)}
/>
{open ? "Ocultar detalhes" : "Ver detalhes"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2 overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2">
<VersionDetails version={version} />
</CollapsibleContent>
</Collapsible>
) : null}
</div>
</div>
);
}
export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
const [openVersions, setOpenVersions] = useState<Set<string>>(() => {
const initial = new Set<string>();
const first = versions[0]?.version;
if (first) initial.add(first);
return initial;
});
useEffect(() => {
if (typeof window === "undefined") return;
const hash = window.location.hash.slice(1);
if (!hash) return;
const target = anchorIdToVersion(hash);
if (target) {
setOpenVersions((prev) => {
if (prev.has(target)) return prev;
const next = new Set(prev);
next.add(target);
return next;
});
}
requestAnimationFrame(() => {
const el = document.getElementById(hash);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
});
}, []);
const groups = useMemo(() => groupByMonth(versions), [versions]);
const latestVersion = versions[0]?.version;
const setVersionOpen = (version: string, isOpen: boolean) => {
setOpenVersions((prev) => {
const next = new Set(prev);
if (isOpen) next.add(version);
else next.delete(version);
return next;
});
};
return (
<div className="space-y-4">
{versions.map((version) => (
<Card key={version.version} className="p-6">
<div className="flex items-baseline gap-3">
<h3 className="text-lg font-semibold">v{version.version}</h3>
<span className="text-sm text-muted-foreground">
{version.date}
</span>
</div>
<div className="space-y-4 w-full mx-auto sm:w-3/4">
{version.summary && (
<p className="border-l-2 border-muted-foreground/25 pl-3 text-sm text-muted-foreground/80 leading-relaxed italic">
{version.summary}
</p>
)}
{version.sections.map((section) => (
<div key={section.type}>
<Badge
variant={getSectionVariant(section.type)}
className="mb-2"
>
{section.type}
</Badge>
<ul className="space-y-2 text-muted-foreground leading-relaxed text-pretty">
{section.items.map((item) => (
<li key={item} className="flex gap-2">
<span className="text-primary">&bull;</span>
<span className="text-sm">{item}</span>
</li>
))}
</ul>
</div>
<div className="space-y-8 max-w-4xl mx-auto">
{groups.map((group) => (
<div key={group.key} className="space-y-4">
<h2 className="sticky top-0 z-10 py-2 font-semibold uppercase text-primary">
{group.label}
</h2>
<div>
{group.items.map((version) => (
<TimelineItem
key={version.version}
version={version}
isLatest={version.version === latestVersion}
open={openVersions.has(version.version)}
onOpenChange={(o) => setVersionOpen(version.version, o)}
/>
))}
{version.contributor && (
<div className="border-t pt-4 mt-4">
<span className="text-sm text-muted-foreground">
Contribuições: {(() => {
const { label, url } = parseContributorLine(
version.contributor,
);
if (url) {
return (
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary"
>
{label}
</Link>
);
}
return (
<span className="font-medium text-foreground">
{label}
</span>
);
})()}
</span>
</div>
)}
</div>
</Card>
</div>
))}
</div>
);

View File

@@ -0,0 +1,30 @@
export type SectionType = "Adicionado" | "Alterado" | "Corrigido" | "Removido";
export const SECTION_TYPES: readonly SectionType[] = [
"Adicionado",
"Alterado",
"Corrigido",
"Removido",
];
export type ChangelogSection = {
type: SectionType;
items: string[];
};
export type BumpType = "major" | "minor" | "patch";
export type ChangelogVersion = {
version: string;
/** Formato exibido "DD/MM/YYYY". */
date: string;
/** Data ISO crua "YYYY-MM-DD" para ordenação e formatação client-side. */
isoDate: string;
bump: BumpType;
summary?: string;
sections: ChangelogSection[];
};
export function isSectionType(value: string): value is SectionType {
return (SECTION_TYPES as readonly string[]).includes(value);
}

View File

@@ -1,21 +1,27 @@
import "server-only";
import fs from "node:fs";
import path from "node:path";
import {
type BumpType,
type ChangelogSection,
type ChangelogVersion,
isSectionType,
} from "@/features/settings/lib/changelog-types";
type ChangelogSection = {
type: string;
items: string[];
};
function diffBump(current: string, previous: string | undefined): BumpType {
if (!previous) return "minor";
const [aMajor = 0, aMinor = 0] = current.split(".").map(Number);
const [bMajor = 0, bMinor = 0] = previous.split(".").map(Number);
if (aMajor !== bMajor) return "major";
if (aMinor !== bMinor) return "minor";
return "patch";
}
export type ChangelogVersion = {
version: string;
date: string;
summary?: string;
sections: ChangelogSection[];
/** Linha de contribuições/autor (pode conter markdown, ex: [Nome](url)) */
contributor?: string;
};
let cached: ChangelogVersion[] | null = null;
export function parseChangelog(): ChangelogVersion[] {
if (cached) return cached;
const filePath = path.join(process.cwd(), "CHANGELOG.md");
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
@@ -31,10 +37,14 @@ export function parseChangelog(): ChangelogVersion[] {
if (currentSection && currentVersion) {
currentVersion.sections.push(currentSection);
}
const [y, m, d] = versionMatch[2].split("-");
const version = versionMatch[1] ?? "";
const isoDate = versionMatch[2] ?? "";
const [y, m, d] = isoDate.split("-");
currentVersion = {
version: versionMatch[1],
date: d && m && y ? `${d}/${m}/${y}` : versionMatch[2],
version,
isoDate,
date: d && m && y ? `${d}/${m}/${y}` : isoDate,
bump: "patch",
sections: [],
};
versions.push(currentVersion);
@@ -52,33 +62,35 @@ export function parseChangelog(): ChangelogVersion[] {
if (currentSection) {
currentVersion.sections.push(currentSection);
}
currentSection = { type: sectionMatch[1], items: [] };
const type = sectionMatch[1] ?? "";
currentSection = isSectionType(type) ? { type, items: [] } : null;
continue;
}
const itemMatch = line.match(/^- (.+)$/);
if (itemMatch && currentSection) {
currentSection.items.push(itemMatch[1]);
currentSection.items.push(itemMatch[1] ?? "");
continue;
}
if (currentVersion && !currentSection && line.trim()) {
summaryLines.push(line.trim());
continue;
}
// **Contribuições:** ou **Autor:** com texto/link opcional
const contributorMatch = line.match(
/^\*\*(?:Contribuições|Autor):\*\*\s*(.+)$/,
);
if (contributorMatch && currentVersion) {
currentVersion.contributor = contributorMatch[1].trim() || undefined;
}
}
if (currentSection && currentVersion) {
currentVersion.sections.push(currentSection);
}
if (currentVersion && !currentVersion.summary && summaryLines.length > 0) {
currentVersion.summary = summaryLines.join(" ").trim();
}
for (let i = 0; i < versions.length; i++) {
const current = versions[i];
if (!current) continue;
current.bump = diffBump(current.version, versions[i + 1]?.version);
}
cached = versions;
return versions;
}

View File

@@ -25,11 +25,11 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
period: z.string().regex(/^\d{4}-\d{2}$/),
filters: z.object({
transactionFilter: z.string().nullable(),
conditionFilter: z.string().nullable(),
paymentFilter: z.string().nullable(),
payerFilter: z.string().nullable(),
categoryFilter: z.string().nullable(),
accountCardFilter: z.string().nullable(),
conditionFilters: z.array(z.string()),
paymentFilters: z.array(z.string()),
payerFilters: z.array(z.string()),
categoryFilters: z.array(z.string()),
accountCardFilters: z.array(z.string()),
searchFilter: z.string().nullable(),
settledFilter: z.string().nullable(),
attachmentFilter: z.string().nullable(),

View File

@@ -116,10 +116,10 @@ export function BulkActionDialog({
htmlFor="period"
className="text-sm cursor-pointer font-medium"
>
Todas as pessoas deste período
{`Todas as pessoas desta parcela (${currentNumber}/${totalCount})`}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a todos os lançamentos deste mesmo mês na série
Aplica a alteração para todas as pessoas que dividem esta parcela
</p>
{scope === "period" && actionType === "edit" && (
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">

View File

@@ -1,5 +1,6 @@
"use client";
import { RiAddFill } from "@remixicon/react";
import { useState } from "react";
import { toast } from "sonner";
import {
@@ -18,6 +19,7 @@ import {
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button";
import type {
TransactionsExportContext,
TransactionsPaginationState,
@@ -115,7 +117,6 @@ export function TransactionsPage({
const [selectedTransaction, setSelectedTransaction] =
useState<TransactionItem | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [copyOpen, setCopyOpen] = useState(false);
const [transactionToCopy, setTransactionToCopy] =
useState<TransactionItem | null>(null);
@@ -411,15 +412,6 @@ export function TransactionsPage({
setPendingMultipleDeleteData([]);
};
const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
"Despesa" | "Receita" | null
>(null);
const handleCreate = (type: "Despesa" | "Receita") => {
setTransactionTypeForCreate(type);
setCreateOpen(true);
};
const handleMassAdd = () => {
setMassAddOpen(true);
};
@@ -558,6 +550,57 @@ export function TransactionsPage({
setAnticipationHistoryOpen(true);
};
const createSlot = allowCreate ? (
<>
<TransactionDialog
mode="create"
payerOptions={payerOptions}
splitPayerOptions={splitPayerOptions}
defaultPayerId={defaultPayerId}
accountOptions={accountOptions}
cardOptions={cardOptions}
categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod}
defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod}
defaultTransactionType="Receita"
maxSizeMb={attachmentMaxSizeMb}
trigger={
<Button className="w-full sm:w-auto">
<RiAddFill className="size-4" />
Nova Receita
</Button>
}
/>
<TransactionDialog
mode="create"
payerOptions={payerOptions}
splitPayerOptions={splitPayerOptions}
defaultPayerId={defaultPayerId}
accountOptions={accountOptions}
cardOptions={cardOptions}
categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod}
defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod}
defaultTransactionType="Despesa"
maxSizeMb={attachmentMaxSizeMb}
trigger={
<Button className="w-full sm:w-auto">
<RiAddFill className="size-4" />
Nova Despesa
</Button>
}
/>
</>
) : null;
return (
<>
<TransactionsTable
@@ -571,7 +614,7 @@ export function TransactionsPage({
selectedPeriod={selectedPeriod}
pagination={pagination}
exportContext={exportContext}
onCreate={allowCreate ? handleCreate : undefined}
createSlot={createSlot}
onMassAdd={allowCreate ? handleMassAdd : undefined}
onEdit={handleEdit}
onCopy={handleCopy}
@@ -587,28 +630,6 @@ export function TransactionsPage({
isSettlementLoading={(id) => settlementLoadingId === id}
/>
{allowCreate ? (
<TransactionDialog
mode="create"
open={createOpen}
onOpenChange={setCreateOpen}
payerOptions={payerOptions}
splitPayerOptions={splitPayerOptions}
defaultPayerId={defaultPayerId}
accountOptions={accountOptions}
cardOptions={cardOptions}
categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod}
defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod}
defaultTransactionType={transactionTypeForCreate ?? undefined}
maxSizeMb={attachmentMaxSizeMb}
/>
) : null}
<TransactionDialog
mode="create"
open={copyOpen && !!transactionToCopy}

View File

@@ -2,6 +2,7 @@
import {
RiCheckLine,
RiCloseLine,
RiExpandUpDownLine,
RiFilter3Line,
} from "@remixicon/react";
@@ -10,6 +11,7 @@ import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
@@ -20,6 +22,7 @@ import {
TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
Command,
CommandEmpty,
@@ -46,9 +49,7 @@ import {
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
} from "@/shared/components/ui/select";
import { Switch } from "@/shared/components/ui/switch";
@@ -127,6 +128,158 @@ function FilterSelect({
);
}
type MultiOption = {
value: string;
label: string;
group?: string;
render?: ReactNode;
};
interface MultiSelectFilterProps {
placeholder: string;
options: MultiOption[];
selected: string[];
onChange: (values: string[]) => void;
widthClass?: string;
disabled?: boolean;
searchable?: boolean;
searchPlaceholder?: string;
groupOrder?: string[];
}
function MultiSelectFilter({
placeholder,
options,
selected,
onChange,
widthClass = "w-full",
disabled,
searchable = false,
searchPlaceholder = "Buscar...",
groupOrder,
}: MultiSelectFilterProps) {
const [open, setOpen] = useState(false);
const groupedOptions = useMemo(() => {
const map = new Map<string, MultiOption[]>();
for (const option of options) {
const key = option.group ?? "";
const list = map.get(key) ?? [];
list.push(option);
map.set(key, list);
}
const orderedKeys = groupOrder
? [
...groupOrder,
...Array.from(map.keys()).filter((k) => !groupOrder.includes(k)),
]
: Array.from(map.keys());
return orderedKeys
.filter((key) => map.has(key))
.map((key) => ({ name: key, items: map.get(key) ?? [] }));
}, [options, groupOrder]);
const selectedSet = new Set(selected);
const selectedOptions = options.filter((option) =>
selectedSet.has(option.value),
);
const toggle = (value: string) => {
if (selectedSet.has(value)) {
onChange(selected.filter((v) => v !== value));
} else {
onChange([...selected, value]);
}
};
const clear = () => {
onChange([]);
};
const triggerLabel: ReactNode =
selectedOptions.length === 0 ? (
placeholder
) : selectedOptions.length === 1 ? (
(selectedOptions[0]?.render ?? selectedOptions[0]?.label)
) : (
<span className="flex items-center gap-1.5">
<span className="text-foreground">
{selectedOptions.length} selecionados
</span>
</span>
);
return (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm border-dashed font-normal",
widthClass,
)}
disabled={disabled}
>
<span className="truncate flex items-center gap-2">
{triggerLabel}
</span>
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[260px] p-0">
<Command>
{searchable ? <CommandInput placeholder={searchPlaceholder} /> : null}
<CommandList>
<CommandEmpty>Nada encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__clear"
onSelect={() => clear()}
disabled={selectedOptions.length === 0}
className="text-muted-foreground data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none"
>
Limpar seleção
</CommandItem>
</CommandGroup>
{groupedOptions.map((group) => (
<CommandGroup
key={group.name || "default"}
heading={group.name || undefined}
>
{group.items.map((option) => {
const isSelected = selectedSet.has(option.value);
return (
<CommandItem
key={option.value}
value={`${option.value} ${option.label}`}
onSelect={() => toggle(option.value)}
className="gap-2"
>
<Checkbox
checked={isSelected}
className="pointer-events-none"
aria-hidden
/>
<span className="flex items-center gap-2 flex-1 min-w-0 truncate">
{option.render ?? option.label}
</span>
{isSelected ? (
<RiCheckLine className="ml-auto size-4 shrink-0" />
) : null}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
interface TransactionsFiltersProps {
payerOptions: TransactionFilterOption[];
categoryOptions: TransactionFilterOption[];
@@ -152,6 +305,11 @@ export function TransactionsFilters({
const getParamValue = (key: string) =>
searchParams.get(key) ?? FILTER_EMPTY_VALUE;
const getParamValues = useCallback(
(key: string) => searchParams.getAll(key),
[searchParams],
);
const handleFilterChange = useCallback(
(key: string, value: string | null) => {
const nextParams = new URLSearchParams(searchParams.toString());
@@ -174,6 +332,27 @@ export function TransactionsFilters({
[searchParams, pathname, router],
);
const handleMultiFilterChange = useCallback(
(key: string, values: string[]) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete(key);
for (const value of values) {
if (value) {
nextParams.append(key, value);
}
}
nextParams.delete("page");
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
},
[searchParams, pathname, router],
);
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
const currentSearchParam = searchParams.get("q") ?? "";
@@ -205,7 +384,6 @@ export function TransactionsFilters({
nextParams.set("pageSize", pageSizeValue);
}
setSearchValue("");
setCategoryOpen(false);
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
@@ -214,56 +392,79 @@ export function TransactionsFilters({
});
};
const payerSelectOptions = payerOptions.map((option) => ({
value: option.slug,
label: option.label,
avatarUrl: option.avatarUrl,
}));
const conditionOptions = useMemo<MultiOption[]>(
() =>
TRANSACTION_CONDITIONS.map((value) => ({
value: slugify(value),
label: value,
render: <ConditionSelectContent label={value} />,
})),
[],
);
const accountOptions = accountCardOptions
.filter((option) => option.kind === "conta")
.map((option) => ({
value: option.slug,
label: option.label,
logo: option.logo,
}));
const paymentOptions = useMemo<MultiOption[]>(
() =>
PAYMENT_METHODS.map((value) => ({
value: slugify(value),
label: value,
render: <PaymentMethodSelectContent label={value} />,
})),
[],
);
const cardOptions = accountCardOptions
.filter((option) => option.kind === "cartao")
.map((option) => ({
value: option.slug,
label: option.label,
logo: option.logo,
}));
const payerMultiOptions = useMemo<MultiOption[]>(
() =>
payerOptions.map((option) => ({
value: option.slug,
label: option.label,
render: (
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
),
})),
[payerOptions],
);
const categoryValue = getParamValue("category");
const selectedCategory =
categoryValue !== FILTER_EMPTY_VALUE
? categoryOptions.find((option) => option.slug === categoryValue)
: null;
const categoryMultiOptions = useMemo<MultiOption[]>(
() =>
categoryOptions.map((option) => ({
value: option.slug,
label: option.label,
render: (
<CategorySelectContent label={option.label} icon={option.icon} />
),
})),
[categoryOptions],
);
const payerValue = getParamValue("payer");
const selectedPayer =
payerValue !== FILTER_EMPTY_VALUE
? payerOptions.find((option) => option.slug === payerValue)
: null;
const accountCardMultiOptions = useMemo<MultiOption[]>(
() =>
accountCardOptions.map((option) => ({
value: option.slug,
label: option.label,
group: option.kind === "cartao" ? "Cartões" : "Contas",
render: (
<AccountCardSelectContent
label={option.label}
logo={option.logo}
isCartao={option.kind === "cartao"}
/>
),
})),
[accountCardOptions],
);
const accountCardValue = getParamValue("accountCard");
const selectedAccountCard =
accountCardValue !== FILTER_EMPTY_VALUE
? accountCardOptions.find((option) => option.slug === accountCardValue)
: null;
const [categoryOpen, setCategoryOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const hasActiveFilters =
searchParams.get("type") ||
searchParams.get("condition") ||
searchParams.get("payment") ||
searchParams.get("payer") ||
searchParams.get("category") ||
searchParams.get("accountCard") ||
searchParams.getAll("condition").length > 0 ||
searchParams.getAll("payment").length > 0 ||
searchParams.getAll("payer").length > 0 ||
searchParams.getAll("category").length > 0 ||
searchParams.getAll("accountCard").length > 0 ||
searchParams.get("settled") ||
searchParams.get("hasAttachment") ||
searchParams.get("isDivided");
@@ -280,13 +481,28 @@ export function TransactionsFilters({
className,
)}
>
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar"
aria-label="Buscar lançamentos"
className="w-full md:w-[250px] text-sm border-dashed"
/>
<div className="relative w-full md:w-[250px]">
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar"
aria-label="Buscar lançamentos"
className={cn(
"w-full text-sm border-dashed",
searchValue.length > 0 && "pr-8",
)}
/>
{searchValue.length > 0 ? (
<button
type="button"
onClick={() => setSearchValue("")}
aria-label="Limpar busca"
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<RiCloseLine className="size-4" />
</button>
) : null}
</div>
<div className="flex w-full gap-2 md:w-auto">
{exportButton && (
@@ -348,20 +564,14 @@ export function TransactionsFilters({
<label className="text-sm font-medium">
Condição de Lançamento
</label>
<FilterSelect
param="condition"
<MultiSelectFilter
placeholder="Todas"
options={TRANSACTION_CONDITIONS.map((v) => ({
value: slugify(v),
label: v,
}))}
widthClass="w-full border-dashed"
options={conditionOptions}
selected={getParamValues("condition")}
onChange={(values) =>
handleMultiFilterChange("condition", values)
}
disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => (
<ConditionSelectContent label={label} />
)}
/>
</div>
@@ -369,195 +579,61 @@ export function TransactionsFilters({
<label className="text-sm font-medium">
Forma de Pagamento
</label>
<FilterSelect
param="payment"
placeholder="Todos"
options={PAYMENT_METHODS.map((v) => ({
value: slugify(v),
label: v,
}))}
widthClass="w-full border-dashed"
<MultiSelectFilter
placeholder="Todas"
options={paymentOptions}
selected={getParamValues("payment")}
onChange={(values) =>
handleMultiFilterChange("payment", values)
}
disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => (
<PaymentMethodSelectContent label={label} />
)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Pessoa</label>
<Select
value={getParamValue("payer")}
onValueChange={(value) =>
handleFilterChange(
"payer",
value === FILTER_EMPTY_VALUE ? null : value,
)
<MultiSelectFilter
placeholder="Todas"
options={payerMultiOptions}
selected={getParamValues("payer")}
onChange={(values) =>
handleMultiFilterChange("payer", values)
}
disabled={isPending}
>
<SelectTrigger
className="w-full text-sm border-dashed"
disabled={isPending}
>
<span className="truncate">
{selectedPayer ? (
<PayerSelectContent
label={selectedPayer.label}
avatarUrl={selectedPayer.avatarUrl}
/>
) : (
"Todos"
)}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
{payerSelectOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
searchable
searchPlaceholder="Buscar pessoa..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Categoria</label>
<Popover
open={categoryOpen}
onOpenChange={setCategoryOpen}
modal
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={categoryOpen}
className="w-full justify-between text-sm border-dashed"
disabled={isPending}
>
<span className="truncate flex items-center gap-2">
{selectedCategory ? (
<CategorySelectContent
label={selectedCategory.label}
icon={selectedCategory.icon}
/>
) : (
"Todas"
)}
</span>
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[220px] p-0">
<Command>
<CommandInput placeholder="Buscar categoria..." />
<CommandList>
<CommandEmpty>Nada encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
value={FILTER_EMPTY_VALUE}
onSelect={() => {
handleFilterChange("category", null);
setCategoryOpen(false);
}}
>
Todas
{categoryValue === FILTER_EMPTY_VALUE ? (
<RiCheckLine className="ml-auto size-4" />
) : null}
</CommandItem>
{categoryOptions.map((option) => (
<CommandItem
key={option.slug}
value={option.slug}
onSelect={() => {
handleFilterChange("category", option.slug);
setCategoryOpen(false);
}}
>
<CategorySelectContent
label={option.label}
icon={option.icon}
/>
{categoryValue === option.slug ? (
<RiCheckLine className="ml-auto size-4" />
) : null}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<MultiSelectFilter
placeholder="Todas"
options={categoryMultiOptions}
selected={getParamValues("category")}
onChange={(values) =>
handleMultiFilterChange("category", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar categoria..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Conta/Cartão</label>
<Select
value={getParamValue("accountCard")}
onValueChange={(value) =>
handleFilterChange(
"accountCard",
value === FILTER_EMPTY_VALUE ? null : value,
)
<MultiSelectFilter
placeholder="Todos"
options={accountCardMultiOptions}
selected={getParamValues("accountCard")}
onChange={(values) =>
handleMultiFilterChange("accountCard", values)
}
disabled={isPending}
>
<SelectTrigger
className="w-full text-sm border-dashed"
disabled={isPending}
>
<span className="truncate">
{selectedAccountCard ? (
<AccountCardSelectContent
label={selectedAccountCard.label}
logo={selectedAccountCard.logo}
isCartao={selectedAccountCard.kind === "cartao"}
/>
) : (
"Todos"
)}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
{accountOptions.length > 0 ? (
<SelectGroup>
<SelectLabel>Contas</SelectLabel>
{accountOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectGroup>
) : null}
{cardOptions.length > 0 ? (
<SelectGroup>
<SelectLabel>Cartões</SelectLabel>
{cardOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))}
</SelectGroup>
) : null}
</SelectContent>
</Select>
searchable
searchPlaceholder="Buscar conta ou cartão..."
groupOrder={["Contas", "Cartões"]}
/>
</div>
<div className="space-y-3">

View File

@@ -1,6 +1,5 @@
"use client";
import {
RiAddFill,
RiArrowLeftRightLine,
RiFileExcel2Line,
RiFlashlightFill,
@@ -16,7 +15,7 @@ import {
type VisibilityState,
} from "@tanstack/react-table";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState } from "react";
import { type ReactNode, useMemo, useState } from "react";
import type {
TransactionsExportContext,
TransactionsPaginationState,
@@ -61,7 +60,7 @@ type TransactionsTableProps = {
selectedPeriod?: string;
pagination?: TransactionsPaginationState;
exportContext?: TransactionsExportContext;
onCreate?: (type: "Despesa" | "Receita") => void;
createSlot?: ReactNode;
onMassAdd?: () => void;
onEdit?: (item: TransactionItem) => void;
onCopy?: (item: TransactionItem) => void;
@@ -90,7 +89,7 @@ export function TransactionsTable({
selectedPeriod,
pagination: serverPagination,
exportContext,
onCreate,
createSlot,
onMassAdd,
onEdit,
onCopy,
@@ -253,32 +252,15 @@ export function TransactionsTable({
};
const showTopControls =
Boolean(onCreate) || Boolean(onMassAdd) || showFilters;
Boolean(createSlot) || Boolean(onMassAdd) || showFilters;
return (
<TooltipProvider>
{showTopControls ? (
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
{onCreate || onMassAdd ? (
{createSlot || onMassAdd ? (
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
{onCreate ? (
<>
<Button
onClick={() => onCreate("Receita")}
className="w-full sm:w-auto"
>
<RiAddFill className="size-4" />
Nova Receita
</Button>
<Button
onClick={() => onCreate("Despesa")}
className="w-full sm:w-auto"
>
<RiAddFill className="size-4" />
Nova Despesa
</Button>
</>
) : null}
{createSlot}
{onMassAdd ? (
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -1,10 +1,10 @@
type TransactionExportFilters = {
transactionFilter: string | null;
conditionFilter: string | null;
paymentFilter: string | null;
payerFilter: string | null;
categoryFilter: string | null;
accountCardFilter: string | null;
conditionFilters: string[];
paymentFilters: string[];
payerFilters: string[];
categoryFilters: string[];
accountCardFilters: string[];
searchFilter: string | null;
settledFilter: string | null;
attachmentFilter: string | null;

View File

@@ -1,5 +1,5 @@
import type { SQL } from "drizzle-orm";
import { and, eq, ilike, isNotNull, or, sql } from "drizzle-orm";
import { and, eq, ilike, inArray, isNotNull, or, sql } from "drizzle-orm";
import {
cards,
type categories,
@@ -37,11 +37,11 @@ const TRANSACTIONS_PAGE_SIZE_OPTIONS = [5, 10, 20, 30, 40, 50, 100];
export type TransactionSearchFilters = {
transactionFilter: string | null;
conditionFilter: string | null;
paymentFilter: string | null;
payerFilter: string | null;
categoryFilter: string | null;
accountCardFilter: string | null;
conditionFilters: string[];
paymentFilters: string[];
payerFilters: string[];
categoryFilters: string[];
accountCardFilters: string[];
searchFilter: string | null;
settledFilter: string | null;
attachmentFilter: string | null;
@@ -123,15 +123,27 @@ export const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export const getMultiParam = (
params: ResolvedSearchParams,
key: string,
): string[] => {
const value = params?.[key];
if (!value) {
return [];
}
const list = Array.isArray(value) ? value : [value];
return list.filter((item): item is string => Boolean(item));
};
export const extractTransactionSearchFilters = (
params: ResolvedSearchParams,
): TransactionSearchFilters => ({
transactionFilter: getSingleParam(params, "type"),
conditionFilter: getSingleParam(params, "condition"),
paymentFilter: getSingleParam(params, "payment"),
payerFilter: getSingleParam(params, "payer"),
categoryFilter: getSingleParam(params, "category"),
accountCardFilter: getSingleParam(params, "accountCard"),
conditionFilters: getMultiParam(params, "condition"),
paymentFilters: getMultiParam(params, "payment"),
payerFilters: getMultiParam(params, "payer"),
categoryFilters: getMultiParam(params, "category"),
accountCardFilters: getMultiParam(params, "accountCard"),
searchFilter: getSingleParam(params, "q"),
settledFilter: getSingleParam(params, "settled"),
attachmentFilter: getSingleParam(params, "hasAttachment"),
@@ -354,41 +366,63 @@ export const buildTransactionWhere = ({
where.push(eq(transactions.transactionType, typeValue));
}
const conditionValue =
conditionSlugToValue[filters.conditionFilter ?? ""] ?? null;
if (isValidCondition(conditionValue)) {
where.push(eq(transactions.condition, conditionValue));
const conditionValues = filters.conditionFilters
.map((slug) => conditionSlugToValue[slug] ?? null)
.filter(isValidCondition);
if (conditionValues.length > 0) {
where.push(inArray(transactions.condition, conditionValues));
}
const paymentValue = paymentSlugToValue[filters.paymentFilter ?? ""] ?? null;
if (isValidPaymentMethod(paymentValue)) {
where.push(eq(transactions.paymentMethod, paymentValue));
const paymentValues = filters.paymentFilters
.map((slug) => paymentSlugToValue[slug] ?? null)
.filter(isValidPaymentMethod);
if (paymentValues.length > 0) {
where.push(inArray(transactions.paymentMethod, paymentValues));
}
if (!payerId && filters.payerFilter) {
const id = slugMaps.payer.get(filters.payerFilter);
if (id) {
where.push(eq(transactions.payerId, id));
if (!payerId && filters.payerFilters.length > 0) {
const ids = filters.payerFilters
.map((slug) => slugMaps.payer.get(slug))
.filter((id): id is string => Boolean(id));
if (ids.length > 0) {
where.push(inArray(transactions.payerId, ids));
}
}
if (filters.categoryFilter) {
const id = slugMaps.category.get(filters.categoryFilter);
if (id) {
where.push(eq(transactions.categoryId, id));
if (filters.categoryFilters.length > 0) {
const ids = filters.categoryFilters
.map((slug) => slugMaps.category.get(slug))
.filter((id): id is string => Boolean(id));
if (ids.length > 0) {
where.push(inArray(transactions.categoryId, ids));
}
}
if (filters.accountCardFilter) {
const accountId = slugMaps.financialAccount.get(filters.accountCardFilter);
const relatedCardId = accountId
? null
: slugMaps.card.get(filters.accountCardFilter);
if (accountId) {
where.push(eq(transactions.accountId, accountId));
if (filters.accountCardFilters.length > 0) {
const accountIds: string[] = [];
const cardIds: string[] = [];
for (const slug of filters.accountCardFilters) {
const accountId = slugMaps.financialAccount.get(slug);
if (accountId) {
accountIds.push(accountId);
continue;
}
const cardId = slugMaps.card.get(slug);
if (cardId) {
cardIds.push(cardId);
}
}
if (!accountId && relatedCardId) {
where.push(eq(transactions.cardId, relatedCardId));
if (accountIds.length > 0 && cardIds.length > 0) {
where.push(
or(
inArray(transactions.accountId, accountIds),
inArray(transactions.cardId, cardIds),
) as SQL,
);
} else if (accountIds.length > 0) {
where.push(inArray(transactions.accountId, accountIds));
} else if (cardIds.length > 0) {
where.push(inArray(transactions.cardId, cardIds));
}
}

View File

@@ -190,7 +190,7 @@ export function NavbarUser({
>
<RiMegaphoneLine className="size-4 text-success shrink-0" />
<span className="flex-1 tracking-wide text-xs font-bold">
Atualização {updateCheck.latestVersion} disponível
Versão {updateCheck.latestVersion} disponível
</span>
</Link>
)}

View File

@@ -22,8 +22,8 @@ function Checkbox({
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<RiCheckLine className="size-3.5 [[data-state=indeterminate]_&]:hidden" />
<RiSubtractLine className="size-3.5 hidden [[data-state=indeterminate]_&]:block" />
<RiCheckLine className="size-3.5 text-current [[data-state=indeterminate]_&]:hidden" />
<RiSubtractLine className="size-3.5 hidden text-current [[data-state=indeterminate]_&]:block" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);

View File

@@ -36,7 +36,10 @@ function DialogOverlay({
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn("fixed inset-0 z-50 bg-black/50", className)}
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
@@ -56,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background fixed top-[50%] left-[50%] z-50 grid w-full min-w-0 max-w-[calc(100%-2rem)] max-h-[90vh] overflow-x-hidden overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-10 sm:max-w-xl",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full min-w-0 max-w-[calc(100%-2rem)] max-h-[90vh] overflow-x-hidden overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg duration-200 sm:p-10 sm:max-w-xl",
className,
)}
{...props}