mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-11 03:31:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">•</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">•</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>
|
||||
);
|
||||
|
||||
30
src/features/settings/lib/changelog-types.ts
Normal file
30
src/features/settings/lib/changelog-types.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user