Merge pull request #23 from felipegcoutinho/feat/passkeys-complete-implementation

feat: implementar passkeys completos (login, ajustes e banco)
This commit is contained in:
Felipe Coutinho
2026-03-02 20:22:40 -03:00
committed by GitHub
44 changed files with 4182 additions and 440 deletions

View File

@@ -5,6 +5,40 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [1.7.6] - 2026-03-02
### Adicionado
- Suporte completo a Passkeys (WebAuthn) com plugin `@better-auth/passkey` no servidor e `passkeyClient` no cliente de autenticação
- Tabela `passkey` no banco de dados para persistência de credenciais WebAuthn vinculadas ao usuário
- Nova aba **Passkeys** em `/ajustes` com gerenciamento de credenciais: listar, adicionar, renomear e remover passkeys
- Ação de login com passkey na tela de autenticação (`/login`)
- Dashboard: botões rápidos na toolbar de widgets para `Nova receita`, `Nova despesa` e `Nova anotação` com abertura direta dos diálogos correspondentes
- Widget de **Anotações** no dashboard com listagem das anotações ativas, ações discretas de editar e ver detalhes, e atalho para `/anotacoes`
### Alterado
- `PasskeysForm` refatorado para melhor experiência com React 19/Next 16: detecção de suporte do navegador, bloqueio de ações simultâneas e atualização da lista sem loader global após operações
- Widget de pagadores no dashboard agora exibe variação percentual em relação ao mês anterior (seta + cor semântica), seguindo o padrão visual dos widgets de categorias
- Dashboard: widgets `Condições de Pagamentos` + `Formas de Pagamento` unificados em um único widget com abas; `Top Estabelecimentos` + `Maiores Gastos do Mês` também unificados em widget com abas
- Relatórios: rota de Top Estabelecimentos consolidada em `/relatorios/estabelecimentos`
- Dashboard: widget `Lançamentos recentes` removido e substituído por `Progresso de metas` com lista de orçamentos do período (gasto, limite configurado e percentual de uso por categoria)
- Dashboard: `fetchDashboardData` deixou de carregar `notificationsSnapshot` (notificações continuam sendo carregadas no layout), reduzindo uma query no carregamento da página inicial
### Corrigido
- Login com passkey na tela de autenticação agora fica disponível em navegadores com WebAuthn, mesmo sem suporte a Conditional UI
- Listagem de passkeys em Ajustes agora trata `createdAt` ausente sem gerar data inválida na interface
- Migração `0017_previous_warstar` tornou-se idempotente para colunas de `preferencias_usuario` com `IF NOT EXISTS`, evitando falha em bancos já migrados
### Removido
- Código legado não utilizado no dashboard: widget e fetcher de `Lançamentos Recentes`
- Componente legado `CategoryCard` em categorias (substituído pelo layout atual em tabela)
- Componente `AuthFooter` não utilizado na autenticação
- Barrel files sem consumo em `components/relatorios`, `components/lancamentos` e `components/lancamentos/shared`
- Rota legada `/top-estabelecimentos` e arquivos auxiliares (`layout.tsx` e `loading.tsx`) removidos
## [1.7.5] - 2026-02-28 ## [1.7.5] - 2026-02-28
### Adicionado ### Adicionado

View File

