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:
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { RiAlertLine } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -54,34 +52,29 @@ export function DeleteAccountForm() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<RiAlertLine className="size-5 text-destructive mt-0.5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="font-medium text-destructive">
|
||||
Remoção definitiva de conta
|
||||
</h3>
|
||||
<p className="text-sm text-foreground">
|
||||
Ao prosseguir, sua conta e todos os dados associados serão
|
||||
excluídos de forma irreversível.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
|
||||
<li>Lançamentos, orçamentos e anotações</li>
|
||||
<li>Contas, cartões e categorias</li>
|
||||
<li>Pagadores (incluindo o pagador padrão)</li>
|
||||
<li>Preferências e configurações</li>
|
||||
<li className="font-bold">
|
||||
Resumindo tudo, sua conta será permanentemente removida
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 pl-8">
|
||||
<li>Lançamentos, anexos e notas</li>
|
||||
<li>Contas, cartões, orçamentos e categorias</li>
|
||||
<li>Pagadores (incluindo o pagador padrão)</li>
|
||||
<li>Preferências e configurações</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenModal}
|
||||
disabled={isPending}
|
||||
className="w-fit"
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
|
||||
138
components/ajustes/preferences-form.tsx
Normal file
138
components/ajustes/preferences-form.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PreferencesFormProps {
|
||||
disableMagnetlines: boolean;
|
||||
periodMonthsBefore: number;
|
||||
periodMonthsAfter: number;
|
||||
}
|
||||
|
||||
export function PreferencesForm({
|
||||
disableMagnetlines,
|
||||
periodMonthsBefore,
|
||||
periodMonthsAfter,
|
||||
}: PreferencesFormProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
||||
useState(disableMagnetlines);
|
||||
const [monthsBefore, setMonthsBefore] = useState(periodMonthsBefore);
|
||||
const [monthsAfter, setMonthsAfter] = useState(periodMonthsAfter);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updatePreferencesAction({
|
||||
disableMagnetlines: magnetlinesDisabled,
|
||||
periodMonthsBefore: monthsBefore,
|
||||
periodMonthsAfter: monthsAfter,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
// Recarregar a página para aplicar as mudanças nos componentes
|
||||
router.refresh();
|
||||
// Forçar reload completo para garantir que os hooks re-executem
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col space-y-6"
|
||||
>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="magnetlines" className="text-base">
|
||||
Desabilitar Magnetlines
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remove o recurso de linhas magnéticas do sistema. Essa mudança
|
||||
afeta a interface e interações visuais.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="magnetlines"
|
||||
checked={magnetlinesDisabled}
|
||||
onCheckedChange={setMagnetlinesDisabled}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-dashed p-4">
|
||||
<div>
|
||||
<h3 className="text-base font-medium mb-2">
|
||||
Seleção de Período
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure quantos meses antes e depois do mês atual serão exibidos
|
||||
nos seletores de período.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthsBefore" className="text-sm">
|
||||
Meses anteriores
|
||||
</Label>
|
||||
<Input
|
||||
id="monthsBefore"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={monthsBefore}
|
||||
onChange={(e) => setMonthsBefore(Number(e.target.value))}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
1 a 24 meses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthsAfter" className="text-sm">
|
||||
Meses posteriores
|
||||
</Label>
|
||||
<Input
|
||||
id="monthsAfter"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={monthsAfter}
|
||||
onChange={(e) => setMonthsAfter(Number(e.target.value))}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
1 a 24 meses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="w-fit">
|
||||
{isPending ? "Salvando..." : "Salvar preferências"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -68,148 +68,153 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* E-mail atual (apenas informativo) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentEmail">E-mail atual</Label>
|
||||
<Input
|
||||
id="currentEmail"
|
||||
type="email"
|
||||
value={currentEmail}
|
||||
disabled
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="current-email-help"
|
||||
/>
|
||||
<p id="current-email-help" className="text-xs text-muted-foreground">
|
||||
Este é seu e-mail atual cadastrado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */}
|
||||
{!isGoogleAuth && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
{/* E-mail atual (apenas informativo) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
<Label htmlFor="currentEmail">E-mail atual</Label>
|
||||
<Input
|
||||
id="currentEmail"
|
||||
type="email"
|
||||
value={currentEmail}
|
||||
disabled
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="current-email-help"
|
||||
/>
|
||||
<p id="current-email-help" className="text-xs text-muted-foreground">
|
||||
Este é seu e-mail atual cadastrado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */}
|
||||
{!isGoogleAuth && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha para confirmar"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="password-help"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="password-help" className="text-xs text-muted-foreground">
|
||||
Por segurança, confirme sua senha antes de alterar seu e-mail
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newEmail">
|
||||
Novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="newEmail"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite o novo e-mail"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="new-email-help"
|
||||
aria-invalid={!isEmailDifferent}
|
||||
className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||
/>
|
||||
{!isEmailDifferent && newEmail && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
O novo e-mail deve ser diferente do atual
|
||||
</p>
|
||||
)}
|
||||
{!newEmail && (
|
||||
<p id="new-email-help" className="text-xs text-muted-foreground">
|
||||
Digite o novo endereço de e-mail para sua conta
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmar novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmEmail">
|
||||
Confirmar novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
id="confirmEmail"
|
||||
type="email"
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha para confirmar"
|
||||
placeholder="Repita o novo e-mail"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="password-help"
|
||||
aria-describedby="confirm-email-help"
|
||||
aria-invalid={emailsMatch === false}
|
||||
className={
|
||||
emailsMatch === false
|
||||
? "border-red-500 focus-visible:ring-red-500 pr-10"
|
||||
: emailsMatch === true
|
||||
? "border-green-500 focus-visible:ring-green-500 pr-10"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
{/* Indicador visual de match */}
|
||||
{emailsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{emailsMatch ? (
|
||||
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="Os e-mails coincidem" />
|
||||
) : (
|
||||
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="Os e-mails não coincidem" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p id="password-help" className="text-xs text-muted-foreground">
|
||||
Por segurança, confirme sua senha antes de alterar seu e-mail
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newEmail">
|
||||
Novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="newEmail"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite o novo e-mail"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="new-email-help"
|
||||
aria-invalid={!isEmailDifferent}
|
||||
className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||
/>
|
||||
{!isEmailDifferent && newEmail && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
O novo e-mail deve ser diferente do atual
|
||||
</p>
|
||||
)}
|
||||
{!newEmail && (
|
||||
<p id="new-email-help" className="text-xs text-muted-foreground">
|
||||
Digite o novo endereço de e-mail para sua conta
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmar novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmEmail">
|
||||
Confirmar novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmEmail"
|
||||
type="email"
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Repita o novo e-mail"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="confirm-email-help"
|
||||
aria-invalid={emailsMatch === false}
|
||||
className={
|
||||
emailsMatch === false
|
||||
? "border-red-500 focus-visible:ring-red-500 pr-10"
|
||||
: emailsMatch === true
|
||||
? "border-green-500 focus-visible:ring-green-500 pr-10"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{/* Indicador visual de match */}
|
||||
{emailsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{emailsMatch ? (
|
||||
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="Os e-mails coincidem" />
|
||||
) : (
|
||||
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="Os e-mails não coincidem" />
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{emailsMatch === false && (
|
||||
<p id="confirm-email-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
Os e-mails não coincidem
|
||||
</p>
|
||||
)}
|
||||
{emailsMatch === true && (
|
||||
<p id="confirm-email-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
Os e-mails coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{emailsMatch === false && (
|
||||
<p id="confirm-email-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
Os e-mails não coincidem
|
||||
</p>
|
||||
)}
|
||||
{emailsMatch === true && (
|
||||
<p id="confirm-email-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
Os e-mails coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || emailsMatch === false || !isEmailDifferent}
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
||||
</Button>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || emailsMatch === false || !isEmailDifferent}
|
||||
className="w-fit"
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,32 +40,36 @@ export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Primeiro nome</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Primeiro nome</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Sobrenome</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Sobrenome</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="w-fit">
|
||||
{isPending ? "Atualizando..." : "Atualizar nome"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Atualizando..." : "Atualizar nome"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiEyeLine, RiEyeOffLine, RiCheckLine, RiCloseLine, RiAlertLine } from "@remixicon/react";
|
||||
import {
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiAlertLine,
|
||||
} from "@remixicon/react";
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -44,13 +50,7 @@ function validatePassword(password: string): PasswordValidation {
|
||||
};
|
||||
}
|
||||
|
||||
function PasswordRequirement({
|
||||
met,
|
||||
label,
|
||||
}: {
|
||||
met: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -133,15 +133,16 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<div className="flex gap-3">
|
||||
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-amber-900 dark:text-amber-400">
|
||||
Alteração de senha não disponível
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-500">
|
||||
Você fez login usando sua conta do Google. A senha é gerenciada diretamente pelo Google
|
||||
e não pode ser alterada aqui. Para modificar sua senha, acesse as configurações de
|
||||
segurança da sua conta Google.
|
||||
Você fez login usando sua conta do Google. A senha é gerenciada
|
||||
diretamente pelo Google e não pode ser alterada aqui. Para
|
||||
modificar sua senha, acesse as configurações de segurança da sua
|
||||
conta Google.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,173 +151,213 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Senha atual */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha atual"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="current-password-help"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showCurrentPassword ? "Ocultar senha atual" : "Mostrar senha atual"}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="current-password-help" className="text-xs text-muted-foreground">
|
||||
Por segurança, confirme sua senha atual antes de alterá-la
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">
|
||||
Nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Crie uma senha forte"
|
||||
required
|
||||
minLength={7}
|
||||
maxLength={23}
|
||||
aria-required="true"
|
||||
aria-describedby="new-password-help"
|
||||
aria-invalid={newPassword.length > 0 && !passwordValidation.isValid}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Indicadores de requisitos da senha */}
|
||||
{newPassword.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMinLength}
|
||||
label="Mínimo 7 caracteres"
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
{/* Senha atual */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha atual"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="current-password-help"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMaxLength}
|
||||
label="Máximo 23 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasLowercase}
|
||||
label="Letra minúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasUppercase}
|
||||
label="Letra maiúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasNumber}
|
||||
label="Número"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasSpecial}
|
||||
label="Caractere especial"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmar nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirmar nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Repita a senha"
|
||||
required
|
||||
minLength={6}
|
||||
aria-required="true"
|
||||
aria-describedby="confirm-password-help"
|
||||
aria-invalid={passwordsMatch === false}
|
||||
className={
|
||||
passwordsMatch === false
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: passwordsMatch === true
|
||||
? "border-green-500 focus-visible:ring-green-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showConfirmPassword ? "Ocultar confirmação de senha" : "Mostrar confirmação de senha"}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
{/* Indicador visual de match */}
|
||||
{passwordsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{passwordsMatch ? (
|
||||
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="As senhas coincidem" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showCurrentPassword
|
||||
? "Ocultar senha atual"
|
||||
: "Mostrar senha atual"
|
||||
}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="As senhas não coincidem" />
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="current-password-help"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Por segurança, confirme sua senha atual antes de alterá-la
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">
|
||||
Nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Crie uma senha forte"
|
||||
required
|
||||
minLength={7}
|
||||
maxLength={23}
|
||||
aria-required="true"
|
||||
aria-describedby="new-password-help"
|
||||
aria-invalid={
|
||||
newPassword.length > 0 && !passwordValidation.isValid
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"
|
||||
}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Indicadores de requisitos da senha */}
|
||||
{newPassword.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMinLength}
|
||||
label="Mínimo 7 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMaxLength}
|
||||
label="Máximo 23 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasLowercase}
|
||||
label="Letra minúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasUppercase}
|
||||
label="Letra maiúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasNumber}
|
||||
label="Número"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasSpecial}
|
||||
label="Caractere especial"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{passwordsMatch === false && (
|
||||
<p id="confirm-password-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
As senhas não coincidem
|
||||
</p>
|
||||
)}
|
||||
{passwordsMatch === true && (
|
||||
<p id="confirm-password-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
As senhas coincidem
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Confirmar nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirmar nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Repita a senha"
|
||||
required
|
||||
minLength={6}
|
||||
aria-required="true"
|
||||
aria-describedby="confirm-password-help"
|
||||
aria-invalid={passwordsMatch === false}
|
||||
className={
|
||||
passwordsMatch === false
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: passwordsMatch === true
|
||||
? "border-green-500 focus-visible:ring-green-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showConfirmPassword
|
||||
? "Ocultar confirmação de senha"
|
||||
: "Mostrar confirmação de senha"
|
||||
}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
{/* Indicador visual de match */}
|
||||
{passwordsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{passwordsMatch ? (
|
||||
<RiCheckLine
|
||||
className="h-5 w-5 text-green-500"
|
||||
aria-label="As senhas coincidem"
|
||||
/>
|
||||
) : (
|
||||
<RiCloseLine
|
||||
className="h-5 w-5 text-red-500"
|
||||
aria-label="As senhas não coincidem"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{passwordsMatch === false && (
|
||||
<p
|
||||
id="confirm-password-help"
|
||||
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
|
||||
role="alert"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
As senhas não coincidem
|
||||
</p>
|
||||
)}
|
||||
{passwordsMatch === true && (
|
||||
<p
|
||||
id="confirm-password-help"
|
||||
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
|
||||
>
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
As senhas coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending || passwordsMatch === false || (newPassword.length > 0 && !passwordValidation.isValid)}>
|
||||
{isPending ? "Atualizando..." : "Atualizar senha"}
|
||||
</Button>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isPending ||
|
||||
passwordsMatch === false ||
|
||||
(newPassword.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
className="w-fit"
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar senha"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ export function MonthlyCalendar({
|
||||
cartaoOptions={formOptions.cartaoOptions}
|
||||
categoriaOptions={formOptions.categoriaOptions}
|
||||
estabelecimentos={formOptions.estabelecimentos}
|
||||
periodPreferences={formOptions.periodPreferences}
|
||||
defaultPeriod={period.period}
|
||||
defaultPurchaseDate={createDate ?? undefined}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LancamentoItem, SelectOption } from "@/components/lancamentos/types";
|
||||
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||
|
||||
export type CalendarEventType = "lancamento" | "boleto" | "cartao";
|
||||
|
||||
@@ -53,6 +54,7 @@ export type CalendarFormOptions = {
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
periodPreferences: PeriodPreferences;
|
||||
};
|
||||
|
||||
export type CalendarData = {
|
||||
|
||||
200
components/changelog/changelog-list.tsx
Normal file
200
components/changelog/changelog-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiDeleteBin5Line,
|
||||
RiEyeOffLine,
|
||||
RiFileList2Line,
|
||||
RiPencilLine,
|
||||
RiInformationLine,
|
||||
} from "@remixicon/react";
|
||||
import type React from "react";
|
||||
import MoneyValues from "../money-values";
|
||||
import { Card, CardContent, CardFooter } from "../ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
interface AccountCardProps {
|
||||
accountName: string;
|
||||
@@ -19,6 +19,7 @@ interface AccountCardProps {
|
||||
status?: string;
|
||||
icon?: React.ReactNode;
|
||||
excludeFromBalance?: boolean;
|
||||
excludeInitialBalanceFromIncome?: boolean;
|
||||
onViewStatement?: () => void;
|
||||
onEdit?: () => void;
|
||||
onRemove?: () => void;
|
||||
@@ -33,6 +34,7 @@ export function AccountCard({
|
||||
status,
|
||||
icon,
|
||||
excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome,
|
||||
onViewStatement,
|
||||
onEdit,
|
||||
onRemove,
|
||||
@@ -85,21 +87,39 @@ export function AccountCard({
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
{excludeFromBalance ? (
|
||||
<div
|
||||
className="flex items-center gap-1 text-muted-foreground"
|
||||
title="Excluída do saldo geral"
|
||||
>
|
||||
<RiEyeOffLine className="size-4" aria-hidden />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{excludeFromBalance && (
|
||||
<p className="text-xs">
|
||||
<strong>Desconsiderado do saldo total:</strong> Esta conta
|
||||
não é incluída no cálculo do saldo total geral.
|
||||
</p>
|
||||
)}
|
||||
{excludeInitialBalanceFromIncome && (
|
||||
<p className="text-xs">
|
||||
<strong>
|
||||
Saldo inicial desconsiderado das receitas:
|
||||
</strong>{" "}
|
||||
O saldo inicial desta conta não é contabilizado como
|
||||
receita nas métricas.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Saldo</p>
|
||||
<p className="text-3xl text-foreground">
|
||||
<MoneyValues amount={balance} className="text-3xl" />
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<MoneyValues amount={balance} className="text-3xl" />
|
||||
<p className="text-sm text-muted-foreground">{accountType}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -38,7 +38,7 @@ const DEFAULT_ACCOUNT_TYPES = [
|
||||
"Conta Poupança",
|
||||
"Carteira Digital",
|
||||
"Conta Investimento",
|
||||
"Cartão Pré-pago",
|
||||
"Pré-Pago | VR/VA",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_ACCOUNT_STATUS = ["Ativa", "Inativa"] as const;
|
||||
@@ -75,6 +75,8 @@ const buildInitialValues = ({
|
||||
logo: selectedLogo,
|
||||
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
|
||||
excludeFromBalance: account?.excludeFromBalance ?? false,
|
||||
excludeInitialBalanceFromIncome:
|
||||
account?.excludeInitialBalanceFromIncome ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -106,17 +106,39 @@ export function AccountFormFields({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:col-span-2">
|
||||
<Checkbox
|
||||
id="exclude-from-balance"
|
||||
checked={values.excludeFromBalance}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("excludeFromBalance", checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="exclude-from-balance" className="cursor-pointer text-sm font-normal">
|
||||
Excluir do saldo total (útil para contas de investimento ou reserva)
|
||||
</Label>
|
||||
<div className="flex flex-col gap-3 sm:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="exclude-from-balance"
|
||||
checked={values.excludeFromBalance === true || values.excludeFromBalance === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("excludeFromBalance", !!checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="exclude-from-balance"
|
||||
className="cursor-pointer text-sm font-normal leading-tight"
|
||||
>
|
||||
Desconsiderar do saldo total (útil para contas de investimento ou
|
||||
reserva)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="exclude-initial-balance-from-income"
|
||||
checked={values.excludeInitialBalanceFromIncome === true || values.excludeInitialBalanceFromIncome === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("excludeInitialBalanceFromIncome", !!checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="exclude-initial-balance-from-income"
|
||||
className="cursor-pointer text-sm font-normal leading-tight"
|
||||
>
|
||||
Desconsiderar o saldo inicial ao calcular o total de receitas
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -137,10 +137,13 @@ export function AccountsPage({ accounts, logoOptions }: AccountsPageProps) {
|
||||
<AccountCard
|
||||
key={account.id}
|
||||
accountName={account.name}
|
||||
accountType={`${account.accountType} - ${account.status}`}
|
||||
accountType={`${account.accountType}`}
|
||||
balance={account.balance ?? account.initialBalance ?? 0}
|
||||
status={account.status}
|
||||
excludeFromBalance={account.excludeFromBalance}
|
||||
excludeInitialBalanceFromIncome={
|
||||
account.excludeInitialBalanceFromIncome
|
||||
}
|
||||
icon={
|
||||
logoSrc ? (
|
||||
<Image
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Account = {
|
||||
initialBalance: number;
|
||||
balance?: number | null;
|
||||
excludeFromBalance?: boolean;
|
||||
excludeInitialBalanceFromIncome?: boolean;
|
||||
};
|
||||
|
||||
export type AccountFormValues = {
|
||||
@@ -18,4 +19,5 @@ export type AccountFormValues = {
|
||||
logo: string;
|
||||
initialBalance: string;
|
||||
excludeFromBalance: boolean;
|
||||
excludeInitialBalanceFromIncome: boolean;
|
||||
};
|
||||
|
||||
@@ -343,7 +343,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Selecione categorias para visualizar"
|
||||
description="Escolha até 5 categorias para acompanhar o histórico nos últimos 6 meses."
|
||||
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card } from "../ui/card";
|
||||
|
||||
type DashboardWelcomeProps = {
|
||||
name?: string | null;
|
||||
disableMagnetlines?: boolean;
|
||||
};
|
||||
|
||||
const capitalizeFirstLetter = (value: string) =>
|
||||
@@ -44,7 +45,7 @@ const getGreeting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export function DashboardWelcome({ name }: DashboardWelcomeProps) {
|
||||
export function DashboardWelcome({ name, disableMagnetlines = false }: DashboardWelcomeProps) {
|
||||
const displayName = name && name.trim().length > 0 ? name : "Administrador";
|
||||
const formattedDate = formatCurrentDate();
|
||||
const greeting = getGreeting();
|
||||
@@ -63,6 +64,7 @@ export function DashboardWelcome({ name }: DashboardWelcomeProps) {
|
||||
lineHeight="5vmin"
|
||||
baseAngle={0}
|
||||
className="text-welcome-banner-foreground"
|
||||
disabled={disableMagnetlines}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative tracking-tight text-welcome-banner-foreground">
|
||||
|
||||
@@ -76,7 +76,7 @@ export function MyAccountsWidget({
|
||||
return (
|
||||
<li
|
||||
key={account.id}
|
||||
className="flex items-center justify-between gap-2 border-b border-dashed py-1"
|
||||
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{logoSrc ? (
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { ChangelogNotification } from "@/components/changelog/changelog-notification";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { NotificationBell } from "@/components/notificacoes/notification-bell";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { getUnreadUpdates } from "@/lib/changelog/data";
|
||||
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
|
||||
import { AnimatedThemeToggler } from "./animated-theme-toggler";
|
||||
import LogoutButton from "./auth/logout-button";
|
||||
@@ -16,7 +14,6 @@ type SiteHeaderProps = {
|
||||
|
||||
export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
||||
const user = await getUser();
|
||||
const { unreadCount, allEntries } = await getUnreadUpdates(user.id);
|
||||
|
||||
return (
|
||||
<header className="border-b flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
@@ -31,10 +28,6 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
||||
<PrivacyModeToggle />
|
||||
<AnimatedThemeToggler />
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<ChangelogNotification
|
||||
unreadCount={unreadCount}
|
||||
entries={allEntries}
|
||||
/>
|
||||
<FeedbackDialog />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,8 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/hooks/use-form-state";
|
||||
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
|
||||
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||
import { createMonthOptions } from "@/lib/utils/period";
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -53,6 +55,7 @@ interface AnticipateInstallmentsDialogProps {
|
||||
categorias: Array<{ id: string; name: string; icon: string | null }>;
|
||||
pagadores: Array<{ id: string; name: string }>;
|
||||
defaultPeriod: string;
|
||||
periodPreferences: PeriodPreferences;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
@@ -65,57 +68,6 @@ type AnticipationFormValues = {
|
||||
note: string;
|
||||
};
|
||||
|
||||
type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const formatPeriodLabel = (period: string) => {
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
if (!year || !month) {
|
||||
return period;
|
||||
}
|
||||
const date = new Date(year, month - 1, 1);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return period;
|
||||
}
|
||||
const label = monthFormatter.format(date);
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
};
|
||||
|
||||
const buildPeriodOptions = (currentValue?: string): SelectOption[] => {
|
||||
const now = new Date();
|
||||
const options: SelectOption[] = [];
|
||||
|
||||
// Adiciona opções de 3 meses no passado até 6 meses no futuro
|
||||
for (let offset = -3; offset <= 6; offset += 1) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
||||
const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}`;
|
||||
options.push({ value, label: formatPeriodLabel(value) });
|
||||
}
|
||||
|
||||
// Adiciona o valor atual se não estiver na lista
|
||||
if (
|
||||
currentValue &&
|
||||
!options.some((option) => option.value === currentValue)
|
||||
) {
|
||||
options.push({
|
||||
value: currentValue,
|
||||
label: formatPeriodLabel(currentValue),
|
||||
});
|
||||
}
|
||||
|
||||
return options.sort((a, b) => a.value.localeCompare(b.value));
|
||||
};
|
||||
|
||||
export function AnticipateInstallmentsDialog({
|
||||
trigger,
|
||||
seriesId,
|
||||
@@ -123,6 +75,7 @@ export function AnticipateInstallmentsDialog({
|
||||
categorias,
|
||||
pagadores,
|
||||
defaultPeriod,
|
||||
periodPreferences,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AnticipateInstallmentsDialogProps) {
|
||||
@@ -152,8 +105,13 @@ export function AnticipateInstallmentsDialog({
|
||||
});
|
||||
|
||||
const periodOptions = useMemo(
|
||||
() => buildPeriodOptions(formState.anticipationPeriod),
|
||||
[formState.anticipationPeriod]
|
||||
() =>
|
||||
createMonthOptions(
|
||||
formState.anticipationPeriod,
|
||||
periodPreferences.monthsBefore,
|
||||
periodPreferences.monthsAfter
|
||||
),
|
||||
[formState.anticipationPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
|
||||
);
|
||||
|
||||
// Buscar parcelas elegíveis ao abrir o dialog
|
||||
|
||||
@@ -110,10 +110,14 @@ export function LancamentoDetailsDialog({
|
||||
<span className="capitalize">
|
||||
<Badge
|
||||
variant={getTransactionBadgeVariant(
|
||||
lancamento.transactionType
|
||||
lancamento.categoriaName === "Saldo inicial"
|
||||
? "Saldo inicial"
|
||||
: lancamento.transactionType
|
||||
)}
|
||||
>
|
||||
{lancamento.transactionType}
|
||||
{lancamento.categoriaName === "Saldo inicial"
|
||||
? "Saldo Inicial"
|
||||
: lancamento.transactionType}
|
||||
</Badge>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LancamentoFormState } from "@/lib/lancamentos/form-helpers";
|
||||
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||
import type { LancamentoItem, SelectOption } from "../../types";
|
||||
|
||||
export type FormState = LancamentoFormState;
|
||||
@@ -17,6 +18,7 @@ export interface LancamentoDialogProps {
|
||||
estabelecimentos: string[];
|
||||
lancamento?: LancamentoItem;
|
||||
defaultPeriod?: string;
|
||||
periodPreferences: PeriodPreferences;
|
||||
defaultCartaoId?: string | null;
|
||||
defaultPaymentMethod?: string | null;
|
||||
defaultPurchaseDate?: string | null;
|
||||
|
||||
@@ -58,6 +58,7 @@ export function LancamentoDialog({
|
||||
estabelecimentos,
|
||||
lancamento,
|
||||
defaultPeriod,
|
||||
periodPreferences,
|
||||
defaultCartaoId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
@@ -125,8 +126,13 @@ export function LancamentoDialog({
|
||||
}, [categoriaOptions, formState.transactionType]);
|
||||
|
||||
const monthOptions = useMemo(
|
||||
() => createMonthOptions(formState.period),
|
||||
[formState.period]
|
||||
() =>
|
||||
createMonthOptions(
|
||||
formState.period,
|
||||
periodPreferences.monthsBefore,
|
||||
periodPreferences.monthsAfter
|
||||
),
|
||||
[formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
|
||||
);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
|
||||
@@ -31,8 +31,18 @@ export function PaymentMethodSection({
|
||||
"Dinheiro",
|
||||
"Boleto",
|
||||
"Cartão de débito",
|
||||
"Pré-Pago | VR/VA",
|
||||
"Transferência bancária",
|
||||
].includes(formState.paymentMethod);
|
||||
|
||||
// Filtrar contas apenas do tipo "Pré-Pago | VR/VA" quando forma de pagamento for "Pré-Pago | VR/VA"
|
||||
const filteredContaOptions =
|
||||
formState.paymentMethod === "Pré-Pago | VR/VA"
|
||||
? contaOptions.filter(
|
||||
(option) => option.accountType === "Pré-Pago | VR/VA"
|
||||
)
|
||||
: contaOptions;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isUpdateMode ? (
|
||||
@@ -56,7 +66,9 @@ export function PaymentMethodSection({
|
||||
>
|
||||
<SelectValue placeholder="Selecione" className="w-full">
|
||||
{formState.paymentMethod && (
|
||||
<PaymentMethodSelectContent label={formState.paymentMethod} />
|
||||
<PaymentMethodSelectContent
|
||||
label={formState.paymentMethod}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
@@ -138,7 +150,7 @@ export function PaymentMethodSection({
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.contaId &&
|
||||
(() => {
|
||||
const selectedOption = contaOptions.find(
|
||||
const selectedOption = filteredContaOptions.find(
|
||||
(opt) => opt.value === formState.contaId
|
||||
);
|
||||
return selectedOption ? (
|
||||
@@ -152,14 +164,14 @@ export function PaymentMethodSection({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contaOptions.length === 0 ? (
|
||||
{filteredContaOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conta cadastrada
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
contaOptions.map((option) => (
|
||||
filteredContaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
@@ -246,7 +258,7 @@ export function PaymentMethodSection({
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.contaId &&
|
||||
(() => {
|
||||
const selectedOption = contaOptions.find(
|
||||
const selectedOption = filteredContaOptions.find(
|
||||
(opt) => opt.value === formState.contaId
|
||||
);
|
||||
return selectedOption ? (
|
||||
@@ -260,14 +272,14 @@ export function PaymentMethodSection({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contaOptions.length === 0 ? (
|
||||
{filteredContaOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conta cadastrada
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
contaOptions.map((option) => (
|
||||
filteredContaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
|
||||
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
|
||||
import { getTodayDateString } from "@/lib/utils/date";
|
||||
import { createMonthOptions } from "@/lib/utils/period";
|
||||
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -51,6 +52,7 @@ interface MassAddDialogProps {
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
selectedPeriod: string;
|
||||
periodPreferences: PeriodPreferences;
|
||||
defaultPagadorId?: string | null;
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ export function MassAddDialog({
|
||||
categoriaOptions,
|
||||
estabelecimentos,
|
||||
selectedPeriod,
|
||||
periodPreferences,
|
||||
defaultPagadorId,
|
||||
}: MassAddDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -119,8 +122,13 @@ export function MassAddDialog({
|
||||
|
||||
// Period options
|
||||
const periodOptions = useMemo(
|
||||
() => createMonthOptions(selectedPeriod, 3),
|
||||
[selectedPeriod]
|
||||
() =>
|
||||
createMonthOptions(
|
||||
selectedPeriod,
|
||||
periodPreferences.monthsBefore,
|
||||
periodPreferences.monthsAfter
|
||||
),
|
||||
[selectedPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
|
||||
);
|
||||
|
||||
// Categorias agrupadas e filtradas por tipo de transação
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
LancamentoItem,
|
||||
SelectOption,
|
||||
} from "../types";
|
||||
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||
|
||||
interface LancamentosPageProps {
|
||||
lancamentos: LancamentoItem[];
|
||||
@@ -39,6 +40,7 @@ interface LancamentosPageProps {
|
||||
contaCartaoFilterOptions: ContaCartaoFilterOption[];
|
||||
selectedPeriod: string;
|
||||
estabelecimentos: string[];
|
||||
periodPreferences: PeriodPreferences;
|
||||
allowCreate?: boolean;
|
||||
defaultCartaoId?: string | null;
|
||||
defaultPaymentMethod?: string | null;
|
||||
@@ -59,6 +61,7 @@ export function LancamentosPage({
|
||||
contaCartaoFilterOptions,
|
||||
selectedPeriod,
|
||||
estabelecimentos,
|
||||
periodPreferences,
|
||||
allowCreate = true,
|
||||
defaultCartaoId,
|
||||
defaultPaymentMethod,
|
||||
@@ -114,7 +117,7 @@ export function LancamentosPage({
|
||||
return;
|
||||
}
|
||||
|
||||
const supportedMethods = ["Pix", "Boleto", "Dinheiro", "Cartão de débito"];
|
||||
const supportedMethods = ["Pix", "Boleto", "Dinheiro", "Cartão de débito", "Pré-Pago | VR/VA", "Transferência bancária"];
|
||||
if (!supportedMethods.includes(item.paymentMethod)) {
|
||||
return;
|
||||
}
|
||||
@@ -354,6 +357,7 @@ export function LancamentosPage({
|
||||
categoriaOptions={categoriaOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
defaultPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
defaultCartaoId={defaultCartaoId}
|
||||
defaultPaymentMethod={defaultPaymentMethod}
|
||||
lockCartaoSelection={lockCartaoSelection}
|
||||
@@ -379,6 +383,7 @@ export function LancamentosPage({
|
||||
estabelecimentos={estabelecimentos}
|
||||
lancamento={lancamentoToCopy ?? undefined}
|
||||
defaultPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
/>
|
||||
|
||||
<LancamentoDialog
|
||||
@@ -394,6 +399,7 @@ export function LancamentosPage({
|
||||
estabelecimentos={estabelecimentos}
|
||||
lancamento={selectedLancamento ?? undefined}
|
||||
defaultPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
onBulkEditRequest={handleBulkEditRequest}
|
||||
/>
|
||||
|
||||
@@ -473,6 +479,7 @@ export function LancamentosPage({
|
||||
categoriaOptions={categoriaOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
selectedPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
/>
|
||||
) : null}
|
||||
@@ -508,6 +515,7 @@ export function LancamentosPage({
|
||||
name: p.label,
|
||||
}))}
|
||||
defaultPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
|
||||
import { displayPeriod } from "@/lib/utils/period";
|
||||
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
@@ -26,24 +27,6 @@ interface AnticipationCardProps {
|
||||
onCanceled?: () => void;
|
||||
}
|
||||
|
||||
const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const formatPeriodLabel = (period: string) => {
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
if (!year || !month) {
|
||||
return period;
|
||||
}
|
||||
const date = new Date(year, month - 1, 1);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return period;
|
||||
}
|
||||
const label = monthFormatter.format(date);
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
};
|
||||
|
||||
export function AnticipationCard({
|
||||
anticipation,
|
||||
onViewLancamento,
|
||||
@@ -93,7 +76,7 @@ export function AnticipationCard({
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{formatPeriodLabel(anticipation.anticipationPeriod)}
|
||||
{displayPeriod(anticipation.anticipationPeriod)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
@@ -288,16 +288,20 @@ const buildColumns = ({
|
||||
{
|
||||
accessorKey: "transactionType",
|
||||
header: "Transação",
|
||||
cell: ({ row }) => (
|
||||
<TypeBadge
|
||||
type={
|
||||
row.original.transactionType as
|
||||
| "Despesa"
|
||||
| "Receita"
|
||||
| "Transferência"
|
||||
}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const type =
|
||||
row.original.categoriaName === "Saldo inicial"
|
||||
? "Saldo inicial"
|
||||
: row.original.transactionType;
|
||||
|
||||
return (
|
||||
<TypeBadge
|
||||
type={
|
||||
type as "Despesa" | "Receita" | "Transferência" | "Saldo inicial"
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
|
||||
@@ -43,6 +43,7 @@ export type SelectOption = {
|
||||
avatarUrl?: string | null;
|
||||
logo?: string | null;
|
||||
icon?: string | null;
|
||||
accountType?: string | null;
|
||||
};
|
||||
|
||||
export type LancamentoFilterOption = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React, { CSSProperties, useEffect, useRef } from "react";
|
||||
|
||||
interface MagnetLinesProps {
|
||||
@@ -10,6 +12,7 @@ interface MagnetLinesProps {
|
||||
baseAngle?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MagnetLines: React.FC<MagnetLinesProps> = ({
|
||||
@@ -22,9 +25,15 @@ const MagnetLines: React.FC<MagnetLinesProps> = ({
|
||||
baseAngle = -10,
|
||||
className = "",
|
||||
style = {},
|
||||
disabled = false,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Se magnetlines estiver desabilitado, não renderiza nada
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/hooks/use-form-state";
|
||||
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||
import { createMonthOptions } from "@/lib/utils/period";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -43,64 +45,11 @@ interface BudgetDialogProps {
|
||||
budget?: Budget;
|
||||
categories: BudgetCategory[];
|
||||
defaultPeriod: string;
|
||||
periodPreferences: PeriodPreferences;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const formatPeriodLabel = (period: string) => {
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
if (!year || !month) {
|
||||
return period;
|
||||
}
|
||||
const date = new Date(year, month - 1, 1);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return period;
|
||||
}
|
||||
const label = monthFormatter.format(date);
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
};
|
||||
|
||||
const buildPeriodOptions = (currentValue?: string): SelectOption[] => {
|
||||
const now = new Date();
|
||||
const options: SelectOption[] = [];
|
||||
|
||||
for (let offset = -3; offset <= 3; offset += 1) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
||||
const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}`;
|
||||
options.push({ value, label: formatPeriodLabel(value) });
|
||||
}
|
||||
|
||||
if (
|
||||
currentValue &&
|
||||
!options.some((option) => option.value === currentValue)
|
||||
) {
|
||||
options.push({
|
||||
value: currentValue,
|
||||
label: formatPeriodLabel(currentValue),
|
||||
});
|
||||
}
|
||||
|
||||
return options
|
||||
.sort((a, b) => a.value.localeCompare(b.value))
|
||||
.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildInitialValues = ({
|
||||
budget,
|
||||
defaultPeriod,
|
||||
@@ -119,6 +68,7 @@ export function BudgetDialog({
|
||||
budget,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
periodPreferences,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BudgetDialogProps) {
|
||||
@@ -161,8 +111,13 @@ export function BudgetDialog({
|
||||
}, [dialogOpen]);
|
||||
|
||||
const periodOptions = useMemo(
|
||||
() => buildPeriodOptions(formState.period),
|
||||
[formState.period]
|
||||
() =>
|
||||
createMonthOptions(
|
||||
formState.period,
|
||||
periodPreferences.monthsBefore,
|
||||
periodPreferences.monthsAfter
|
||||
),
|
||||
[formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { deleteBudgetAction } from "@/app/(dashboard)/orcamentos/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PeriodPreferences } from "@/lib/user-preferences/period";
|
||||
import { RiAddCircleLine, RiFundsLine } from "@remixicon/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -17,6 +18,7 @@ interface BudgetsPageProps {
|
||||
categories: BudgetCategory[];
|
||||
selectedPeriod: string;
|
||||
periodLabel: string;
|
||||
periodPreferences: PeriodPreferences;
|
||||
}
|
||||
|
||||
export function BudgetsPage({
|
||||
@@ -24,6 +26,7 @@ export function BudgetsPage({
|
||||
categories,
|
||||
selectedPeriod,
|
||||
periodLabel,
|
||||
periodPreferences,
|
||||
}: BudgetsPageProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
||||
@@ -91,6 +94,7 @@ export function BudgetsPage({
|
||||
mode="create"
|
||||
categories={categories}
|
||||
defaultPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
trigger={
|
||||
<Button disabled={categories.length === 0}>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
@@ -128,6 +132,7 @@ export function BudgetsPage({
|
||||
budget={selectedBudget ?? undefined}
|
||||
categories={categories}
|
||||
defaultPeriod={selectedPeriod}
|
||||
periodPreferences={periodPreferences}
|
||||
open={editOpen && !!selectedBudget}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RiBankLine,
|
||||
RiCalendarEventLine,
|
||||
RiDashboardLine,
|
||||
RiGitCommitLine,
|
||||
RiFundsLine,
|
||||
RiGroupLine,
|
||||
RiLineChartLine,
|
||||
@@ -161,6 +162,11 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Changelog",
|
||||
url: "/changelog",
|
||||
icon: RiGitCommitLine,
|
||||
},
|
||||
{
|
||||
title: "Ajustes",
|
||||
url: "/ajustes",
|
||||
|
||||
@@ -8,10 +8,12 @@ type TypeBadgeType =
|
||||
| "Receita"
|
||||
| "Despesa"
|
||||
| "Transferência"
|
||||
| "transferência";
|
||||
| "transferência"
|
||||
| "Saldo inicial"
|
||||
| "Saldo Inicial";
|
||||
|
||||
interface TypeBadgeProps {
|
||||
type: TypeBadgeType;
|
||||
type: TypeBadgeType | string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -22,23 +24,26 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
Despesa: "Despesa",
|
||||
Transferência: "Transferência",
|
||||
transferência: "Transferência",
|
||||
"Saldo inicial": "Saldo Inicial",
|
||||
"Saldo Inicial": "Saldo Inicial",
|
||||
};
|
||||
|
||||
export function TypeBadge({ type, className }: TypeBadgeProps) {
|
||||
const normalizedType = type.toLowerCase();
|
||||
const isReceita = normalizedType === "receita";
|
||||
const isTransferencia = normalizedType === "transferência";
|
||||
const isSaldoInicial = normalizedType === "saldo inicial";
|
||||
const label = TYPE_LABELS[type] || type;
|
||||
|
||||
const colorClass = isTransferencia
|
||||
? "text-blue-700 dark:text-blue-400"
|
||||
: isReceita
|
||||
: (isReceita || isSaldoInicial)
|
||||
? "text-green-700 dark:text-green-400"
|
||||
: "text-red-700 dark:text-red-400";
|
||||
|
||||
const dotColor = isTransferencia
|
||||
? "bg-blue-700 dark:bg-blue-400"
|
||||
: isReceita
|
||||
: (isReceita || isSaldoInicial)
|
||||
? "bg-green-600 dark:bg-green-400"
|
||||
: "bg-red-600 dark:bg-red-400";
|
||||
|
||||
|
||||
65
components/ui/accordion.tsx
Normal file
65
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RiArrowDownBoxFill } from "@remixicon/react";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<RiArrowDownBoxFill className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
Reference in New Issue
Block a user