feat: implementar sistema de preferências do usuário e refatorar changelog

Adiciona sistema completo de preferências de usuário:
  - Cria tabela userPreferences no schema com campos disableMagnetlines, periodMonthsBefore e periodMonthsAfter
  - Implementa página de Ajustes com abas (Preferências, Alterar nome, Senha, E-mail, Deletar conta)
  - Adiciona componente PreferencesForm para configuração de magnetlines e períodos de exibição
  - Propaga periodPreferences para todos os componentes de lançamentos e calendário

  Refatora sistema de changelog:
  - Remove implementação anterior baseada em JSON estático
  - Adiciona nova página de changelog dinâmica em app/(dashboard)/changelog
  - Adiciona componente changelog-list.tsx
  - Remove arquivos obsoletos (changelog-notification, actions, data, utils, scripts)

  Adiciona controle de saldo inicial em contas:
  - Novo campo excludeInitialBalanceFromIncome em contas
  - Permite excluir saldo inicial do cálculo de receitas
  - Atualiza queries de lançamentos para respeitar esta configuração

  Melhorias adicionais:
  - Adiciona componente ui/accordion.tsx do shadcn/ui
  - Refatora formatPeriodLabel para displayPeriod centralizado
  - Propaga estabelecimentos para componentes de lançamentos
  - Remove variável DB_PROVIDER obsoleta do .env.example e documentação
  - Adiciona 6 migrações de banco de dados (0003-0008)
This commit is contained in:
Felipe Coutinho
2026-01-03 14:18:03 +00:00
parent 3eca48c71a
commit fd817683ca
87 changed files with 13582 additions and 1445 deletions

View File