@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import { CompanionTab } from "@/components/ajustes/companion-tab"; import { CompanionTab } from "@/components/ajustes/companion-tab";
import { DeleteAccountForm } from "@/components/ajustes/delete-account-form"; import { DeleteAccountForm } from "@/components/ajustes/delete-account-form";
import { PasskeysForm } from "@/components/ajustes/passkeys-form";
import { PreferencesForm } from "@/components/ajustes/preferences-form"; import { PreferencesForm } from "@/components/ajustes/preferences-form";
import { UpdateEmailForm } from "@/components/ajustes/update-email-form"; import { UpdateEmailForm } from "@/components/ajustes/update-email-form";
import { UpdateNameForm } from "@/components/ajustes/update-name-form"; import { UpdateNameForm } from "@/components/ajustes/update-name-form";
@@ -39,6 +40,7 @@ export default async function Page() {
<TabsTrigger value="companion">Companion</TabsTrigger> <TabsTrigger value="companion">Companion</TabsTrigger>
<TabsTrigger value="nome">Alterar nome</TabsTrigger> <TabsTrigger value="nome">Alterar nome</TabsTrigger>
<TabsTrigger value="senha">Alterar senha</TabsTrigger> <TabsTrigger value="senha">Alterar senha</TabsTrigger>
<TabsTrigger value="passkeys">Passkeys</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger> <TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive"> <TabsTrigger value="deletar" className="text-destructive">
Deletar conta Deletar conta
@@ -114,6 +116,21 @@ export default async function Page() {
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="passkeys" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground mb-4">
Passkeys permitem login sem senha, usando biometria (Face ID,
Touch ID, Windows Hello) ou chaves de segurança.
</p>
</div>
<PasskeysForm />
</div>
</Card>
</TabsContent>
<TabsContent value="email" className="mt-4"> <TabsContent value="email" className="mt-4">
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -4,7 +4,13 @@ import { SectionCards } from "@/components/dashboard/section-cards";
import MonthNavigation from "@/components/month-picker/month-navigation"; import MonthNavigation from "@/components/month-picker/month-navigation";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period"; import { parsePeriodParam } from "@/lib/utils/period";
import { getRecentEstablishmentsAction } from "../lancamentos/actions";
import { fetchUserDashboardPreferences } from "./data"; import { fetchUserDashboardPreferences } from "./data";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>; type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -28,12 +34,26 @@ export default async function Page({ searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam); const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [data, preferences] = await Promise.all([ const [dashboardData, preferences, filterSources, estabelecimentos] =
await Promise.all([
fetchDashboardData(user.id, selectedPeriod), fetchDashboardData(user.id, selectedPeriod),
fetchUserDashboardPreferences(user.id), fetchUserDashboardPreferences(user.id),
fetchLancamentoFilterSources(user.id),
getRecentEstablishmentsAction(),
]); ]);
const { disableMagnetlines, dashboardWidgets } = preferences; const { disableMagnetlines, dashboardWidgets } = preferences;
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
return ( return (
<main className="flex flex-col gap-4"> <main className="flex flex-col gap-4">
@@ -42,11 +62,20 @@ export default async function Page({ searchParams }: PageProps) {
disableMagnetlines={disableMagnetlines} disableMagnetlines={disableMagnetlines}
/> />
<MonthNavigation /> <MonthNavigation />
<SectionCards metrics={data.metrics} /> <SectionCards metrics={dashboardData.metrics} />
<DashboardGridEditable <DashboardGridEditable
data={data} data={dashboardData}
period={selectedPeriod} period={selectedPeriod}
initialPreferences={dashboardWidgets} initialPreferences={dashboardWidgets}
quickActionOptions={{
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
}}
/> />
</main> </main>
); );

View File

@@ -4,7 +4,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() { export default function Loading() {
return ( return (
<main className="flex flex-col gap-4 px-6"> <main className="flex flex-col gap-4 px-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Skeleton className="h-8 w-48" /> <Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" /> <Skeleton className="h-4 w-64" />

View File

@@ -52,7 +52,7 @@ export default async function TopEstabelecimentosPage({
return ( return (
<main className="flex flex-col gap-4"> <main className="flex flex-col gap-4">
<Card className="p-3 flex-row justify-between items-center"> <Card className="flex-row items-center justify-between p-3">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Selecione o intervalo de meses Selecione o intervalo de meses
</span> </span>
@@ -63,7 +63,7 @@ export default async function TopEstabelecimentosPage({
<HighlightsCards summary={data.summary} /> <HighlightsCards summary={data.summary} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div> <div>
<EstablishmentsList establishments={data.establishments} /> <EstablishmentsList establishments={data.establishments} />
</div> </div>

View File

@@ -105,7 +105,7 @@
/* Base surfaces - warm dark with consistent hue family */ /* Base surfaces - warm dark with consistent hue family */
--background: oklch(18.5% 0.002 70); --background: oklch(18.5% 0.002 70);
--foreground: oklch(92% 0.015 80); --foreground: oklch(92% 0.015 80);
--card: oklch(22.717% 0.00244 67.467); --card: oklch(0.13 0.01 64.18);
--card-foreground: oklch(92% 0.015 80); --card-foreground: oklch(92% 0.015 80);
--popover: oklch(24% 0.003 70); --popover: oklch(24% 0.003 70);
--popover-foreground: oklch(92% 0.015 80); --popover-foreground: oklch(92% 0.015 80);

View File

@@ -24,7 +24,6 @@ export default function robots(): MetadataRoute.Robots {
"/consultor", "/consultor",
"/ajustes", "/ajustes",
"/relatorios", "/relatorios",
"/top-estabelecimentos",
"/pre-lancamentos", "/pre-lancamentos",
"/login", "/login",
"/api/", "/api/",

View File

@@ -0,0 +1,418 @@
"use client";
import {
RiAddLine,
RiAlertLine,
RiDeleteBinLine,
RiFingerprintLine,
RiLoader4Line,
RiPencilLine,
} from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useCallback, useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth/client";
interface Passkey {
id: string;
name: string | null;
deviceType: string;
createdAt: Date | null;
}
export function PasskeysForm() {
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [passkeySupported, setPasskeySupported] = useState(false);
// Add passkey
const [isAddOpen, setIsAddOpen] = useState(false);
const [addName, setAddName] = useState("");
const [isAdding, setIsAdding] = useState(false);
// Rename passkey
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
// Delete passkey
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const isMutating = isAdding || isRenaming || isDeleting;
const fetchPasskeys = useCallback(
async (options?: { showLoader?: boolean }) => {
const showLoader = options?.showLoader ?? true;
if (showLoader) setIsLoading(true);
setError(null);
try {
const { data, error: fetchError } =
await authClient.passkey.listUserPasskeys();
if (fetchError) {
setError(fetchError.message || "Erro ao carregar passkeys.");
return;
}
setPasskeys(
(data ?? []).map((p) => ({
id: p.id,
name: p.name,
deviceType: p.deviceType,
createdAt: p.createdAt ? new Date(p.createdAt) : null,
})),
);
} catch {
setError("Erro ao carregar passkeys.");
} finally {
if (showLoader) setIsLoading(false);
}
},
[],
);
useEffect(() => {
if (typeof window === "undefined") return;
setPasskeySupported(typeof PublicKeyCredential !== "undefined");
fetchPasskeys();
}, [fetchPasskeys]);
const handleAdd = async () => {
if (!passkeySupported) {
setError("Passkeys não são suportadas neste navegador/dispositivo.");
return;
}
setIsAdding(true);
setError(null);
try {
const { error: addError } = await authClient.passkey.addPasskey({
name: addName.trim() || undefined,
});
if (addError) {
setError(addError.message || "Erro ao registrar passkey.");
return;
}
setAddName("");
setIsAddOpen(false);
await fetchPasskeys({ showLoader: false });
} catch {
setError("Erro ao registrar passkey.");
} finally {
setIsAdding(false);
}
};
const handleRename = async (id: string) => {
if (!editName.trim()) return;
setIsRenaming(true);
setError(null);
try {
const { error: renameError } = await authClient.passkey.updatePasskey({
id,
name: editName.trim(),
});
if (renameError) {
setError(renameError.message || "Erro ao renomear passkey.");
return;
}
setEditingId(null);
setEditName("");
await fetchPasskeys({ showLoader: false });
} catch {
setError("Erro ao renomear passkey.");
} finally {
setIsRenaming(false);
}
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
setError(null);
try {
const { error: deleteError } = await authClient.passkey.deletePasskey({
id: deleteId,
});
if (deleteError) {
setError(deleteError.message || "Erro ao remover passkey.");
return;
}
setDeleteId(null);
await fetchPasskeys({ showLoader: false });
} catch {
setError("Erro ao remover passkey.");
} finally {
setIsDeleting(false);
}
};
const startEditing = (passkey: Passkey) => {
setEditingId(passkey.id);
setEditName(passkey.name || "");
};
const cancelEditing = () => {
setEditingId(null);
setEditName("");
};
const deviceTypeLabel = (type: string) => {
switch (type) {
case "singleDevice":
return "Dispositivo único";
case "multiDevice":
return "Multi-dispositivo";
default:
return type;
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Suas passkeys</h3>
<p className="text-sm text-muted-foreground">
Gerencie suas passkeys para login sem senha.
</p>
</div>
<Dialog
open={isAddOpen}
onOpenChange={(open) => {
if (!open) {
setAddName("");
setError(null);
}
setIsAddOpen(open);
}}
>
<DialogTrigger asChild>
<Button size="sm" disabled={isMutating || !passkeySupported}>
<RiAddLine className="h-4 w-4 mr-1" />
Adicionar
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrar Passkey</DialogTitle>
<DialogDescription>
um nome para identificar esta passkey (opcional). Em seguida,
seu navegador solicitará a confirmação biométrica.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkeyName">Nome (opcional)</Label>
<Input
id="passkeyName"
placeholder="Ex: MacBook Pro, iPhone..."
value={addName}
onChange={(e) => setAddName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAdd();
}
}}
disabled={isAdding}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<RiAlertLine className="h-4 w-4" />
{error}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddOpen(false)}
disabled={isAdding}
>
Cancelar
</Button>
<Button onClick={handleAdd} disabled={isAdding}>
{isAdding ? (
<>
<RiLoader4Line className="h-4 w-4 animate-spin mr-1" />
Registrando...
</>
) : (
"Registrar"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{error && !isAddOpen && (
<div className="flex items-center gap-2 text-sm text-destructive">
<RiAlertLine className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{!passkeySupported && !isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RiAlertLine className="h-4 w-4 shrink-0" />
Este navegador/dispositivo não suporta passkeys.
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<RiLoader4Line className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : passkeys.length === 0 ? (
<div className="flex items-center gap-3 py-4 text-muted-foreground">
<RiFingerprintLine className="h-5 w-5" />
<p className="text-sm">
Nenhuma passkey cadastrada. Adicione uma para login sem senha.
</p>
</div>
) : (
<div className="divide-y py-2">
{passkeys.map((pk) => (
<div
key={pk.id}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
<RiFingerprintLine className="h-4 w-4" />
</div>
<div className="min-w-0">
{editingId === pk.id ? (
<div className="flex items-center gap-2">
<Input
className="h-7 text-sm w-40"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(pk.id);
if (e.key === "Escape") cancelEditing();
}}
autoFocus
disabled={isRenaming}
/>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => handleRename(pk.id)}
disabled={isRenaming || !editName.trim()}
>
{isRenaming ? (
<RiLoader4Line className="h-3 w-3 animate-spin" />
) : (
"Salvar"
)}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={cancelEditing}
disabled={isRenaming}
>
Cancelar
</Button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-bold truncate">
{pk.name || "Passkey sem nome"}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => startEditing(pk)}
disabled={isMutating}
>
<RiPencilLine className="h-3 w-3" />
</Button>
</div>
<p className="text-xs text-muted-foreground py-1">
{deviceTypeLabel(pk.deviceType)}
{pk.createdAt
? ` · Criada ${formatDistanceToNow(pk.createdAt, {
addSuffix: true,
locale: ptBR,
})}`
: " · Data de criação indisponível"}
</p>
</>
)}
</div>
</div>
{editingId !== pk.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteId(pk.id)}
disabled={isMutating}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remover passkey?</AlertDialogTitle>
<AlertDialogDescription>
Esta passkey não poderá mais ser usada para login. Esta ação não
pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isDeleting ? "Removendo..." : "Remover"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,17 +0,0 @@
import { FieldDescription } from "@/components/ui/field";
export function AuthFooter() {
return (
<FieldDescription className="px-6 text-center">
Ao continuar, você concorda com nossos{" "}
<a href="/terms" className="underline underline-offset-4">
Termos de Serviço
</a>{" "}
e{" "}
<a href="/privacy" className="underline underline-offset-4">
Política de Privacidade
</a>
.
</FieldDescription>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { RiLoader4Line } from "@remixicon/react"; import { RiFingerprintLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { type FormEvent, useState } from "react"; import { type FormEvent, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -33,6 +33,15 @@ export function LoginForm({ className, ...props }: DivProps) {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false); const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false); const [loadingGoogle, setLoadingGoogle] = useState(false);
const [loadingPasskey, setLoadingPasskey] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (typeof PublicKeyCredential === "undefined") return;
setPasskeySupported(true);
}, []);
async function handleSubmit(e: FormEvent<HTMLFormElement>) { async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
@@ -97,6 +106,29 @@ export function LoginForm({ className, ...props }: DivProps) {
); );
} }
async function handlePasskey() {
setError("");
setLoadingPasskey(true);
const { error: passkeyError } = await authClient.signIn.passkey({
fetchOptions: {
onSuccess: () => {
setLoadingPasskey(false);
router.replace("/dashboard");
},
onError: (ctx) => {
setError(ctx.error.message);
setLoadingPasskey(false);
},
},
});
if (passkeyError) {
setError(passkeyError.message || "Erro ao entrar com passkey.");
setLoadingPasskey(false);
}
}
return ( return (
<div className={cn("flex flex-col gap-6", className)} {...props}> <div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" /> <Logo className="mb-2" />
@@ -118,7 +150,7 @@ export function LoginForm({ className, ...props }: DivProps) {
id="email" id="email"
type="email" type="email"
placeholder="Digite seu e-mail" placeholder="Digite seu e-mail"
autoComplete="email" autoComplete="username webauthn"
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
@@ -145,7 +177,7 @@ export function LoginForm({ className, ...props }: DivProps) {
<Field> <Field>
<Button <Button
type="submit" type="submit"
disabled={loadingEmail || loadingGoogle} disabled={loadingEmail || loadingGoogle || loadingPasskey}
className="w-full" className="w-full"
> >
{loadingEmail ? ( {loadingEmail ? (
@@ -164,11 +196,35 @@ export function LoginForm({ className, ...props }: DivProps) {
<GoogleAuthButton <GoogleAuthButton
onClick={handleGoogle} onClick={handleGoogle}
loading={loadingGoogle} loading={loadingGoogle}
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable} disabled={
loadingEmail ||
loadingGoogle ||
loadingPasskey ||
!isGoogleAvailable
}
text="Entrar com Google" text="Entrar com Google"
/> />
</Field> </Field>
{passkeySupported && (
<Field>
<Button
variant="outline"
type="button"
onClick={handlePasskey}
disabled={loadingEmail || loadingGoogle || loadingPasskey}
className="w-full gap-2"
>
{loadingPasskey ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiFingerprintLine className="h-5 w-5" />
)}
<span>Entrar com passkey</span>
</Button>
</Field>
)}
<FieldDescription className="text-center"> <FieldDescription className="text-center">
Não tem uma conta?{" "} Não tem uma conta?{" "}
<a href="/signup" className="underline underline-offset-4"> <a href="/signup" className="underline underline-offset-4">
@@ -182,7 +238,6 @@ export function LoginForm({ className, ...props }: DivProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* <AuthFooter /> */}
<FieldDescription className="text-center"> <FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4"> <a href="/" className="underline underline-offset-4">
Voltar para o site Voltar para o site

View File

@@ -281,7 +281,6 @@ export function SignupForm({ className, ...props }: DivProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* <AuthFooter /> */}
<FieldDescription className="text-center"> <FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4"> <a href="/" className="underline underline-offset-4">
Voltar para o site Voltar para o site

View File

@@ -1,103 +0,0 @@
"use client";
import {
RiDeleteBin5Line,
RiFileList2Line,
RiPencilLine,
} from "@remixicon/react";
import Link from "next/link";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils/ui";
import { CategoryIconBadge } from "./category-icon-badge";
import type { Category } from "./types";
interface CategoryCardProps {
category: Category;
colorIndex: number;
onEdit: (category: Category) => void;
onRemove: (category: Category) => void;
}
export function CategoryCard({
category,
colorIndex,
onEdit,
onRemove,
}: CategoryCardProps) {
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
const isProtegida = categoriasProtegidas.includes(category.name);
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: () => onEdit(category),
variant: "default" as const,
disabled: isProtegida,
},
{
label: "detalhes",
icon: <RiFileList2Line className="size-4" aria-hidden />,
href: `/categorias/${category.id}`,
variant: "default" as const,
disabled: false,
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: () => onRemove(category),
variant: "destructive" as const,
disabled: isProtegida,
},
].filter((action) => !action.disabled);
return (
<Card className="flex h-full flex-col gap-0 py-3">
<CardContent className="flex flex-1 flex-col">
<div className="flex items-center gap-3">
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={colorIndex}
size="md"
/>
<h3 className="leading-tight">{category.name}</h3>
</div>
</CardContent>
<CardFooter className="flex flex-wrap gap-3 px-6 pt-4 text-sm">
{actions.map(({ label, icon, onClick, href, variant }) => {
const className = cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
variant === "destructive" ? "text-destructive" : "text-primary",
);
if (href) {
return (
<Link key={label} href={href} className={className}>
{icon}
{label}
</Link>
);
}
return (
<button
key={label}
type="button"
onClick={onClick}
className={className}
>
{icon}
{label}
</button>
);
})}
</CardFooter>
</Card>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { import {
closestCenter, closestCorners,
DndContext, DndContext,
type DragEndEvent, type DragEndEvent,
KeyboardSensor, KeyboardSensor,
@@ -16,15 +16,21 @@ import {
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { import {
RiArrowDownLine,
RiArrowUpLine,
RiCheckLine, RiCheckLine,
RiCloseLine, RiCloseLine,
RiDragMove2Line, RiDragMove2Line,
RiEyeOffLine, RiEyeOffLine,
RiTodoLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { useCallback, useMemo, useState, useTransition } from "react"; import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { NoteDialog } from "@/components/anotacoes/note-dialog";
import { SortableWidget } from "@/components/dashboard/sortable-widget"; import { SortableWidget } from "@/components/dashboard/sortable-widget";
import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog"; import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
import type { SelectOption } from "@/components/lancamentos/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import WidgetCard from "@/components/widget-card"; import WidgetCard from "@/components/widget-card";
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data"; import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
@@ -42,12 +48,22 @@ type DashboardGridEditableProps = {
data: DashboardData; data: DashboardData;
period: string; period: string;
initialPreferences: WidgetPreferences | null; initialPreferences: WidgetPreferences | null;
quickActionOptions: {
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
};
}; };
export function DashboardGridEditable({ export function DashboardGridEditable({
data, data,
period, period,
initialPreferences, initialPreferences,
quickActionOptions,
}: DashboardGridEditableProps) { }: DashboardGridEditableProps) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -183,7 +199,65 @@ export function DashboardGridEditable({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
{!isEditing ? (
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2 px-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Ações rápidas
</span>
<div className="-mb-1 flex items-center gap-2 overflow-x-auto pb-1 sm:mb-0 sm:overflow-visible sm:pb-0">
<LancamentoDialog
mode="create"
pagadorOptions={quickActionOptions.pagadorOptions}
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
defaultPagadorId={quickActionOptions.defaultPagadorId}
contaOptions={quickActionOptions.contaOptions}
cartaoOptions={quickActionOptions.cartaoOptions}
categoriaOptions={quickActionOptions.categoriaOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Receita"
trigger={
<Button size="sm" variant="outline" className="gap-2">
<RiArrowUpLine className="size-4 text-success/80" />
Nova receita
</Button>
}
/>
<LancamentoDialog
mode="create"
pagadorOptions={quickActionOptions.pagadorOptions}
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
defaultPagadorId={quickActionOptions.defaultPagadorId}
contaOptions={quickActionOptions.contaOptions}
cartaoOptions={quickActionOptions.cartaoOptions}
categoriaOptions={quickActionOptions.categoriaOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Despesa"
trigger={
<Button size="sm" variant="outline" className="gap-2">
<RiArrowDownLine className="size-4 text-destructive/80" />
Nova despesa
</Button>
}
/>
<NoteDialog
mode="create"
trigger={
<Button size="sm" variant="outline" className="gap-2">
<RiTodoLine className="size-4 text-info/80" />
Nova anotação
</Button>
}
/>
</div>
</div>
) : (
<div />
)}
<div className="flex items-center gap-2">
{isEditing ? ( {isEditing ? (
<> <>
<Button <Button
@@ -225,11 +299,12 @@ export function DashboardGridEditable({
</> </>
)} )}
</div> </div>
</div>
{/* Grid */} {/* Grid */}
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCorners}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext

View File

@@ -0,0 +1,146 @@
"use client";
import { RiFundsLine, RiPencilLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import { BudgetDialog } from "@/components/orcamentos/budget-dialog";
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import type { GoalsProgressData } from "@/lib/dashboard/goals-progress";
import { WidgetEmptyState } from "../widget-empty-state";
type GoalsProgressWidgetProps = {
data: GoalsProgressData;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const formatPercentage = (value: number, withSign = false) =>
`${new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
...(withSign ? { signDisplay: "always" as const } : {}),
}).format(value)}%`;
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
const [editOpen, setEditOpen] = useState(false);
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
const categories = useMemo<BudgetCategory[]>(
() =>
data.categories.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
})),
[data.categories],
);
const defaultPeriod = data.items[0]?.period ?? "";
const handleEdit = useCallback((item: GoalsProgressData["items"][number]) => {
setSelectedBudget({
id: item.id,
amount: item.budgetAmount,
spent: item.spentAmount,
period: item.period,
createdAt: item.createdAt,
category: item.categoryId
? {
id: item.categoryId,
name: item.categoryName,
icon: item.categoryIcon,
}
: null,
});
setEditOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedBudget(null);
}
}, []);
if (data.items.length === 0) {
return (
<WidgetEmptyState
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
title="Nenhum orçamento para o período"
description="Cadastre orçamentos para acompanhar o progresso das metas."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col">
{data.items.map((item, index) => {
const statusColor =
item.status === "exceeded" ? "text-destructive" : "";
const progressValue = clamp(item.usedPercentage, 0, 100);
const percentageDelta = item.usedPercentage - 100;
return (
<li
key={item.id}
className="border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2">
<CategoryIconBadge
icon={item.categoryIcon}
name={item.categoryName}
colorIndex={index}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{item.categoryName}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<MoneyValues amount={item.spentAmount} /> de{" "}
<MoneyValues amount={item.budgetAmount} />
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={`text-xs font-medium ${statusColor}`}>
{formatPercentage(percentageDelta, true)}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => handleEdit(item)}
aria-label={`Editar orçamento de ${item.categoryName}`}
>
<RiPencilLine className="size-3.5" />
</Button>
</div>
</div>
<div className="mt-1.5 ml-11">
<Progress value={progressValue} />
</div>
</li>
);
})}
</ul>
<BudgetDialog
mode="update"
budget={selectedBudget ?? undefined}
categories={categories}
defaultPeriod={defaultPeriod}
open={editOpen && !!selectedBudget}
onOpenChange={handleEditOpenChange}
/>
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import { RiEyeLine, RiPencilLine, RiTodoLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog";
import { NoteDialog } from "@/components/anotacoes/note-dialog";
import type { Note } from "@/components/anotacoes/types";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import type { DashboardNote } from "@/lib/dashboard/notes";
import { Badge } from "../ui/badge";
import { WidgetEmptyState } from "../widget-empty-state";
type NotesWidgetProps = {
notes: DashboardNote[];
};
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const buildDisplayTitle = (value: string) => {
const trimmed = value.trim();
return trimmed.length ? trimmed : "Anotação sem título";
};
const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
id: note.id,
title: note.title,
description: note.description,
type: note.type,
tasks: note.tasks,
arquivada: note.arquivada,
createdAt: note.createdAt,
});
const getTasksSummary = (note: DashboardNote) => {
if (note.type !== "tarefa") {
return "Nota";
}
const tasks = note.tasks ?? [];
const completed = tasks.filter((task) => task.completed).length;
return `${completed}/${tasks.length} concluídas`;
};
export function NotesWidget({ notes }: NotesWidgetProps) {
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]);
const handleOpenEdit = useCallback((note: Note) => {
setNoteToEdit(note);
setIsEditOpen(true);
}, []);
const handleOpenDetails = useCallback((note: Note) => {
setNoteDetails(note);
setIsDetailsOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setIsEditOpen(open);
if (!open) {
setNoteToEdit(null);
}
}, []);
const handleDetailsOpenChange = useCallback((open: boolean) => {
setIsDetailsOpen(open);
if (!open) {
setNoteDetails(null);
}
}, []);
return (
<>
<CardContent className="flex flex-col gap-4 px-0">
{mappedNotes.length === 0 ? (
<WidgetEmptyState
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
title="Nenhuma anotação ativa"
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
/>
) : (
<ul className="flex flex-col">
{mappedNotes.map((note) => (
<li
key={note.id}
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{buildDisplayTitle(note.title)}
</p>
<div className="mt-1 flex items-center gap-2">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px]"
>
{getTasksSummary(note)}
</Badge>
<p className="truncate text-xs text-muted-foreground">
{DATE_FORMATTER.format(new Date(note.createdAt))}
</p>
</div>
</div>
<div className="flex shrink-0 items-center">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleOpenEdit(note)}
aria-label={`Editar anotação ${buildDisplayTitle(note.title)}`}
>
<RiPencilLine className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleOpenDetails(note)}
aria-label={`Ver detalhes da anotação ${buildDisplayTitle(
note.title,
)}`}
>
<RiEyeLine className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
<NoteDialog
mode="update"
note={noteToEdit ?? undefined}
open={isEditOpen}
onOpenChange={handleEditOpenChange}
/>
<NoteDetailsDialog
note={noteDetails}
open={isDetailsOpen}
onOpenChange={handleDetailsOpenChange}
/>
</>
);
}

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { import {
RiArrowDownSFill,
RiArrowUpSFill,
RiExternalLinkLine, RiExternalLinkLine,
RiGroupLine, RiGroupLine,
RiVerifiedBadgeFill, RiVerifiedBadgeFill,
@@ -17,6 +19,10 @@ type PagadoresWidgetProps = {
pagadores: DashboardPagador[]; pagadores: DashboardPagador[];
}; };
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(0)}%`;
};
const buildInitials = (value: string) => { const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean); const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) { if (parts.length === 0) {
@@ -44,6 +50,12 @@ export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
<ul className="flex flex-col"> <ul className="flex flex-col">
{pagadores.map((pagador) => { {pagadores.map((pagador) => {
const initials = buildInitials(pagador.name); const initials = buildInitials(pagador.name);
const hasValidPercentageChange =
typeof pagador.percentageChange === "number" &&
Number.isFinite(pagador.percentageChange);
const percentageChange = hasValidPercentageChange
? pagador.percentageChange
: null;
return ( return (
<li <li
@@ -87,6 +99,25 @@ export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
<div className="flex shrink-0 flex-col items-end"> <div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={pagador.totalExpenses} /> <MoneyValues amount={pagador.totalExpenses} />
{percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
percentageChange > 0
? "text-destructive"
: percentageChange < 0
? "text-success"
: "text-muted-foreground"
}`}
>
{percentageChange > 0 && (
<RiArrowUpSFill className="size-3" />
)}
{percentageChange < 0 && (
<RiArrowDownSFill className="size-3" />
)}
{formatPercentage(percentageChange)}
</span>
)}
</div> </div>
</li> </li>
); );

View File

@@ -0,0 +1,50 @@
"use client";
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
import { useState } from "react";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { PaymentConditionsWidget } from "./payment-conditions-widget";
import { PaymentMethodsWidget } from "./payment-methods-widget";
type PaymentOverviewWidgetProps = {
paymentConditionsData: PaymentConditionsData;
paymentMethodsData: PaymentMethodsData;
};
export function PaymentOverviewWidget({
paymentConditionsData,
paymentMethodsData,
}: PaymentOverviewWidgetProps) {
const [activeTab, setActiveTab] = useState<"conditions" | "methods">(
"conditions",
);
return (
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "conditions" | "methods")}
className="w-full"
>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="conditions" className="text-xs">
<RiSlideshowLine className="mr-1 size-3.5" />
Condições
</TabsTrigger>
<TabsTrigger value="methods" className="text-xs">
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
Formas
</TabsTrigger>
</TabsList>
<TabsContent value="conditions" className="mt-2">
<PaymentConditionsWidget data={paymentConditionsData} />
</TabsContent>
<TabsContent value="methods" className="mt-2">
<PaymentMethodsWidget data={paymentMethodsData} />
</TabsContent>
</Tabs>
);
}

View File

@@ -1,67 +0,0 @@
import { RiExchangeLine } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import type { RecentTransactionsData } from "@/lib/dashboard/recent-transactions";
import { WidgetEmptyState } from "../widget-empty-state";
type RecentTransactionsWidgetProps = {
data: RecentTransactionsData;
};
const formatTransactionDate = (date: Date | string) => {
const d = date instanceof Date ? date : new Date(date);
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatted = formatter.format(d);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};
export function RecentTransactionsWidget({
data,
}: RecentTransactionsWidgetProps) {
return (
<div className="flex flex-col px-0">
{data.transactions.length === 0 ? (
<WidgetEmptyState
icon={<RiExchangeLine className="size-6 text-muted-foreground" />}
title="Nenhum lançamento encontrado"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{data.transactions.map((transaction) => {
return (
<li
key={transaction.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<EstabelecimentoLogo name={transaction.name} size={37} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{transaction.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(transaction.purchaseDate)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -37,7 +37,8 @@ export function SortableWidget({
className={cn( className={cn(
"relative", "relative",
isDragging && "z-50 opacity-90", isDragging && "z-50 opacity-90",
isEditing && "cursor-grab active:cursor-grabbing", isEditing &&
"cursor-grab active:cursor-grabbing touch-none select-none",
)} )}
{...(isEditing ? { ...attributes, ...listeners } : {})} {...(isEditing ? { ...attributes, ...listeners } : {})}
> >

View File

@@ -0,0 +1,57 @@
"use client";
import { RiArrowUpDoubleLine, RiStore2Line } from "@remixicon/react";
import { useState } from "react";
import type { TopExpensesData } from "@/lib/dashboard/expenses/top-expenses";
import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { TopEstablishmentsWidget } from "./top-establishments-widget";
import { TopExpensesWidget } from "./top-expenses-widget";
type SpendingOverviewWidgetProps = {
topExpensesAll: TopExpensesData;
topExpensesCardOnly: TopExpensesData;
topEstablishmentsData: TopEstablishmentsData;
};
export function SpendingOverviewWidget({
topExpensesAll,
topExpensesCardOnly,
topEstablishmentsData,
}: SpendingOverviewWidgetProps) {
const [activeTab, setActiveTab] = useState<"expenses" | "establishments">(
"expenses",
);
return (
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "expenses" | "establishments")
}
className="w-full"
>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="expenses" className="text-xs">
<RiArrowUpDoubleLine className="mr-1 size-3.5" />
Top gastos
</TabsTrigger>
<TabsTrigger value="establishments" className="text-xs">
<RiStore2Line className="mr-1 size-3.5" />
Estabelecimentos
</TabsTrigger>
</TabsList>
<TabsContent value="expenses" className="mt-2">
<TopExpensesWidget
allExpenses={topExpensesAll}
cardOnlyExpenses={topExpensesCardOnly}
/>
</TabsContent>
<TabsContent value="establishments" className="mt-2">
<TopEstablishmentsWidget data={topEstablishmentsData} />
</TabsContent>
</Tabs>
);
}

View File

@@ -1,21 +0,0 @@
// Main page component
export { default as AnticipateInstallmentsDialog } from "./dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
export { default as BulkActionDialog } from "./dialogs/bulk-action-dialog";
export { default as LancamentoDetailsDialog } from "./dialogs/lancamento-details-dialog";
// Main dialogs
export { default as LancamentoDialog } from "./dialogs/lancamento-dialog/lancamento-dialog";
export type * from "./dialogs/lancamento-dialog/lancamento-dialog-types";
export { default as MassAddDialog } from "./dialogs/mass-add-dialog";
export { default as LancamentosPage } from "./page/lancamentos-page";
export * from "./select-items";
export { default as AnticipationCard } from "./shared/anticipation-card";
// Shared components
export { default as EstabelecimentoInput } from "./shared/estabelecimento-input";
export { default as InstallmentTimeline } from "./shared/installment-timeline";
export { default as LancamentosFilters } from "./table/lancamentos-filters";
// Table components
export { default as LancamentosTable } from "./table/lancamentos-table";
// Types and utilities
export type * from "./types";

View File

@@ -1,4 +0,0 @@
export { AnticipationCard } from "./anticipation-card";
export { EstabelecimentoInput } from "./estabelecimento-input";
export { EstabelecimentoLogo } from "./estabelecimento-logo";
export { InstallmentTimeline } from "./installment-timeline";

View File

@@ -9,6 +9,7 @@ import {
RiGroupLine, RiGroupLine,
RiPriceTag3Line, RiPriceTag3Line,
RiSparklingLine, RiSparklingLine,
RiStore2Line,
RiTodoLine, RiTodoLine,
} from "@remixicon/react"; } from "@remixicon/react";
@@ -110,6 +111,11 @@ export const NAV_SECTIONS: NavSection[] = [
icon: <RiBankCard2Line className="size-4" />, icon: <RiBankCard2Line className="size-4" />,
preservePeriod: true, preservePeriod: true,
}, },
{
href: "/relatorios/estabelecimentos",
label: "estabelecimentos",
icon: <RiStore2Line className="size-4" />,
},
], ],
}, },
]; ];

View File

@@ -9,7 +9,6 @@ import {
import { CategoryIcon } from "@/components/categorias/category-icon"; import { CategoryIcon } from "@/components/categorias/category-icon";
import { PeriodPicker } from "@/components/period-picker"; import { PeriodPicker } from "@/components/period-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -27,6 +26,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { useControlledState } from "@/hooks/use-controlled-state"; import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state"; import { useFormState } from "@/hooks/use-form-state";
@@ -54,6 +54,12 @@ const buildInitialValues = ({
amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "", amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "",
}); });
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function BudgetDialog({ export function BudgetDialog({
mode, mode,
trigger, trigger,
@@ -164,6 +170,15 @@ export function BudgetDialog({
const submitLabel = const submitLabel =
mode === "create" ? "Salvar orçamento" : "Atualizar orçamento"; mode === "create" ? "Salvar orçamento" : "Atualizar orçamento";
const disabled = categories.length === 0; const disabled = categories.length === 0;
const parsedAmount = Number.parseFloat(formState.amount);
const sliderValue = Number.isFinite(parsedAmount)
? Math.max(0, parsedAmount)
: 0;
const baseForSlider = Math.max(budget?.spent ?? 0, sliderValue, 1000);
const sliderMax = Math.max(
1000,
Math.ceil((baseForSlider * 1.5) / 100) * 100,
);
return ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -215,7 +230,7 @@ export function BudgetDialog({
</Select> </Select>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="budget-period">Período</Label> <Label htmlFor="budget-period">Período</Label>
<PeriodPicker <PeriodPicker
@@ -227,12 +242,30 @@ export function BudgetDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="budget-amount">Valor limite</Label> <Label htmlFor="budget-amount">Valor limite</Label>
<CurrencyInput <div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Limite atual</span>
<span className="font-semibold text-foreground">
{formatCurrency(sliderValue)}
</span>
</div>
<Slider
id="budget-amount" id="budget-amount"
placeholder="R$ 0,00" value={[sliderValue]}
value={formState.amount} min={0}
onValueChange={(value) => updateField("amount", value)} max={sliderMax}
step={10}
onValueChange={(value) =>
updateField("amount", value[0]?.toFixed(2) ?? "0.00")
}
/> />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{formatCurrency(0)}</span>
<span>{formatCurrency(sliderMax)}</span>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -1,13 +0,0 @@
export { CategoryCell } from "./category-cell";
export { CategoryReportCards } from "./category-report-cards";
export { CategoryReportChart } from "./category-report-chart";
export { CategoryReportExport } from "./category-report-export";
export { CategoryReportFilters } from "./category-report-filters";
export { CategoryReportPage } from "./category-report-page";
export { CategoryReportTable } from "./category-report-table";
export { CategoryTable } from "./category-table";
export type {
CategoryOption,
CategoryReportFiltersProps,
FilterState,
} from "./types";

View File

@@ -22,7 +22,7 @@ export function PeriodFilterButtons({ currentFilter }: PeriodFilterProps) {
const handleFilterChange = (filter: PeriodFilter) => { const handleFilterChange = (filter: PeriodFilter) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set("meses", filter); params.set("meses", filter);
router.push(`/top-estabelecimentos?${params.toString()}`); router.push(`/relatorios/estabelecimentos?${params.toString()}`);
}; };
return ( return (

37
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
import { Slider as SliderPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils/ui";
function Slider({
className,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
return (
<SliderPrimitive.Root
data-slot="slider"
className={cn(
"relative flex w-full touch-none items-center select-none",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="bg-muted relative h-2 w-full grow overflow-hidden rounded-full"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="bg-primary absolute h-full"
/>
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
data-slot="slider-thumb"
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-colors focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50"
/>
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -100,6 +100,27 @@ export const verification = pgTable("verification", {
}), }),
}); });
// ===================== PASSKEY (WebAuthn) =====================
export const passkey = pgTable("passkey", {
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("publicKey").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credentialID").notNull(),
counter: integer("counter").notNull(),
deviceType: text("deviceType").notNull(),
backedUp: boolean("backedUp").notNull(),
transports: text("transports"),
aaguid: text("aaguid"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}),
});
export const preferenciasUsuario = pgTable("preferencias_usuario", { export const preferenciasUsuario = pgTable("preferencias_usuario", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id") userId: text("user_id")

View File

@@ -0,0 +1,17 @@
CREATE TABLE "passkey" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"publicKey" text NOT NULL,
"userId" text NOT NULL,
"credentialID" text NOT NULL,
"counter" integer NOT NULL,
"deviceType" text NOT NULL,
"backedUp" boolean NOT NULL,
"transports" text,
"aaguid" text,
"createdAt" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "extrato_note_as_column" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "lancamentos_column_order" jsonb;--> statement-breakpoint
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,13 @@
"when": 1771166328908, "when": 1771166328908,
"tag": "0016_complete_randall", "tag": "0016_complete_randall",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
} }
] ]
} }

View File

@@ -27,7 +27,7 @@ export const revalidateConfig = {
estabelecimentos: ["/estabelecimentos", "/lancamentos"], estabelecimentos: ["/estabelecimentos", "/lancamentos"],
orcamentos: ["/orcamentos"], orcamentos: ["/orcamentos"],
pagadores: ["/pagadores"], pagadores: ["/pagadores"],
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"], anotacoes: ["/anotacoes", "/anotacoes/arquivadas", "/dashboard"],
lancamentos: ["/lancamentos", "/contas"], lancamentos: ["/lancamentos", "/contas"],
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"], inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
} as const; } as const;
@@ -39,6 +39,7 @@ const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
"cartoes", "cartoes",
"orcamentos", "orcamentos",
"pagadores", "pagadores",
"anotacoes",
"inbox", "inbox",
]); ]);

View File

@@ -1,9 +1,11 @@
import { passkeyClient } from "@better-auth/passkey/client";
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
const baseURL = process.env.BETTER_AUTH_URL?.replace(/\/$/, ""); const baseURL = process.env.BETTER_AUTH_URL?.replace(/\/$/, "");
export const authClient = createAuthClient({ export const authClient = createAuthClient({
...(baseURL ? { baseURL } : {}), ...(baseURL ? { baseURL } : {}),
plugins: [passkeyClient()],
}); });
/** /**

View File

@@ -1,3 +1,4 @@
import { passkey } from "@better-auth/passkey";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { GoogleProfile } from "better-auth/social-providers"; import type { GoogleProfile } from "better-auth/social-providers";
@@ -83,6 +84,13 @@ export const auth = betterAuth({
}, },
}, },
// Plugins
plugins: [
passkey({
rpName: "OpenMonetis",
}),
],
// Google OAuth (se configurado) // Google OAuth (se configurado)
socialProviders: socialProviders:
googleClientId && googleClientSecret googleClientId && googleClientSecret

View File

@@ -6,16 +6,16 @@ import { fetchIncomeByCategory } from "./categories/income-by-category";
import { fetchInstallmentExpenses } from "./expenses/installment-expenses"; import { fetchInstallmentExpenses } from "./expenses/installment-expenses";
import { fetchRecurringExpenses } from "./expenses/recurring-expenses"; import { fetchRecurringExpenses } from "./expenses/recurring-expenses";
import { fetchTopExpenses } from "./expenses/top-expenses"; import { fetchTopExpenses } from "./expenses/top-expenses";
import { fetchGoalsProgressData } from "./goals-progress";
import { fetchIncomeExpenseBalance } from "./income-expense-balance"; import { fetchIncomeExpenseBalance } from "./income-expense-balance";
import { fetchDashboardInvoices } from "./invoices"; import { fetchDashboardInvoices } from "./invoices";
import { fetchDashboardCardMetrics } from "./metrics"; import { fetchDashboardCardMetrics } from "./metrics";
import { fetchDashboardNotifications } from "./notifications"; import { fetchDashboardNotes } from "./notes";
import { fetchDashboardPagadores } from "./pagadores"; import { fetchDashboardPagadores } from "./pagadores";
import { fetchPaymentConditions } from "./payments/payment-conditions"; import { fetchPaymentConditions } from "./payments/payment-conditions";
import { fetchPaymentMethods } from "./payments/payment-methods"; import { fetchPaymentMethods } from "./payments/payment-methods";
import { fetchPaymentStatus } from "./payments/payment-status"; import { fetchPaymentStatus } from "./payments/payment-status";
import { fetchPurchasesByCategory } from "./purchases-by-category"; import { fetchPurchasesByCategory } from "./purchases-by-category";
import { fetchRecentTransactions } from "./recent-transactions";
import { fetchTopEstablishments } from "./top-establishments"; import { fetchTopEstablishments } from "./top-establishments";
async function fetchDashboardDataInternal(userId: string, period: string) { async function fetchDashboardDataInternal(userId: string, period: string) {
@@ -24,11 +24,11 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
accountsSnapshot, accountsSnapshot,
invoicesSnapshot, invoicesSnapshot,
boletosSnapshot, boletosSnapshot,
notificationsSnapshot, goalsProgressData,
paymentStatusData, paymentStatusData,
incomeExpenseBalanceData, incomeExpenseBalanceData,
pagadoresSnapshot, pagadoresSnapshot,
recentTransactionsData, notesData,
paymentConditionsData, paymentConditionsData,
paymentMethodsData, paymentMethodsData,
recurringExpensesData, recurringExpensesData,
@@ -44,11 +44,11 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
fetchDashboardAccounts(userId), fetchDashboardAccounts(userId),
fetchDashboardInvoices(userId, period), fetchDashboardInvoices(userId, period),
fetchDashboardBoletos(userId, period), fetchDashboardBoletos(userId, period),
fetchDashboardNotifications(userId, period), fetchGoalsProgressData(userId, period),
fetchPaymentStatus(userId, period), fetchPaymentStatus(userId, period),
fetchIncomeExpenseBalance(userId, period), fetchIncomeExpenseBalance(userId, period),
fetchDashboardPagadores(userId, period), fetchDashboardPagadores(userId, period),
fetchRecentTransactions(userId, period), fetchDashboardNotes(userId),
fetchPaymentConditions(userId, period), fetchPaymentConditions(userId, period),
fetchPaymentMethods(userId, period), fetchPaymentMethods(userId, period),
fetchRecurringExpenses(userId, period), fetchRecurringExpenses(userId, period),
@@ -66,11 +66,11 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
accountsSnapshot, accountsSnapshot,
invoicesSnapshot, invoicesSnapshot,
boletosSnapshot, boletosSnapshot,
notificationsSnapshot, goalsProgressData,
paymentStatusData, paymentStatusData,
incomeExpenseBalanceData, incomeExpenseBalanceData,
pagadoresSnapshot, pagadoresSnapshot,
recentTransactionsData, notesData,
paymentConditionsData, paymentConditionsData,
paymentMethodsData, paymentMethodsData,
recurringExpensesData, recurringExpensesData,

View File

@@ -0,0 +1,147 @@
import { and, eq, ne, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
const BUDGET_CRITICAL_THRESHOLD = 80;
export type GoalProgressStatus = "on-track" | "critical" | "exceeded";
export type GoalProgressItem = {
id: string;
categoryId: string | null;
categoryName: string;
categoryIcon: string | null;
period: string;
createdAt: string;
budgetAmount: number;
spentAmount: number;
usedPercentage: number;
status: GoalProgressStatus;
};
export type GoalProgressCategory = {
id: string;
name: string;
icon: string | null;
};
export type GoalsProgressData = {
items: GoalProgressItem[];
categories: GoalProgressCategory[];
totalBudgets: number;
exceededCount: number;
criticalCount: number;
};
const resolveStatus = (usedPercentage: number): GoalProgressStatus => {
if (usedPercentage >= 100) {
return "exceeded";
}
if (usedPercentage >= BUDGET_CRITICAL_THRESHOLD) {
return "critical";
}
return "on-track";
};
export async function fetchGoalsProgressData(
userId: string,
period: string,
): Promise<GoalsProgressData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return {
items: [],
categories: [],
totalBudgets: 0,
exceededCount: 0,
criticalCount: 0,
};
}
const [rows, categoryRows] = await Promise.all([
db
.select({
orcamentoId: orcamentos.id,
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: orcamentos.period,
createdAt: orcamentos.createdAt,
budgetAmount: orcamentos.amount,
spentAmount: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
})
.from(orcamentos)
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
.leftJoin(
lancamentos,
and(
eq(lancamentos.categoriaId, orcamentos.categoriaId),
eq(lancamentos.userId, orcamentos.userId),
eq(lancamentos.period, orcamentos.period),
eq(lancamentos.pagadorId, adminPagadorId),
eq(lancamentos.transactionType, "Despesa"),
ne(lancamentos.condition, "cancelado"),
),
)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
.groupBy(
orcamentos.id,
categorias.id,
categorias.name,
categorias.icon,
orcamentos.period,
orcamentos.createdAt,
orcamentos.amount,
),
db.query.categorias.findMany({
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
orderBy: (category, { asc }) => [asc(category.name)],
}),
]);
const categories: GoalProgressCategory[] = categoryRows.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
}));
const items: GoalProgressItem[] = rows
.map((row) => {
const budgetAmount = toNumber(row.budgetAmount);
const spentAmount = toNumber(row.spentAmount);
const usedPercentage =
budgetAmount > 0 ? (spentAmount / budgetAmount) * 100 : 0;
return {
id: row.orcamentoId,
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
period: row.period,
createdAt: row.createdAt.toISOString(),
budgetAmount,
spentAmount,
usedPercentage,
status: resolveStatus(usedPercentage),
};
})
.sort((a, b) => b.usedPercentage - a.usedPercentage);
const exceededCount = items.filter(
(item) => item.status === "exceeded",
).length;
const criticalCount = items.filter(
(item) => item.status === "critical",
).length;
return {
items,
categories,
totalBudgets: items.length,
exceededCount,
criticalCount,
};
}

73
lib/dashboard/notes.ts Normal file
View File

@@ -0,0 +1,73 @@
import { and, eq } from "drizzle-orm";
import { anotacoes } from "@/db/schema";
import { db } from "@/lib/db";
export type DashboardTask = {
id: string;
text: string;
completed: boolean;
};
export type DashboardNote = {
id: string;
title: string;
description: string;
type: "nota" | "tarefa";
tasks?: DashboardTask[];
arquivada: boolean;
createdAt: string;
};
const parseTasks = (value: string | null): DashboardTask[] | undefined => {
if (!value) {
return undefined;
}
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) {
return undefined;
}
return parsed
.filter((item): item is DashboardTask => {
if (!item || typeof item !== "object") {
return false;
}
const candidate = item as Partial<DashboardTask>;
return (
typeof candidate.id === "string" &&
typeof candidate.text === "string" &&
typeof candidate.completed === "boolean"
);
})
.map((task) => ({
id: task.id,
text: task.text,
completed: task.completed,
}));
} catch (error) {
console.error("Failed to parse dashboard note tasks", error);
return undefined;
}
};
export async function fetchDashboardNotes(
userId: string,
): Promise<DashboardNote[]> {
const notes = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
orderBy: (note, { desc }) => [desc(note.createdAt)],
limit: 5,
});
return notes.map((note) => ({
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks: parseTasks(note.tasks),
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
}));
}

View File

@@ -1,9 +1,11 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema"; import { lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common"; import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { calculatePercentageChange } from "@/lib/utils/math";
import { getPreviousPeriod } from "@/lib/utils/period";
export type DashboardPagador = { export type DashboardPagador = {
id: string; id: string;
@@ -11,6 +13,8 @@ export type DashboardPagador = {
email: string | null; email: string | null;
avatarUrl: string | null; avatarUrl: string | null;
totalExpenses: number; totalExpenses: number;
previousExpenses: number;
percentageChange: number | null;
isAdmin: boolean; isAdmin: boolean;
}; };
@@ -23,6 +27,8 @@ export async function fetchDashboardPagadores(
userId: string, userId: string,
period: string, period: string,
): Promise<DashboardPagadoresSnapshot> { ): Promise<DashboardPagadoresSnapshot> {
const previousPeriod = getPreviousPeriod(period);
const rows = await db const rows = await db
.select({ .select({
id: pagadores.id, id: pagadores.id,
@@ -30,6 +36,7 @@ export async function fetchDashboardPagadores(
email: pagadores.email, email: pagadores.email,
avatarUrl: pagadores.avatarUrl, avatarUrl: pagadores.avatarUrl,
role: pagadores.role, role: pagadores.role,
period: lancamentos.period,
totalExpenses: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`, totalExpenses: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
}) })
.from(lancamentos) .from(lancamentos)
@@ -37,7 +44,7 @@ export async function fetchDashboardPagadores(
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.period, period), inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"), eq(lancamentos.transactionType, "Despesa"),
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
@@ -51,19 +58,60 @@ export async function fetchDashboardPagadores(
pagadores.email, pagadores.email,
pagadores.avatarUrl, pagadores.avatarUrl,
pagadores.role, pagadores.role,
lancamentos.period,
) )
.orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`)); .orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`));
const pagadoresList = rows const groupedPagadores = new Map<
.map((row) => ({ string,
{
id: string;
name: string;
email: string | null;
avatarUrl: string | null;
isAdmin: boolean;
currentExpenses: number;
previousExpenses: number;
}
>();
for (const row of rows) {
const entry = groupedPagadores.get(row.id) ?? {
id: row.id, id: row.id,
name: row.name, name: row.name,
email: row.email, email: row.email,
avatarUrl: row.avatarUrl, avatarUrl: row.avatarUrl,
totalExpenses: toNumber(row.totalExpenses),
isAdmin: row.role === PAGADOR_ROLE_ADMIN, isAdmin: row.role === PAGADOR_ROLE_ADMIN,
currentExpenses: 0,
previousExpenses: 0,
};
const amount = toNumber(row.totalExpenses);
if (row.period === period) {
entry.currentExpenses = amount;
} else {
entry.previousExpenses = amount;
}
groupedPagadores.set(row.id, entry);
}
const pagadoresList = Array.from(groupedPagadores.values())
.filter((p) => p.currentExpenses > 0)
.map((pagador) => ({
id: pagador.id,
name: pagador.name,
email: pagador.email,
avatarUrl: pagador.avatarUrl,
totalExpenses: pagador.currentExpenses,
previousExpenses: pagador.previousExpenses,
percentageChange: calculatePercentageChange(
pagador.currentExpenses,
pagador.previousExpenses,
),
isAdmin: pagador.isAdmin,
})) }))
.filter((p) => p.totalExpenses > 0); .sort((a, b) => b.totalExpenses - a.totalExpenses);
const totalExpenses = pagadoresList.reduce( const totalExpenses = pagadoresList.reduce(
(sum, p) => sum + p.totalExpenses, (sum, p) => sum + p.totalExpenses,

View File

@@ -1,78 +0,0 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type RecentTransaction = {
id: string;
name: string;
amount: number;
purchaseDate: Date;
cardLogo: string | null;
accountLogo: string | null;
};
export type RecentTransactionsData = {
transactions: RecentTransaction[];
};
export async function fetchRecentTransactions(
userId: string,
period: string,
): Promise<RecentTransactionsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { transactions: [] };
}
const results = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
purchaseDate: lancamentos.purchaseDate,
cardLogo: cartoes.logo,
accountLogo: contas.logo,
note: lancamentos.note,
})
.from(lancamentos)
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
),
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt))
.limit(5);
const transactions = results.map((row): RecentTransaction => {
return {
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
purchaseDate: row.purchaseDate,
cardLogo: row.cardLogo,
accountLogo: row.accountLogo,
};
});
return {
transactions,
};
}

View File

@@ -69,7 +69,10 @@ export async function fetchTopEstablishments(
), ),
) )
.groupBy(lancamentos.name) .groupBy(lancamentos.name)
.orderBy(sql`ABS(sum(${lancamentos.amount})) DESC`) .orderBy(
sql`count(${lancamentos.id}) DESC`,
sql`ABS(sum(${lancamentos.amount})) DESC`,
)
.limit(10); .limit(10);
const establishments = rows const establishments = rows

View File

@@ -7,33 +7,30 @@ import {
RiExchangeLine, RiExchangeLine,
RiGroupLine, RiGroupLine,
RiLineChartLine, RiLineChartLine,
RiMoneyDollarCircleLine,
RiNumbersLine, RiNumbersLine,
RiPieChartLine, RiPieChartLine,
RiRefreshLine, RiRefreshLine,
RiSlideshowLine,
RiStore2Line,
RiStore3Line, RiStore3Line,
RiTodoLine,
RiWallet3Line, RiWallet3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { BoletosWidget } from "@/components/dashboard/boletos-widget"; import { BoletosWidget } from "@/components/dashboard/boletos-widget";
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart"; import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
import { GoalsProgressWidget } from "@/components/dashboard/goals-progress-widget";
import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart"; import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart";
import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget"; import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget";
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget"; import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
import { InvoicesWidget } from "@/components/dashboard/invoices-widget"; import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget"; import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget";
import { NotesWidget } from "@/components/dashboard/notes-widget";
import { PagadoresWidget } from "@/components/dashboard/pagadores-widget"; import { PagadoresWidget } from "@/components/dashboard/pagadores-widget";
import { PaymentConditionsWidget } from "@/components/dashboard/payment-conditions-widget"; import { PaymentOverviewWidget } from "@/components/dashboard/payment-overview-widget";
import { PaymentMethodsWidget } from "@/components/dashboard/payment-methods-widget";
import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget"; import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget";
import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget"; import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget";
import { RecentTransactionsWidget } from "@/components/dashboard/recent-transactions-widget";
import { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget"; import { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget";
import { TopEstablishmentsWidget } from "@/components/dashboard/top-establishments-widget"; import { SpendingOverviewWidget } from "@/components/dashboard/spending-overview-widget";
import { TopExpensesWidget } from "@/components/dashboard/top-expenses-widget";
import type { DashboardData } from "./fetch-dashboard-data"; import type { DashboardData } from "./fetch-dashboard-data";
export type WidgetConfig = { export type WidgetConfig = {
@@ -114,30 +111,49 @@ export const widgetsConfig: WidgetConfig[] = [
), ),
}, },
{ {
id: "recent-transactions", id: "notes",
title: "Lançamentos Recentes", title: "Anotações",
subtitle: "Últimas 5 despesas registradas", subtitle: "Últimas anotações ativas",
icon: <RiTodoLine className="size-4" />,
component: ({ data }) => <NotesWidget notes={data.notesData} />,
action: (
<Link
href="/anotacoes"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver todas
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
id: "goals-progress",
title: "Progresso de Orçamentos",
subtitle: "Orçamentos por categoria no período",
icon: <RiExchangeLine className="size-4" />, icon: <RiExchangeLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<RecentTransactionsWidget data={data.recentTransactionsData} /> <GoalsProgressWidget data={data.goalsProgressData} />
),
action: (
<Link
href="/orcamentos"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver todos
<RiArrowRightLine className="size-4" />
</Link>
), ),
}, },
{ {
id: "payment-conditions", id: "payment-overview",
title: "Condições de Pagamentos", title: "Comportamento de Pagamento",
subtitle: "Análise das condições", subtitle: "Despesas por condição e forma de pagamento",
icon: <RiSlideshowLine className="size-4" />, icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<PaymentConditionsWidget data={data.paymentConditionsData} /> <PaymentOverviewWidget
), paymentConditionsData={data.paymentConditionsData}
}, paymentMethodsData={data.paymentMethodsData}
{ />
id: "payment-methods",
title: "Formas de Pagamento",
subtitle: "Distribuição das despesas",
icon: <RiMoneyDollarCircleLine className="size-4" />,
component: ({ data }) => (
<PaymentMethodsWidget data={data.paymentMethodsData} />
), ),
}, },
{ {
@@ -168,35 +184,18 @@ export const widgetsConfig: WidgetConfig[] = [
), ),
}, },
{ {
id: "top-expenses", id: "spending-overview",
title: "Maiores Gastos do Mês", title: "Panorama de Gastos",
subtitle: "Top 10 Despesas", subtitle: "Principais despesas e frequência por local",
icon: <RiArrowUpDoubleLine className="size-4" />, icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<TopExpensesWidget <SpendingOverviewWidget
allExpenses={data.topExpensesAll} topExpensesAll={data.topExpensesAll}
cardOnlyExpenses={data.topExpensesCardOnly} topExpensesCardOnly={data.topExpensesCardOnly}
topEstablishmentsData={data.topEstablishmentsData}
/> />
), ),
}, },
{
id: "top-establishments",
title: "Top Estabelecimentos",
subtitle: "Frequência de gastos no período",
icon: <RiStore2Line className="size-4" />,
component: ({ data }) => (
<TopEstablishmentsWidget data={data.topEstablishmentsData} />
),
action: (
<Link
href="/top-estabelecimentos"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver mais
<RiArrowRightLine className="size-4" />
</Link>
),
},
{ {
id: "purchases-by-category", id: "purchases-by-category",
title: "Lançamentos por Categorias", title: "Lançamentos por Categorias",

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "1.7.5", "version": "1.7.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -30,6 +30,7 @@
"@ai-sdk/anthropic": "^3.0.48", "@ai-sdk/anthropic": "^3.0.48",
"@ai-sdk/google": "^3.0.33", "@ai-sdk/google": "^3.0.33",
"@ai-sdk/openai": "^3.0.36", "@ai-sdk/openai": "^3.0.36",
"@better-auth/passkey": "^1.5.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",

281
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@ai-sdk/openai': '@ai-sdk/openai':
specifier: ^3.0.36 specifier: ^3.0.36
version: 3.0.36(zod@4.3.6) version: 3.0.36(zod@4.3.6)
'@better-auth/passkey':
specifier: ^1.5.0
version: 1.5.0(@better-auth/core@1.5.0(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.4.19(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.19.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(better-call@1.3.2(zod@4.3.6))(nanostores@1.1.0)
'@dnd-kit/core': '@dnd-kit/core':
specifier: ^6.3.1 specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -268,6 +271,30 @@ packages:
kysely: ^0.28.5 kysely: ^0.28.5
nanostores: ^1.0.1 nanostores: ^1.0.1
'@better-auth/core@1.5.0':
resolution: {integrity: sha512-nDPmW7I9VGRACEei31fHaZxGwD/yICraDllZ/f25jbWXYaxDaW88RuH1ZhbOUKmGJlZtDxcjN1+YmcVIc1ioNw==}
peerDependencies:
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
'@cloudflare/workers-types': '>=4'
better-call: 1.3.2
jose: ^6.1.0
kysely: ^0.28.5
nanostores: ^1.0.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
'@better-auth/passkey@1.5.0':
resolution: {integrity: sha512-joupofIxoUEzwd3/T/d7JRSHxPx9kBLYJs6eBhcuso564/O9SrAOKbzfepEmouBMow6VA78bpwAGkVHpMkSwyg==}
peerDependencies:
'@better-auth/core': 1.5.0
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.5.0
better-call: 1.3.2
nanostores: ^1.0.1
'@better-auth/telemetry@1.4.19': '@better-auth/telemetry@1.4.19':
resolution: {integrity: sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw==} resolution: {integrity: sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw==}
peerDependencies: peerDependencies:
@@ -276,6 +303,9 @@ packages:
'@better-auth/utils@0.3.0': '@better-auth/utils@0.3.0':
resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==}
'@better-auth/utils@0.3.1':
resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==}
'@better-fetch/fetch@1.1.21': '@better-fetch/fetch@1.1.21':
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
@@ -834,6 +864,9 @@ packages:
'@floating-ui/utils@0.2.10': '@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@img/colour@1.0.0': '@img/colour@1.0.0':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1003,6 +1036,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@next/env@16.1.6': '@next/env@16.1.6':
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
@@ -1077,6 +1113,43 @@ packages:
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
'@peculiar/asn1-android@2.6.0':
resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==}
'@peculiar/asn1-cms@2.6.1':
resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==}
'@peculiar/asn1-csr@2.6.1':
resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==}
'@peculiar/asn1-ecc@2.6.1':
resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==}
'@peculiar/asn1-pfx@2.6.1':
resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==}
'@peculiar/asn1-pkcs8@2.6.1':
resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==}
'@peculiar/asn1-pkcs9@2.6.1':
resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==}
'@peculiar/asn1-rsa@2.6.1':
resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==}
'@peculiar/asn1-schema@2.6.0':
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
'@peculiar/asn1-x509-attr@2.6.1':
resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==}
'@peculiar/asn1-x509@2.6.1':
resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==}
'@peculiar/x509@1.14.3':
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@radix-ui/number@1.1.1': '@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1866,6 +1939,13 @@ packages:
peerDependencies: peerDependencies:
react: '>=18.2.0' react: '>=18.2.0'
'@simplewebauthn/browser@13.2.2':
resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==}
'@simplewebauthn/server@13.2.3':
resolution: {integrity: sha512-ZhcVBOw63birYx9jVfbhK6rTehckVes8PeWV324zpmdxr0BUfylospwMzcrxrdMcOi48MHWj2LCA+S528LnGvg==}
engines: {node: '>=20.0.0'}
'@stablelib/base64@1.0.1': '@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
@@ -2105,6 +2185,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'} engines: {node: '>=10'}
asn1js@3.0.7:
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
engines: {node: '>=12.0.0'}
babel-plugin-react-compiler@1.0.0: babel-plugin-react-compiler@1.0.0:
resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==}
@@ -2186,6 +2270,14 @@ packages:
zod: zod:
optional: true optional: true
better-call@1.3.2:
resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==}
peerDependencies:
zod: ^4.0.0
peerDependenciesMeta:
zod:
optional: true
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -2699,6 +2791,13 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.5:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
radix-ui@1.4.3: radix-ui@1.4.3:
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
peerDependencies: peerDependencies:
@@ -2791,6 +2890,9 @@ packages:
redux@5.0.1: redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
regenerator-runtime@0.13.11: regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
@@ -2827,6 +2929,9 @@ packages:
set-cookie-parser@2.7.2: set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-cookie-parser@3.0.1:
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
sharp@0.34.5: sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -2899,6 +3004,9 @@ packages:
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2907,6 +3015,10 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
tsyringe@4.10.0:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
typescript@5.9.3: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -3041,6 +3153,29 @@ snapshots:
nanostores: 1.1.0 nanostores: 1.1.0
zod: 4.3.6 zod: 4.3.6
'@better-auth/core@1.5.0(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)':
dependencies:
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
'@standard-schema/spec': 1.1.0
better-call: 1.3.2(zod@4.3.6)
jose: 6.1.3
kysely: 0.28.11
nanostores: 1.1.0
zod: 4.3.6
'@better-auth/passkey@1.5.0(@better-auth/core@1.5.0(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.4.19(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.19.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(better-call@1.3.2(zod@4.3.6))(nanostores@1.1.0)':
dependencies:
'@better-auth/core': 1.5.0(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
'@simplewebauthn/browser': 13.2.2
'@simplewebauthn/server': 13.2.3
better-auth: 1.4.19(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.19.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
better-call: 1.3.2(zod@4.3.6)
nanostores: 1.1.0
zod: 4.3.6
'@better-auth/telemetry@1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': '@better-auth/telemetry@1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))':
dependencies: dependencies:
'@better-auth/core': 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/core': 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
@@ -3049,6 +3184,8 @@ snapshots:
'@better-auth/utils@0.3.0': {} '@better-auth/utils@0.3.0': {}
'@better-auth/utils@0.3.1': {}
'@better-fetch/fetch@1.1.21': {} '@better-fetch/fetch@1.1.21': {}
'@biomejs/biome@2.4.4': '@biomejs/biome@2.4.4':
@@ -3369,6 +3506,8 @@ snapshots:
'@floating-ui/utils@0.2.10': {} '@floating-ui/utils@0.2.10': {}
'@hexagon/base64@1.1.28': {}
'@img/colour@1.0.0': '@img/colour@1.0.0':
optional: true optional: true
@@ -3485,6 +3624,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@levischuck/tiny-cbor@0.2.11': {}
'@next/env@16.1.6': {} '@next/env@16.1.6': {}
'@next/swc-darwin-arm64@16.1.6': '@next/swc-darwin-arm64@16.1.6':
@@ -3522,6 +3663,102 @@ snapshots:
'@opentelemetry/api@1.9.0': {} '@opentelemetry/api@1.9.0': {}
'@peculiar/asn1-android@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-cms@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/asn1-x509-attr': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-csr@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-ecc@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pfx@2.6.1':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-pkcs8': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.6.1':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-pfx': 2.6.1
'@peculiar/asn1-pkcs8': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/asn1-x509-attr': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-rsa@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-schema@2.6.0':
dependencies:
asn1js: 3.0.7
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-x509@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/x509@1.14.3':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-csr': 2.6.1
'@peculiar/asn1-ecc': 2.6.1
'@peculiar/asn1-pkcs9': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.10.0
'@radix-ui/number@1.1.1': {} '@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.3': {}
@@ -4348,6 +4585,19 @@ snapshots:
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
'@simplewebauthn/browser@13.2.2': {}
'@simplewebauthn/server@13.2.3':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11
'@peculiar/asn1-android': 2.6.0
'@peculiar/asn1-ecc': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/x509': 1.14.3
'@stablelib/base64@1.0.1': {} '@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
@@ -4515,6 +4765,12 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
asn1js@3.0.7:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.5
tslib: 2.8.1
babel-plugin-react-compiler@1.0.0: babel-plugin-react-compiler@1.0.0:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
@@ -4556,6 +4812,15 @@ snapshots:
optionalDependencies: optionalDependencies:
zod: 4.3.6 zod: 4.3.6
better-call@1.3.2(zod@4.3.6):
dependencies:
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
rou3: 0.7.12
set-cookie-parser: 3.0.1
optionalDependencies:
zod: 4.3.6
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
caniuse-lite@1.0.30001770: {} caniuse-lite@1.0.30001770: {}
@@ -5007,6 +5272,12 @@ snapshots:
dependencies: dependencies:
xtend: 4.0.2 xtend: 4.0.2
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.5: {}
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -5154,6 +5425,8 @@ snapshots:
redux@5.0.1: {} redux@5.0.1: {}
reflect-metadata@0.2.2: {}
regenerator-runtime@0.13.11: regenerator-runtime@0.13.11:
optional: true optional: true
@@ -5178,6 +5451,8 @@ snapshots:
set-cookie-parser@2.7.2: {} set-cookie-parser@2.7.2: {}
set-cookie-parser@3.0.1: {}
sharp@0.34.5: sharp@0.34.5:
dependencies: dependencies:
'@img/colour': 1.0.0 '@img/colour': 1.0.0
@@ -5264,6 +5539,8 @@ snapshots:
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tslib@1.14.1: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tsx@4.21.0: tsx@4.21.0:
@@ -5273,6 +5550,10 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
tsyringe@4.10.0:
dependencies:
tslib: 1.14.1
typescript@5.9.3: {} typescript@5.9.3: {}
undici-types@7.18.2: {} undici-types@7.18.2: {}