Merge pull request #23 from felipegcoutinho/feat/passkeys-complete-implementation
feat: implementar passkeys completos (login, ajustes e banco)
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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] =
|
||||||
fetchDashboardData(user.id, selectedPeriod),
|
await Promise.all([
|
||||||
fetchUserDashboardPreferences(user.id),
|
fetchDashboardData(user.id, selectedPeriod),
|
||||||
]);
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" />
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
418
components/ajustes/passkeys-form.tsx
Normal file
418
components/ajustes/passkeys-form.tsx
Normal 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>
|
||||||
|
Dê 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,53 +199,112 @@ 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 ? (
|
{!isEditing ? (
|
||||||
<>
|
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2 px-1">
|
||||||
<Button
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
variant="outline"
|
Ações rápidas
|
||||||
size="sm"
|
</span>
|
||||||
onClick={handleCancelEditing}
|
<div className="-mb-1 flex items-center gap-2 overflow-x-auto pb-1 sm:mb-0 sm:overflow-visible sm:pb-0">
|
||||||
disabled={isPending}
|
<LancamentoDialog
|
||||||
className="gap-2"
|
mode="create"
|
||||||
>
|
pagadorOptions={quickActionOptions.pagadorOptions}
|
||||||
<RiCloseLine className="size-4" />
|
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
|
||||||
Cancelar
|
defaultPagadorId={quickActionOptions.defaultPagadorId}
|
||||||
</Button>
|
contaOptions={quickActionOptions.contaOptions}
|
||||||
<Button
|
cartaoOptions={quickActionOptions.cartaoOptions}
|
||||||
size="sm"
|
categoriaOptions={quickActionOptions.categoriaOptions}
|
||||||
onClick={handleSave}
|
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||||
disabled={isPending}
|
defaultPeriod={period}
|
||||||
className="gap-2"
|
defaultTransactionType="Receita"
|
||||||
>
|
trigger={
|
||||||
<RiCheckLine className="size-4" />
|
<Button size="sm" variant="outline" className="gap-2">
|
||||||
Salvar
|
<RiArrowUpLine className="size-4 text-success/80" />
|
||||||
</Button>
|
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 />
|
||||||
<WidgetSettingsDialog
|
|
||||||
hiddenWidgets={hiddenWidgets}
|
|
||||||
onToggleWidget={handleToggleWidget}
|
|
||||||
onReset={handleReset}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleStartEditing}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<RiDragMove2Line className="size-4" />
|
|
||||||
Reordenar
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelEditing}
|
||||||
|
disabled={isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="size-4" />
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RiCheckLine className="size-4" />
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WidgetSettingsDialog
|
||||||
|
hiddenWidgets={hiddenWidgets}
|
||||||
|
onToggleWidget={handleToggleWidget}
|
||||||
|
onReset={handleReset}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleStartEditing}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RiDragMove2Line className="size-4" />
|
||||||
|
Reordenar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCorners}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
|
|||||||
146
components/dashboard/goals-progress-widget.tsx
Normal file
146
components/dashboard/goals-progress-widget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
components/dashboard/notes-widget.tsx
Normal file
157
components/dashboard/notes-widget.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
50
components/dashboard/payment-overview-widget.tsx
Normal file
50
components/dashboard/payment-overview-widget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 } : {})}
|
||||||
>
|
>
|
||||||
|
|||||||
57
components/dashboard/spending-overview-widget.tsx
Normal file
57
components/dashboard/spending-overview-widget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { AnticipationCard } from "./anticipation-card";
|
|
||||||
export { EstabelecimentoInput } from "./estabelecimento-input";
|
|
||||||
export { EstabelecimentoLogo } from "./estabelecimento-logo";
|
|
||||||
export { InstallmentTimeline } from "./installment-timeline";
|
|
||||||
@@ -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" />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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">
|
||||||
id="budget-amount"
|
<div className="flex items-center justify-between text-sm">
|
||||||
placeholder="R$ 0,00"
|
<span className="text-muted-foreground">Limite atual</span>
|
||||||
value={formState.amount}
|
<span className="font-semibold text-foreground">
|
||||||
onValueChange={(value) => updateField("amount", value)}
|
{formatCurrency(sliderValue)}
|
||||||
/>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
id="budget-amount"
|
||||||
|
value={[sliderValue]}
|
||||||
|
min={0}
|
||||||
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
|
||||||
@@ -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
37
components/ui/slider.tsx
Normal 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 };
|
||||||
21
db/schema.ts
21
db/schema.ts
@@ -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")
|
||||||
|
|||||||
17
drizzle/0017_previous_warstar.sql
Normal file
17
drizzle/0017_previous_warstar.sql
Normal 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;
|
||||||
2293
drizzle/meta/0017_snapshot.json
Normal file
2293
drizzle/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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()],
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
147
lib/dashboard/goals-progress.ts
Normal file
147
lib/dashboard/goals-progress.ts
Normal 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
73
lib/dashboard/notes.ts
Normal 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(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
281
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user