@@ -0,0 +1,200 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
RiGitCommitLine,
RiUserLine,
RiCalendarLine,
RiFileList2Line,
} from "@remixicon/react";
type GitCommit = {
hash: string;
shortHash: string;
author: string;
date: string;
message: string;
body: string;
filesChanged: string[];
};
type ChangelogListProps = {
commits: GitCommit[];
repoUrl: string | null;
};
type CommitType = {
type: string;
scope?: string;
description: string;
};
function parseCommitMessage(message: string): CommitType {
const conventionalPattern = /^(\w+)(?:$$([^)]+)$$)?:\s*(.+)$/;
const match = message.match(conventionalPattern);
if (match) {
return {
type: match[1],
scope: match[2],
description: match[3],
};
}
return {
type: "chore",
description: message,
};
}
function getCommitTypeColor(type: string): string {
const colors: Record<string, string> = {
feat: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20",
fix: "bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20",
docs: "bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20",
style:
"bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20",
refactor:
"bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20",
perf: "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-yellow-500/20",
test: "bg-pink-500/10 text-pink-700 dark:text-pink-400 border-pink-500/20",
chore: "bg-gray-500/10 text-gray-700 dark:text-gray-400 border-gray-500/20",
};
return colors[type] || colors.chore;
}
export function ChangelogList({ commits, repoUrl }: ChangelogListProps) {
if (!commits || commits.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum commit encontrado no repositório
</p>
</div>
);
}
return (
<div className="space-y-2">
{commits.map((commit) => (
<CommitCard key={commit.hash} commit={commit} repoUrl={repoUrl} />
))}
</div>
);
}
function CommitCard({
commit,
repoUrl,
}: {
commit: GitCommit;
repoUrl: string | null;
}) {
const commitDate = new Date(commit.date);
const relativeTime = formatDistanceToNow(commitDate, {
addSuffix: true,
locale: ptBR,
});
const commitUrl = repoUrl ? `${repoUrl}/commit/${commit.hash}` : null;
const parsed = parseCommitMessage(commit.message);
return (
<Card className="hover:shadow-sm transition-shadow">
<CardHeader>
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant="outline"
className={`${getCommitTypeColor(parsed.type)} py-1`}
>
{parsed.type}
</Badge>
{parsed.scope && (
<Badge
variant="outline"
className="text-muted-foreground border-muted-foreground/30 text-xs py-0"
>
{parsed.scope}
</Badge>
)}
<span className="font-bold text-lg flex-1 min-w-0 first-letter:uppercase">
{parsed.description}
</span>
</div>
<div className="flex items-center gap-4 flex-wrap text-xs text-muted-foreground">
{commitUrl ? (
<a
href={commitUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground underline transition-colors font-mono flex items-center gap-1"
>
<RiGitCommitLine className="size-4" />
{commit.shortHash}
</a>
) : (
<span className="font-mono flex items-center gap-1">
<RiGitCommitLine className="size-3" />
{commit.shortHash}
</span>
)}
<span className="flex items-center gap-1">
<RiUserLine className="size-3" />
{commit.author}
</span>
<span className="flex items-center gap-1">
<RiCalendarLine className="size-3" />
{relativeTime}
</span>
</div>
</CardHeader>
{commit.body && (
<CardContent className="text-muted-foreground leading-relaxed">
{commit.body}
</CardContent>
)}
{commit.filesChanged.length > 0 && (
<CardContent>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="files-changed" className="border-0">
<AccordionTrigger className="py-0 text-xs text-muted-foreground hover:text-foreground hover:no-underline">
<div className="flex items-center gap-1.5">
<RiFileList2Line className="size-3.5" />
<span>
{commit.filesChanged.length} arquivo
{commit.filesChanged.length !== 1 ? "s" : ""}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-2 pb-0">
<ul className="space-y-1 max-h-48 overflow-y-auto">
{commit.filesChanged.map((file, index) => (
<li
key={index}
className="text-xs font-mono bg-muted rounded px-2 py-1 text-muted-foreground break-all"
>
{file}
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
)}
</Card>
);
}

View File

@@ -1,142 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { markAllUpdatesAsRead } from "@/lib/changelog/actions";
import type { ChangelogEntry } from "@/lib/changelog/data";
import {
getCategoryLabel,
groupEntriesByCategory,
parseSafariCompatibleDate,
} from "@/lib/changelog/utils";
import { cn } from "@/lib/utils";
import { RiMegaphoneLine } from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
interface ChangelogNotificationProps {
unreadCount: number;
entries: ChangelogEntry[];
}
export function ChangelogNotification({
unreadCount: initialUnreadCount,
entries,
}: ChangelogNotificationProps) {
const [unreadCount, setUnreadCount] = useState(initialUnreadCount);
const [isOpen, setIsOpen] = useState(false);
const handleMarkAllAsRead = async () => {
const updateIds = entries.map((e) => e.id);
await markAllUpdatesAsRead(updateIds);
setUnreadCount(0);
};
const grouped = groupEntriesByCategory(entries);
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border"
)}
>
<RiMegaphoneLine className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs"
variant="info"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Novidades</TooltipContent>
</Tooltip>
<PopoverContent className="w-96 p-0" align="end">
<div className="flex items-center justify-between p-4 pb-2">
<div className="flex items-center gap-2">
<RiMegaphoneLine className="h-5 w-5" />
<h3 className="font-semibold">Novidades</h3>
</div>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleMarkAllAsRead}
className="h-7 text-xs"
>
Marcar todas como lida
</Button>
)}
</div>
<Separator />
<ScrollArea className="h-[400px]">
<div className="p-4 space-y-4">
{Object.entries(grouped).map(([category, categoryEntries]) => (
<div key={category} className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">
{getCategoryLabel(category)}
</h4>
<div className="space-y-2">
{categoryEntries.map((entry) => (
<div key={entry.id} className="space-y-1">
<div className="flex items-start gap-2 border-b pb-2 border-dashed">
<span className="text-lg mt-0.5">{entry.icon}</span>
<div className="flex-1 space-y-1">
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
#{entry.id.substring(0, 7)}
</code>
<p className="text-sm leading-tight flex-1 first-letter:capitalize">
{entry.title}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(parseSafariCompatibleDate(entry.date), {
addSuffix: true,
locale: ptBR,
})}
</p>
</div>
</div>
</div>
))}
</div>
</div>
))}
{entries.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
Nenhuma atualização recente
</div>
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
);
}