Merge pull request #24 from felipegcoutinho/feat/melhorias-gerais-do-app

Refatora hooks compartilhados e ajusta month picker
This commit is contained in:
Felipe Coutinho
2026-03-06 14:13:23 -03:00
committed by GitHub
179 changed files with 1262 additions and 923 deletions

View File

@@ -5,6 +5,22 @@ 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/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [Unreleased]
## [1.7.7] - 2026-03-05
### Alterado
- Períodos e navegação mensal: `useMonthPeriod` passou a usar os helpers centrais de período (`YYYY-MM`), o month-picker foi simplificado e o rótulo visual agora segue o formato `Março 2026`.
- Hooks e organização: hooks locais de calculadora, month-picker, logo picker e sidebar foram movidos para perto das respectivas features, deixando `/hooks` focado nos hooks realmente compartilhados.
- Estado de formulários e responsividade: `useFormState` ganhou APIs explícitas de reset/substituição no lugar do setter cru, e `useIsMobile` foi atualizado para assinatura estável com `useSyncExternalStore`, reduzindo a troca estrutural inicial no sidebar entre mobile e desktop.
- Navegação e estrutura compartilhada: `components/navbar` e `components/sidebar` foram consolidados em `components/navigation/*`, componentes globais migraram para `components/shared/*` e os imports foram padronizados no projeto.
- Dashboard e relatórios: a análise de parcelas foi movida para `/relatorios/analise-parcelas`, ações rápidas e widgets do dashboard foram refinados, e os cards de relatórios ganharam ajustes para evitar overflow no mobile.
- Pré-lançamentos e lançamentos: tabs e cards da inbox ficaram mais consistentes no mobile, itens descartados podem voltar para `Pendente` e compras feitas no dia do fechamento do cartão agora entram na próxima fatura.
- Tipografia e exportações: suporte a `SF Pro` foi removido, a validação de fontes ficou centralizada em `public/fonts/font_index.ts` e as exportações em PDF/CSV/Excel receberam melhor branding e apresentação.
- Calculadora e diálogos: o arraste ficou mais estável, os bloqueios de fechamento externo foram reforçados e o display interno foi reorganizado para uso mais consistente.
- Também houve ajustes menores de responsividade, espaçamento e acabamento visual em telas mobile, modais e detalhes de interface.
## [1.7.6] - 2026-03-02
### Adicionado

View File

@@ -10,6 +10,7 @@ import { account, pagadores, tokensApi } from "@/db/schema";
import { auth } from "@/lib/auth/config";
import { db, schema } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { DEFAULT_FONT_KEY, FONT_KEYS } from "@/public/fonts/font_index";
type ActionResponse<T = void> = {
success: boolean;
@@ -52,28 +53,12 @@ const deleteAccountSchema = z.object({
}),
});
const VALID_FONTS = [
"ai-sans",
"anthropic-sans",
"fira-code",
"fira-sans",
"geist",
"ibm-plex-mono",
"inter",
"jetbrains-mono",
"reddit-sans",
"roboto",
"sf-pro-display",
"sf-pro-rounded",
"ubuntu",
] as const;
const updatePreferencesSchema = z.object({
disableMagnetlines: z.boolean(),
extratoNoteAsColumn: z.boolean(),
lancamentosColumnOrder: z.array(z.string()).nullable(),
systemFont: z.enum(VALID_FONTS).default("ai-sans"),
moneyFont: z.enum(VALID_FONTS).default("ai-sans"),
systemFont: z.enum(FONT_KEYS).default(DEFAULT_FONT_KEY),
moneyFont: z.enum(FONT_KEYS).default(DEFAULT_FONT_KEY),
});
// Actions

View File

@@ -1,13 +1,14 @@
import { desc, eq } from "drizzle-orm";
import { tokensApi } from "@/db/schema";
import { db, schema } from "@/lib/db";
import { type FontKey, normalizeFontKey } from "@/public/fonts/font_index";
export interface UserPreferences {
disableMagnetlines: boolean;
extratoNoteAsColumn: boolean;
lancamentosColumnOrder: string[] | null;
systemFont: string;
moneyFont: string;
systemFont: FontKey;
moneyFont: FontKey;
}
export interface ApiToken {
@@ -43,7 +44,13 @@ export async function fetchUserPreferences(
.where(eq(schema.preferenciasUsuario.userId, userId))
.limit(1);
return result[0] || null;
if (!result[0]) return null;
return {
...result[0],
systemFont: normalizeFontKey(result[0].systemFont),
moneyFont: normalizeFontKey(result[0].moneyFont),
};
}
export async function fetchApiTokens(userId: string): Promise<ApiToken[]> {

View File

@@ -1,5 +1,5 @@
import { RiSettings2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Ajustes | OpenMonetis",

View File

@@ -12,6 +12,7 @@ import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { auth } from "@/lib/auth/config";
import { DEFAULT_FONT_KEY } from "@/public/fonts/font_index";
import { fetchAjustesPageData } from "./data";
export default async function Page() {
@@ -75,8 +76,8 @@ export default async function Page() {
lancamentosColumnOrder={
userPreferences?.lancamentosColumnOrder ?? null
}
systemFont={userPreferences?.systemFont ?? "ai-sans"}
moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
systemFont={userPreferences?.systemFont ?? DEFAULT_FONT_KEY}
moneyFont={userPreferences?.moneyFont ?? DEFAULT_FONT_KEY}
/>
</div>
</Card>

View File

@@ -1,5 +1,5 @@
import { RiTodoLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Anotações | OpenMonetis",

View File

@@ -1,5 +1,5 @@
import { RiCalendarEventLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Calendário | OpenMonetis",

View File

@@ -9,9 +9,9 @@ import {
lancamentos,
pagadores,
} from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { revalidateForEntity } from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { buildInvoicePaymentNote } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,

View File

@@ -1,6 +1,6 @@
import { and, desc, eq, type SQL, sum } from "drizzle-orm";
import { cartoes, faturas, lancamentos } from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { buildInvoicePaymentNote } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,

View File

@@ -2,7 +2,7 @@ import {
FilterSkeleton,
InvoiceSummaryCardSkeleton,
TransactionsTableSkeleton,
} from "@/components/skeletons";
} from "@/components/shared/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
/**

View File

@@ -1,5 +1,5 @@
import { RiBankCard2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Cartões | OpenMonetis",

View File

@@ -1,5 +1,5 @@
import { RiPriceTag3Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Categorias | OpenMonetis",

View File

@@ -1,5 +1,5 @@
import { RiHistoryLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Cartões | OpenMonetis",

View File

@@ -1,6 +1,6 @@
import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";

View File

@@ -2,7 +2,7 @@ import {
AccountStatementCardSkeleton,
FilterSkeleton,
TransactionsTableSkeleton,
} from "@/components/skeletons";
} from "@/components/shared/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
/**

View File

@@ -3,19 +3,19 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
import {
INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/lib/accounts/constants";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import {
INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/lib/contas/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";

View File

@@ -1,6 +1,6 @@
import { and, eq, ilike, not, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import { loadLogoOptions } from "@/lib/logo/options";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";

View File

@@ -1,5 +1,5 @@
import { RiBankLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Contas | OpenMonetis",

View File

@@ -1,10 +1,6 @@
import { DashboardGridSkeleton } from "@/components/skeletons";
import { DashboardGridSkeleton } from "@/components/shared/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state para a página do dashboard
* Estrutura: Welcome Banner → Month Picker → Section Cards → Widget Grid
*/
export default function DashboardLoading() {
return (
<main className="flex flex-col gap-4">

View File

@@ -16,8 +16,8 @@ import {
orcamentos,
pagadores,
} from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { getUser } from "@/lib/auth/server";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import {

View File

@@ -1,5 +1,5 @@
import { RiSparklingLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Insights | OpenMonetis",

View File

@@ -10,15 +10,15 @@ import {
lancamentos,
pagadores,
} from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
import {
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/lib/accounts/constants";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
} from "@/lib/contas/constants";
import { db } from "@/lib/db";
import {
LANCAMENTO_CONDITIONS,

View File

@@ -17,10 +17,10 @@ import {
generateAnticipationNote,
} from "@/lib/installments/anticipation-helpers";
import type {
InstallmentAnticipationWithRelations,
CancelAnticipationInput,
CreateAnticipationInput,
EligibleInstallment,
InstallmentAnticipationWithRelations,
} from "@/lib/installments/anticipation-types";
import { uuidSchema } from "@/lib/schemas/common";
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
@@ -94,7 +94,7 @@ export async function getEligibleInstallmentsAction(
},
});
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
const eligibleInstallments: EligibleInstallment[] = rows.map((row: any) => ({
id: row.id,
name: row.name,
amount: row.amount,
@@ -110,10 +110,11 @@ export async function getEligibleInstallmentsAction(
return {
success: true,
message: "Parcelas elegíveis carregadas.",
data: eligibleInstallments,
};
} catch (error) {
return handleActionError(error);
return handleActionError(error) as ActionResult<EligibleInstallment[]>;
}
}
@@ -154,7 +155,7 @@ export async function createInstallmentAnticipationAction(
// 2. Calcular valor total
const totalAmountCents = installments.reduce(
(sum, inst) => sum + Number(inst.amount) * 100,
(sum: number, inst: any) => sum + Number(inst.amount) * 100,
0,
);
const totalAmount = totalAmountCents / 100;
@@ -181,7 +182,7 @@ export async function createInstallmentAnticipationAction(
const firstInstallment = installments[0];
// 4. Criar lançamento e antecipação em transação
await db.transaction(async (tx) => {
await db.transaction(async (tx: any) => {
// 4.1. Criar o lançamento de antecipação (com desconto aplicado)
const [newLancamento] = await tx
.insert(lancamentos)
@@ -205,7 +206,7 @@ export async function createInstallmentAnticipationAction(
note:
data.note ||
generateAnticipationNote(
installments.map((inst) => ({
installments.map((inst: any) => ({
id: inst.id,
name: inst.name,
amount: inst.amount,
@@ -329,10 +330,13 @@ export async function getInstallmentAnticipationsAction(
return {
success: true,
message: "Antecipações carregadas.",
data: anticipations,
};
} catch (error) {
return handleActionError(error);
return handleActionError(
error,
) as ActionResult<InstallmentAnticipationWithRelations[]>;
}
}
@@ -347,7 +351,7 @@ export async function cancelInstallmentAnticipationAction(
const user = await getUser();
const data = cancelAnticipationSchema.parse(input);
await db.transaction(async (tx) => {
await db.transaction(async (tx: any) => {
// 1. Buscar antecipação usando query builder
const anticipationRows = await tx
.select({
@@ -469,9 +473,12 @@ export async function getAnticipationDetailsAction(
return {
success: true,
message: "Detalhes da antecipação carregados.",
data: anticipation,
};
} catch (error) {
return handleActionError(error);
return handleActionError(
error,
) as ActionResult<InstallmentAnticipationWithRelations>;
}
}

View File

@@ -6,7 +6,7 @@ import {
lancamentos,
pagadores,
} from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
import { db } from "@/lib/db";
export async function fetchLancamentos(filters: SQL[]) {

View File

@@ -1,5 +1,5 @@
import { RiArrowLeftRightLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Lançamentos | OpenMonetis",

View File

@@ -1,7 +1,7 @@
import {
FilterSkeleton,
TransactionsTableSkeleton,
} from "@/components/skeletons";
} from "@/components/shared/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
/**

View File

@@ -1,5 +1,5 @@
import { FontProvider } from "@/components/font-provider";
import { AppNavbar } from "@/components/navbar/app-navbar";
import { AppNavbar } from "@/components/navigation/navbar/app-navbar";
import { PrivacyProvider } from "@/components/privacy-provider";
import { getUserSession } from "@/lib/auth/server";
import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";

View File

@@ -6,7 +6,7 @@ import {
orcamentos,
pagadores,
} from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";

View File

@@ -1,5 +1,5 @@
import { RiBarChart2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Orçamentos | OpenMonetis",

View File

@@ -1,5 +1,5 @@
import { RiGroupLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Pagadores | OpenMonetis",

View File

@@ -1,10 +1,9 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { preLancamentos } from "@/db/schema";
import { handleActionError } from "@/lib/actions/helpers";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
@@ -17,6 +16,10 @@ const discardInboxSchema = z.object({
inboxItemId: z.string().uuid("ID do item inválido"),
});
const restoreDiscardedInboxSchema = z.object({
inboxItemId: z.string().uuid("ID do item inválido"),
});
const bulkDiscardSchema = z.object({
inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"),
});
@@ -30,9 +33,7 @@ const bulkDeleteInboxSchema = z.object({
});
function revalidateInbox() {
revalidatePath("/pre-lancamentos");
revalidatePath("/lancamentos");
revalidatePath("/dashboard");
revalidateForEntity("inbox");
}
/**
@@ -166,6 +167,54 @@ export async function bulkDiscardInboxItemsAction(
}
}
export async function restoreDiscardedInboxItemAction(
input: z.infer<typeof restoreDiscardedInboxSchema>,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = restoreDiscardedInboxSchema.parse(input);
const [item] = await db
.select({ id: preLancamentos.id })
.from(preLancamentos)
.where(
and(
eq(preLancamentos.id, data.inboxItemId),
eq(preLancamentos.userId, user.id),
eq(preLancamentos.status, "discarded"),
),
)
.limit(1);
if (!item) {
return {
success: false,
error: "Item não encontrado ou não está descartado.",
};
}
await db
.update(preLancamentos)
.set({
status: "pending",
discardedAt: null,
updatedAt: new Date(),
})
.where(
and(
eq(preLancamentos.id, data.inboxItemId),
eq(preLancamentos.userId, user.id),
),
);
revalidateInbox();
return { success: true, message: "Item voltou para pendentes." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteInboxItemAction(
input: z.infer<typeof deleteInboxSchema>,
): Promise<ActionResult> {

View File

@@ -1,5 +1,5 @@
import { RiAtLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Pré-Lançamentos | OpenMonetis",

View File

@@ -1,5 +1,5 @@
import { RiSecurePaymentLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Análise de Parcelas | OpenMonetis",

View File

@@ -1,5 +1,5 @@
import { RiStore2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Top Estabelecimentos | OpenMonetis",

View File

@@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-4 px-6">
<main className="flex flex-col 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">
<Skeleton className="h-8 w-48" />

View File

@@ -1,14 +1,14 @@
import { EstablishmentsList } from "@/components/top-estabelecimentos/establishments-list";
import { HighlightsCards } from "@/components/top-estabelecimentos/highlights-cards";
import { PeriodFilterButtons } from "@/components/top-estabelecimentos/period-filter";
import { SummaryCards } from "@/components/top-estabelecimentos/summary-cards";
import { TopCategories } from "@/components/top-estabelecimentos/top-categories";
import { EstablishmentsList } from "@/components/relatorios/estabelecimentos/establishments-list";
import { HighlightsCards } from "@/components/relatorios/estabelecimentos/highlights-cards";
import { PeriodFilterButtons } from "@/components/relatorios/estabelecimentos/period-filter";
import { SummaryCards } from "@/components/relatorios/estabelecimentos/summary-cards";
import { TopCategories } from "@/components/relatorios/estabelecimentos/top-categories";
import { Card } from "@/components/ui/card";
import { getUser } from "@/lib/auth/server";
import {
fetchTopEstabelecimentosData,
type PeriodFilter,
} from "@/lib/top-estabelecimentos/fetch-data";
} from "@/lib/relatorios/estabelecimentos/fetch-data";
import { parsePeriodParam } from "@/lib/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;

View File

@@ -1,5 +1,5 @@
import { RiFileChartLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Tendências | OpenMonetis",

View File

@@ -1,4 +1,4 @@
import { CategoryReportSkeleton } from "@/components/skeletons/category-report-skeleton";
import { CategoryReportSkeleton } from "@/components/shared/skeletons/category-report-skeleton";
export default function Loading() {
return (

View File

@@ -1,5 +1,5 @@
import { RiBankCard2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
import PageDescription from "@/components/shared/page-description";
export const metadata = {
title: "Uso de Cartões | OpenMonetis",

View File

@@ -36,7 +36,7 @@ import {
DEFAULT_LANCAMENTOS_COLUMN_ORDER,
LANCAMENTOS_COLUMN_LABELS,
} from "@/lib/lancamentos/column-order";
import { FONT_OPTIONS, getFontVariable } from "@/public/fonts/font_index";
import { FONT_OPTIONS } from "@/public/fonts/font_index";
interface PreferencesFormProps {
disableMagnetlines: boolean;
@@ -189,14 +189,6 @@ export function PreferencesForm({
))}
</SelectContent>
</Select>
<p
className="text-sm text-muted-foreground pt-1"
style={{
fontFamily: getFontVariable(selectedSystemFont),
}}
>
Suas finanças em um lugar
</p>
</div>
{/* Fonte de valores */}
@@ -223,14 +215,6 @@ export function PreferencesForm({
))}
</SelectContent>
</Select>
<p
className="text-sm text-muted-foreground pt-1 tabular-nums"
style={{
fontFamily: getFontVariable(selectedMoneyFont),
}}
>
R$ 1.234,56
</p>
</div>
</section>

View File

@@ -4,7 +4,7 @@ import {
RiArchiveLine,
RiCheckLine,
RiDeleteBin5Line,
RiEyeLine,
RiFileList2Line,
RiInboxUnarchiveLine,
RiPencilLine,
} from "@remixicon/react";
@@ -60,7 +60,7 @@ export function NoteCard({
},
{
label: "detalhes",
icon: <RiEyeLine className="size-4" aria-hidden />,
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onDetails,
variant: "default" as const,
},
@@ -115,7 +115,9 @@ export function NoteCard({
</div>
<span
className={`leading-relaxed ${
task.completed ? "text-muted-foreground" : "text-foreground"
task.completed
? "text-muted-foreground line-through"
: "text-foreground"
}`}
>
{task.text}

View File

@@ -72,11 +72,11 @@ export function NoteDetailsDialog({
</DialogHeader>
{isTask ? (
<div className="max-h-[320px] overflow-auto space-y-3">
<Card className="max-h-[320px] overflow-auto gap-2 p-2">
{sortedTasks.map((task) => (
<Card
<div
key={task.id}
className="flex gap-3 p-3 flex-row items-center"
className="flex items-center gap-3 px-3 py-1.5 space-y-1 rounded-md hover:bg-muted/50"
>
<div
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
@@ -91,14 +91,16 @@ export function NoteDetailsDialog({
</div>
<span
className={`text-sm ${
task.completed ? "text-muted-foreground" : "text-foreground"
task.completed
? "text-muted-foreground line-through"
: "text-foreground"
}`}
>
{task.text}
</span>
</Card>
))}
</div>
))}
</Card>
) : (
<div className="max-h-[320px] overflow-auto whitespace-pre-line wrap-break-word text-sm text-foreground">
{note.description}

View File

@@ -85,17 +85,17 @@ export function NoteDialog({
const initialState = buildInitialValues(note);
const { formState, updateField, setFormState } =
const { formState, resetForm, updateField } =
useFormState<NoteFormValues>(initialState);
useEffect(() => {
if (dialogOpen) {
setFormState(buildInitialValues(note));
resetForm(buildInitialValues(note));
setErrorMessage(null);
setNewTaskText("");
requestAnimationFrame(() => titleRef.current?.focus());
}
}, [dialogOpen, note, setFormState]);
}, [dialogOpen, note, resetForm]);
const dialogTitle = mode === "create" ? "Nova anotação" : "Editar anotação";
const description =
@@ -338,7 +338,7 @@ export function NoteDialog({
</div>
{sortedTasks.length > 0 && (
<div className="space-y-1 max-h-[240px] overflow-y-auto pr-1">
<div className="space-y-1 max-h-[300px] overflow-y-auto pr-1 mt-4 rounded-md p-2 bg-card ">
{sortedTasks.map((task) => (
<div
key={task.id}

View File

@@ -8,7 +8,7 @@ import {
deleteNoteAction,
} from "@/app/(dashboard)/anotacoes/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "../ui/card";

View File

@@ -16,8 +16,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useDraggableDialog } from "@/hooks/use-draggable-dialog";
import { cn } from "@/lib/utils/ui";
import { useDraggableDialog } from "./use-draggable-dialog";
type Variant = React.ComponentProps<typeof Button>["variant"];
type Size = React.ComponentProps<typeof Button>["size"];
@@ -50,8 +50,11 @@ export function CalculatorDialogContent({
return (
<DialogContent
ref={contentRefCallback}
className="p-4 sm:max-w-sm"
className="p-5 sm:max-w-sm sm:p-6"
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onFocusOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader
className="cursor-grab select-none space-y-2 active:cursor-grabbing"

View File

@@ -1,5 +1,6 @@
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils/ui";
export type CalculatorDisplayProps = {
history: string | null;
@@ -7,6 +8,7 @@ export type CalculatorDisplayProps = {
resultText: string | null;
copied: boolean;
onCopy: () => void;
isResultView: boolean;
};
export function CalculatorDisplay({
@@ -15,14 +17,27 @@ export function CalculatorDisplay({
resultText,
copied,
onCopy,
isResultView,
}: CalculatorDisplayProps) {
return (
<div className="rounded-xl border bg-muted px-4 py-5 text-right">
{history && (
<div className="text-sm text-muted-foreground">{history}</div>
<div className="flex h-24 flex-col rounded-xl border bg-muted px-4 py-4 text-right">
<div className="min-h-5 truncate text-sm text-muted-foreground">
{history ?? (
<span
className="pointer-events-none opacity-0 select-none"
aria-hidden
>
0 + 0
</span>
)}
<div className="flex items-center justify-end gap-2">
<div className="text-right text-3xl font-semibold tracking-tight tabular-nums">
</div>
<div className="mt-auto flex items-end justify-end gap-2">
<div
className={cn(
"truncate text-right font-semibold tracking-tight tabular-nums leading-none transition-all",
isResultView ? "text-2xl" : "text-3xl",
)}
>
{expression}
</div>
{resultText && (

View File

@@ -1,5 +1,5 @@
import type { CalculatorButtonConfig } from "@/components/calculadora/use-calculator-state";
import { Button } from "@/components/ui/button";
import type { CalculatorButtonConfig } from "@/hooks/use-calculator-state";
import type { Operator } from "@/lib/utils/calculator";
import { cn } from "@/lib/utils/ui";

View File

@@ -1,9 +1,9 @@
"use client";
import { CalculatorKeypad } from "@/components/calculadora/calculator-keypad";
import { useCalculatorKeyboard } from "@/components/calculadora/use-calculator-keyboard";
import { useCalculatorState } from "@/components/calculadora/use-calculator-state";
import { Button } from "@/components/ui/button";
import { useCalculatorKeyboard } from "@/hooks/use-calculator-keyboard";
import { useCalculatorState } from "@/hooks/use-calculator-state";
import { CalculatorDisplay } from "./calculator-display";
type CalculatorProps = {
@@ -64,6 +64,7 @@ export default function Calculator({
resultText={resultText}
copied={copied}
onCopy={copyToClipboard}
isResultView={Boolean(history)}
/>
<CalculatorKeypad buttons={buttons} activeOperator={operator} />
{onSelectValue && (

View File

@@ -10,10 +10,17 @@ function clampPosition(
elementWidth: number,
elementHeight: number,
): Position {
const maxX = window.innerWidth - MIN_VISIBLE_PX;
const minX = MIN_VISIBLE_PX - elementWidth;
const maxY = window.innerHeight - MIN_VISIBLE_PX;
const minY = MIN_VISIBLE_PX - elementHeight;
// Dialog starts centered (left/top 50% + translate(-50%, -50%)).
// Clamp offsets so at least MIN_VISIBLE_PX remains visible on each axis.
const halfViewportWidth = window.innerWidth / 2;
const halfViewportHeight = window.innerHeight / 2;
const halfElementWidth = elementWidth / 2;
const halfElementHeight = elementHeight / 2;
const minX = MIN_VISIBLE_PX - (halfViewportWidth + halfElementWidth);
const maxX = halfViewportWidth + halfElementWidth - MIN_VISIBLE_PX;
const minY = MIN_VISIBLE_PX - (halfViewportHeight + halfElementHeight);
const maxY = halfViewportHeight + halfElementHeight - MIN_VISIBLE_PX;
return {
x: Math.min(Math.max(x, minX), maxX),
@@ -21,11 +28,14 @@ function clampPosition(
};
}
function applyTranslate(el: HTMLElement, x: number, y: number) {
function applyPosition(el: HTMLElement, x: number, y: number) {
if (x === 0 && y === 0) {
el.style.translate = "";
el.style.transform = "";
} else {
el.style.translate = `${x}px ${y}px`;
// Keep the dialog's centered baseline (-50%, -50%) and only add drag offset.
el.style.translate = `calc(-50% + ${x}px) calc(-50% + ${y}px)`;
el.style.transform = "";
}
}
@@ -56,18 +66,28 @@ export function useDraggableDialog() {
const clamped = clampPosition(rawX, rawY, el.offsetWidth, el.offsetHeight);
offset.current = clamped;
applyTranslate(el, clamped.x, clamped.y);
applyPosition(el, clamped.x, clamped.y);
}, []);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLElement>) => {
dragStart.current = null;
if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}
}, []);
const onPointerCancel = useCallback(() => {
dragStart.current = null;
}, []);
const onLostPointerCapture = useCallback(() => {
dragStart.current = null;
}, []);
const resetPosition = useCallback(() => {
offset.current = { x: 0, y: 0 };
if (contentRef.current) {
applyTranslate(contentRef.current, 0, 0);
applyPosition(contentRef.current, 0, 0);
}
}, []);
@@ -75,6 +95,8 @@ export function useDraggableDialog() {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture,
style: { touchAction: "none" as const, cursor: "grab" },
};

View File

@@ -13,6 +13,7 @@ import {
updateCardAction,
} from "@/app/(dashboard)/cartoes/actions";
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
import { useLogoSelection } from "@/components/logo-picker/use-logo-selection";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -25,7 +26,6 @@ import {
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useLogoSelection } from "@/hooks/use-logo-selection";
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
import { formatLimitInput } from "@/lib/utils/currency";
import { CardFormFields } from "./card-form-fields";
@@ -100,16 +100,16 @@ export function CardDialog({
);
// Use form state hook for form management
const { formState, updateField, updateFields, setFormState } =
const { formState, resetForm, updateField, updateFields } =
useFormState<CardFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, initialState, resetForm]);
// Close logo dialog when main dialog closes
useEffect(() => {
@@ -173,7 +173,7 @@ export function CardDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}
@@ -181,7 +181,7 @@ export function CardDialog({
toast.error(result.error);
});
},
[card?.id, formState, initialState, mode, setDialogOpen, setFormState],
[card?.id, formState, initialState, mode, resetForm, setDialogOpen],
);
const title = mode === "create" ? "Novo cartão" : "Editar cartão";

View File

@@ -3,7 +3,7 @@
import {
RiChat3Line,
RiDeleteBin5Line,
RiEyeLine,
RiFileList2Line,
RiPencilLine,
} from "@remixicon/react";
import Image from "next/image";
@@ -143,7 +143,7 @@ export function CardItem({
},
{
label: "ver fatura",
icon: <RiEyeLine className="size-4" aria-hidden />,
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onInvoice,
className: "text-primary",
},

View File

@@ -6,7 +6,7 @@ import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteCardAction } from "@/app/(dashboard)/cartoes/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

View File

@@ -76,16 +76,16 @@ export function CategoryDialog({
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
const { formState, resetForm, updateField } =
useFormState<CategoryFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, setFormState, initialState]);
}, [dialogOpen, initialState, resetForm]);
// Clear error when dialog closes
useEffect(() => {
@@ -123,7 +123,7 @@ export function CategoryDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}

View File

@@ -13,6 +13,7 @@ import {
updateAccountAction,
} from "@/app/(dashboard)/contas/actions";
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
import { useLogoSelection } from "@/components/logo-picker/use-logo-selection";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -25,7 +26,6 @@ import {
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useLogoSelection } from "@/hooks/use-logo-selection";
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
import { formatInitialBalanceInput } from "@/lib/utils/currency";
@@ -126,16 +126,16 @@ export function AccountDialog({
);
// Use form state hook for form management
const { formState, updateField, updateFields, setFormState } =
const { formState, resetForm, updateField, updateFields } =
useFormState<AccountFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, initialState, resetForm]);
// Close logo dialog when main dialog closes
useEffect(() => {
@@ -190,7 +190,7 @@ export function AccountDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}
@@ -198,7 +198,7 @@ export function AccountDialog({
toast.error(result.error);
});
},
[account?.id, formState, initialState, mode, setDialogOpen, setFormState],
[account?.id, formState, initialState, mode, resetForm, setDialogOpen],
);
const title = mode === "create" ? "Nova conta" : "Editar conta";

View File

@@ -8,7 +8,7 @@ import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { AccountCard } from "@/components/contas/account-card";
import { EmptyState } from "@/components/empty-state";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getCurrentPeriod } from "@/lib/utils/period";

View File

@@ -245,7 +245,7 @@ export function BoletosWidget({ boletos }: BoletosWidgetProps) {
}}
>
<DialogContent
className="max-w-md"
className="max-w-[calc(100%-2rem)] sm:max-w-md"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();

View File

@@ -16,8 +16,7 @@ import {
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import {
RiArrowDownLine,
RiArrowUpLine,
RiAddCircleLine,
RiCheckLine,
RiCloseLine,
RiDragMove2Line,
@@ -201,11 +200,11 @@ export function DashboardGridEditable({
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-2">
{!isEditing ? (
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2 px-1">
<div className="flex w-full min-w-0 flex-col gap-1 px-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Ações rápidas
</span>
<div className="-mb-1 flex items-center gap-2 overflow-x-auto pb-1 sm:mb-0 sm:overflow-visible sm:pb-0">
<div className="-mb-1 grid w-full grid-cols-3 gap-1 pb-1 sm:mb-0 sm:flex sm:w-auto sm:items-center sm:gap-2 sm:overflow-visible sm:pb-0">
<LancamentoDialog
mode="create"
pagadorOptions={quickActionOptions.pagadorOptions}
@@ -218,9 +217,16 @@ export function DashboardGridEditable({
defaultPeriod={period}
defaultTransactionType="Receita"
trigger={
<Button size="sm" variant="outline" className="gap-2">
<RiArrowUpLine className="size-4 text-success/80" />
Nova receita
<Button
size="sm"
variant="outline"
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
>
<span className="flex items-center gap-0.5">
<RiAddCircleLine className="size-3.5 shrink-0 text-success/80" />
</span>
<span className="sm:hidden">Receita</span>
<span className="hidden sm:inline">Nova receita</span>
</Button>
}
/>
@@ -236,18 +242,30 @@ export function DashboardGridEditable({
defaultPeriod={period}
defaultTransactionType="Despesa"
trigger={
<Button size="sm" variant="outline" className="gap-2">
<RiArrowDownLine className="size-4 text-destructive/80" />
Nova despesa
<Button
size="sm"
variant="outline"
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
>
<span className="flex items-center gap-0.5">
<RiAddCircleLine className="size-3.5 shrink-0 text-destructive/80" />
</span>
<span className="sm:hidden">Despesa</span>
<span className="hidden sm:inline">Nova despesa</span>
</Button>
}
/>
<NoteDialog
mode="create"
trigger={
<Button size="sm" variant="outline" className="gap-2">
<RiTodoLine className="size-4 text-info/80" />
Nova anotação
<Button
size="sm"
variant="outline"
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
>
<RiTodoLine className="size-3.5 shrink-0 text-info/80" />
<span className="sm:hidden">Anotação</span>
<span className="hidden sm:inline">Nova anotação</span>
</Button>
}
/>
@@ -257,7 +275,7 @@ export function DashboardGridEditable({
<div />
)}
<div className="flex items-center gap-2">
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
{isEditing ? (
<>
<Button
@@ -281,22 +299,23 @@ export function DashboardGridEditable({
</Button>
</>
) : (
<>
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto">
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
triggerClassName="w-full sm:w-auto"
/>
<Button
variant="outline"
size="sm"
onClick={handleStartEditing}
className="gap-2"
className="w-full gap-2 sm:w-auto"
>
<RiDragMove2Line className="size-4" />
Reordenar
</Button>
</>
</div>
)}
</div>
</div>

View File

@@ -80,7 +80,7 @@ export function InstallmentGroupCard({
{group.cartaoLogo && (
<img
src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName}
alt={group.cartaoName ?? "Cartão"}
className="h-6 w-auto object-contain rounded"
/>
)}

View File

@@ -1,7 +1,6 @@
import type {
InstallmentAnalysisData,
InstallmentGroup,
PendingInvoice,
} from "@/lib/dashboard/expenses/installment-analysis";
export type { InstallmentAnalysisData, InstallmentGroup, PendingInvoice };
export type { InstallmentAnalysisData, InstallmentGroup };

View File

@@ -419,7 +419,7 @@ export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
}}
>
<DialogContent
className="max-w-md"
className="max-w-[calc(100%-2rem)] sm:max-w-md"
onEscapeKeyDown={(event) => {
if (modalState === "processing") {
event.preventDefault();

View File

@@ -1,6 +1,6 @@
"use client";
import { RiEyeLine, RiPencilLine, RiTodoLine } from "@remixicon/react";
import { RiFileList2Line, 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";
@@ -100,13 +100,10 @@ export function NotesWidget({ notes }: NotesWidgetProps) {
{buildDisplayTitle(note.title)}
</p>
<div className="mt-1 flex items-center gap-2">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px]"
>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{getTasksSummary(note)}
</Badge>
<p className="truncate text-xs text-muted-foreground">
<p className="truncate text-[11px] text-muted-foreground">
{DATE_FORMATTER.format(new Date(note.createdAt))}
</p>
</div>
@@ -131,7 +128,7 @@ export function NotesWidget({ notes }: NotesWidgetProps) {
note.title,
)}`}
>
<RiEyeLine className="size-4" />
<RiFileList2Line className="size-4" />
</Button>
</div>
</li>

View File

@@ -14,24 +14,31 @@ import {
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { widgetsConfig } from "@/lib/dashboard/widgets/widgets-config";
import { cn } from "@/lib/utils";
type WidgetSettingsDialogProps = {
hiddenWidgets: string[];
onToggleWidget: (widgetId: string) => void;
onReset: () => void;
triggerClassName?: string;
};
export function WidgetSettingsDialog({
hiddenWidgets,
onToggleWidget,
onReset,
triggerClassName,
}: WidgetSettingsDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Button
variant="outline"
size="sm"
className={cn("gap-2", triggerClassName)}
>
<RiSettings4Line className="size-4" />
Widgets
</Button>

View File

@@ -17,12 +17,12 @@ import {
saveInsightsAction,
} from "@/app/(dashboard)/insights/actions";
import { DEFAULT_MODEL } from "@/app/(dashboard)/insights/data";
import { EmptyState } from "@/components/shared/empty-state";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { InsightsResponse } from "@/lib/schemas/insights";
import { EmptyState } from "../empty-state";
import { InsightsGrid } from "./insights-grid";
import { ModelSelector } from "./model-selector";

View File

@@ -60,7 +60,7 @@ interface AnticipateInstallmentsDialogProps {
type AnticipationFormValues = {
anticipationPeriod: string;
discount: number;
discount: string;
pagadorId: string;
categoriaId: string;
note: string;
@@ -92,10 +92,10 @@ export function AnticipateInstallmentsDialog({
);
// Use form state hook for form management
const { formState, updateField, setFormState } =
const { formState, replaceForm, updateField } =
useFormState<AnticipationFormValues>({
anticipationPeriod: defaultPeriod,
discount: 0,
discount: "0",
pagadorId: "",
categoriaId: "",
note: "",
@@ -110,24 +110,26 @@ export function AnticipateInstallmentsDialog({
getEligibleInstallmentsAction(seriesId)
.then((result) => {
if (result.success && result.data) {
setEligibleInstallments(result.data);
if (!result.success) {
toast.error(result.error || "Erro ao carregar parcelas");
setEligibleInstallments([]);
return;
}
const installments = result.data ?? [];
setEligibleInstallments(installments);
// Pré-preencher pagador e categoria da primeira parcela
if (result.data.length > 0) {
const first = result.data[0];
setFormState({
if (installments.length > 0) {
const first = installments[0];
replaceForm({
anticipationPeriod: defaultPeriod,
discount: 0,
discount: "0",
pagadorId: first.pagadorId ?? "",
categoriaId: first.categoriaId ?? "",
note: "",
});
}
} else {
toast.error(result.error || "Erro ao carregar parcelas");
setEligibleInstallments([]);
}
})
.catch((error) => {
console.error("Erro ao buscar parcelas:", error);
@@ -138,7 +140,7 @@ export function AnticipateInstallmentsDialog({
setIsLoadingInstallments(false);
});
}
}, [dialogOpen, seriesId, defaultPeriod, setFormState]);
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
const totalAmount = useMemo(() => {
return eligibleInstallments
@@ -268,9 +270,7 @@ export function AnticipateInstallmentsDialog({
<CurrencyInput
id="anticipation-discount"
value={formState.discount}
onValueChange={(value) =>
updateField("discount", value ?? 0)
}
onValueChange={(value) => updateField("discount", value)}
placeholder="R$ 0,00"
disabled={isPending}
/>

View File

@@ -59,14 +59,15 @@ export function AnticipationHistoryDialog({
try {
const result = await getInstallmentAnticipationsAction(seriesId);
if (result.success && result.data) {
setAnticipations(result.data);
} else {
if (!result.success) {
toast.error(
result.error || "Erro ao carregar histórico de antecipações",
);
setAnticipations([]);
return;
}
setAnticipations(result.data ?? []);
} catch (error) {
console.error("Erro ao buscar antecipações:", error);
toast.error("Erro ao carregar histórico de antecipações");

View File

@@ -53,9 +53,9 @@ export function LancamentoDetailsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 sm:max-w-xl">
<div className="gap-2 space-y-4 py-6">
<CardHeader className="flex flex-row items-start border-b">
<DialogContent className="p-0 sm:max-w-xl sm:border-0 sm:p-2">
<div className="gap-2 space-y-4 py-4">
<CardHeader className="flex flex-row items-start border-b sm:border-b-0">
<div>
<DialogTitle className="group flex items-center gap-2 text-lg">
#{lancamento.id}

View File

@@ -2,7 +2,7 @@
import { useState } from "react";
import { Label } from "@/components/ui/label";
import { MonthPicker } from "@/components/ui/monthpicker";
import { MonthPicker } from "@/components/ui/month-picker";
import {
Popover,
PopoverContent,

View File

@@ -15,7 +15,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { MonthPicker } from "@/components/ui/monthpicker";
import { MonthPicker } from "@/components/ui/month-picker";
import {
Popover,
PopoverContent,
@@ -36,7 +36,6 @@ import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import { getTodayDateString } from "@/lib/utils/date";
import { displayPeriod } from "@/lib/utils/period";
import type { SelectOption } from "../../types";
import {
CategoriaSelectContent,
ContaCartaoSelectContent,
@@ -45,6 +44,7 @@ import {
TransactionTypeSelectContent,
} from "../select-items";
import { EstabelecimentoInput } from "../shared/estabelecimento-input";
import type { SelectOption } from "../types";
/** Payment methods sem Boleto para este modal */
const MASS_ADD_PAYMENT_METHODS = LANCAMENTO_PAYMENT_METHODS.filter(

View File

@@ -19,6 +19,10 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatCurrency } from "@/lib/lancamentos/formatting-helpers";
import {
getPrimaryPdfColor,
loadExportLogoDataUrl,
} from "@/lib/utils/export-branding";
import type { LancamentoItem } from "./types";
interface LancamentosExportProps {
@@ -51,6 +55,17 @@ export function LancamentosExport({
return "-";
};
const getNameWithInstallment = (lancamento: LancamentoItem) => {
const isInstallment =
lancamento.condition.trim().toLowerCase() === "parcelado";
if (!isInstallment || !lancamento.installmentCount) {
return lancamento.name;
}
return `${lancamento.name} (${lancamento.currentInstallment ?? 1}/${lancamento.installmentCount})`;
};
const exportToCSV = () => {
try {
setIsExporting(true);
@@ -71,7 +86,7 @@ export function LancamentosExport({
lancamentos.forEach((lancamento) => {
const row = [
formatDate(lancamento.purchaseDate),
lancamento.name,
getNameWithInstallment(lancamento),
lancamento.transactionType,
lancamento.condition,
lancamento.paymentMethod,
@@ -129,7 +144,7 @@ export function LancamentosExport({
lancamentos.forEach((lancamento) => {
const row = [
formatDate(lancamento.purchaseDate),
lancamento.name,
getNameWithInstallment(lancamento),
lancamento.transactionType,
lancamento.condition,
lancamento.paymentMethod,
@@ -145,7 +160,7 @@ export function LancamentosExport({
ws["!cols"] = [
{ wch: 12 }, // Data
{ wch: 30 }, // Nome
{ wch: 42 }, // Nome
{ wch: 15 }, // Tipo
{ wch: 15 }, // Condição
{ wch: 20 }, // Pagamento
@@ -168,14 +183,33 @@ export function LancamentosExport({
}
};
const exportToPDF = () => {
const exportToPDF = async () => {
try {
setIsExporting(true);
const doc = new jsPDF({ orientation: "landscape" });
const primaryColor = getPrimaryPdfColor();
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
loadExportLogoDataUrl("/logo_small.png"),
loadExportLogoDataUrl("/logo_text.png"),
]);
let brandingEndX = 14;
if (smallLogoDataUrl) {
doc.addImage(smallLogoDataUrl, "PNG", brandingEndX, 7.5, 8, 8);
brandingEndX += 10;
}
if (textLogoDataUrl) {
doc.addImage(textLogoDataUrl, "PNG", brandingEndX, 8, 30, 8);
brandingEndX += 32;
}
const titleX = brandingEndX > 14 ? brandingEndX + 4 : 14;
doc.setFont("courier", "normal");
doc.setFontSize(16);
doc.text("Lançamentos", 14, 15);
doc.text("Lançamentos", titleX, 15);
doc.setFontSize(10);
const periodParts = period.split("-");
@@ -197,8 +231,15 @@ export function LancamentosExport({
periodParts.length === 2
? `${monthNames[Number.parseInt(periodParts[1], 10) - 1]}/${periodParts[0]}`
: period;
doc.text(`Período: ${formattedPeriod}`, 14, 22);
doc.text(`Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, 14, 27);
doc.text(`Período: ${formattedPeriod}`, titleX, 22);
doc.text(
`Gerado em: ${new Date().toLocaleDateString("pt-BR")}`,
titleX,
27,
);
doc.setDrawColor(...primaryColor);
doc.setLineWidth(0.5);
doc.line(14, 31, doc.internal.pageSize.getWidth() - 14, 31);
const headers = [
[
@@ -216,7 +257,7 @@ export function LancamentosExport({
const body = lancamentos.map((lancamento) => [
formatDate(lancamento.purchaseDate),
lancamento.name,
getNameWithInstallment(lancamento),
lancamento.transactionType,
lancamento.condition,
lancamento.paymentMethod,
@@ -229,26 +270,28 @@ export function LancamentosExport({
autoTable(doc, {
head: headers,
body: body,
startY: 32,
startY: 35,
tableWidth: "auto",
styles: {
font: "courier",
fontSize: 8,
cellPadding: 2,
},
headStyles: {
fillColor: [59, 130, 246],
fillColor: primaryColor,
textColor: 255,
fontStyle: "bold",
},
columnStyles: {
0: { cellWidth: 20 }, // Data
1: { cellWidth: 40 }, // Nome
2: { cellWidth: 25 }, // Tipo
3: { cellWidth: 25 }, // Condição
4: { cellWidth: 30 }, // Pagamento
5: { cellWidth: 25 }, // Valor
0: { cellWidth: 24 }, // Data
1: { cellWidth: 58 }, // Nome
2: { cellWidth: 22 }, // Tipo
3: { cellWidth: 22 }, // Condição
4: { cellWidth: 28 }, // Pagamento
5: { cellWidth: 24 }, // Valor
6: { cellWidth: 30 }, // Categoria
7: { cellWidth: 30 }, // Conta/Cartão
8: { cellWidth: 30 }, // Pagador
8: { cellWidth: 31 }, // Pagador
},
didParseCell: (cellData) => {
if (cellData.section === "body" && cellData.column.index === 5) {
@@ -262,7 +305,7 @@ export function LancamentosExport({
}
}
},
margin: { top: 32 },
margin: { top: 35 },
});
doc.save(getFileName("pdf"));

View File

@@ -1,9 +1,9 @@
import { RiArrowDownFill, RiCheckLine } from "@remixicon/react";
import {
calculateLastInstallmentDate,
formatCurrentInstallment,
formatLastInstallmentDate,
formatPurchaseDate,
formatLastInstallmentDate,
formatCurrentInstallment,
} from "@/lib/installments/utils";
type InstallmentTimelineProps = {

View File

@@ -277,7 +277,7 @@ export function LancamentosFilters({
<div className="flex w-full gap-2 md:w-auto">
{exportButton && (
<div className="flex-1 md:flex-none [&>*]:w-full [&>*]:md:w-auto">
<div className="flex-1 md:flex-none *:w-full *:md:w-auto">
{exportButton}
</div>
)}
@@ -291,13 +291,13 @@ export function LancamentosFilters({
<DrawerTrigger asChild>
<Button
variant="outline"
className="flex-1 md:flex-none text-sm border-dashed relative"
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
aria-label="Abrir filtros"
>
<RiFilter3Line className="size-4" />
Filtros
{hasActiveFilters && (
<span className="absolute -top-1 -right-1 size-2 rounded-full bg-primary" />
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
)}
</Button>
</DrawerTrigger>

View File

@@ -6,8 +6,8 @@ import {
RiChat1Line,
RiCheckLine,
RiDeleteBin5Line,
RiEyeLine,
RiFileCopyLine,
RiFileList2Line,
RiGroupLine,
RiHistoryLine,
RiMoreFill,
@@ -31,8 +31,8 @@ import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
import { CategoryIcon } from "@/components/categorias/category-icon";
import { EmptyState } from "@/components/empty-state";
import MoneyValues from "@/components/money-values";
import { EmptyState } from "@/components/shared/empty-state";
import { TypeBadge } from "@/components/type-badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@@ -588,7 +588,7 @@ const buildColumns = ({
<DropdownMenuItem
onSelect={() => handleViewDetails(row.original)}
>
<RiEyeLine className="size-4" />
<RiFileList2Line className="size-4" />
Detalhes
</DropdownMenuItem>
{row.original.userId === currentUserId && (

View File

@@ -25,7 +25,7 @@ interface UseLogoSelectionProps {
* mode: 'create',
* currentLogo: formState.logo,
* currentName: formState.name,
* onUpdate: (updates) => setFormState(prev => ({ ...prev, ...updates }))
* onUpdate: (updates) => updateFields(updates)
* });
* ```
*/

View File

@@ -3,61 +3,37 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useTransition } from "react";
import { Card } from "@/components/ui/card";
import { useMonthPeriod } from "@/hooks/use-month-period";
import { getNextPeriod, getPreviousPeriod } from "@/lib/utils/period";
import LoadingSpinner from "./loading-spinner";
import NavigationButton from "./nav-button";
import ReturnButton from "./return-button";
import { useMonthPeriod } from "./use-month-period";
export default function MonthNavigation() {
const {
monthNames,
currentMonth,
currentYear,
defaultMonth,
defaultYear,
buildHref,
} = useMonthPeriod();
const { period, currentMonth, currentYear, defaultPeriod, buildHref } =
useMonthPeriod();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const currentMonthLabel = useMemo(
() => currentMonth.charAt(0).toUpperCase() + currentMonth.slice(1),
[currentMonth],
() =>
`${currentMonth.charAt(0).toUpperCase()}${currentMonth.slice(1)} ${currentYear}`,
[currentMonth, currentYear],
);
const currentMonthIndex = useMemo(
() => monthNames.indexOf(currentMonth),
[monthNames, currentMonth],
const prevTarget = useMemo(
() => buildHref(getPreviousPeriod(period)),
[buildHref, period],
);
const nextTarget = useMemo(
() => buildHref(getNextPeriod(period)),
[buildHref, period],
);
const prevTarget = useMemo(() => {
let idx = currentMonthIndex - 1;
let year = currentYear;
if (idx < 0) {
idx = monthNames.length - 1;
year = (parseInt(currentYear, 10) - 1).toString();
}
return buildHref(monthNames[idx], year);
}, [currentMonthIndex, currentYear, monthNames, buildHref]);
const nextTarget = useMemo(() => {
let idx = currentMonthIndex + 1;
let year = currentYear;
if (idx >= monthNames.length) {
idx = 0;
year = (parseInt(currentYear, 10) + 1).toString();
}
return buildHref(monthNames[idx], year);
}, [currentMonthIndex, currentYear, monthNames, buildHref]);
const returnTarget = useMemo(
() => buildHref(defaultMonth, defaultYear),
[buildHref, defaultMonth, defaultYear],
() => buildHref(defaultPeriod),
[buildHref, defaultPeriod],
);
const isDifferentFromCurrent =
currentMonth !== defaultMonth || currentYear !== defaultYear.toString();
const isDifferentFromCurrent = period !== defaultPeriod;
// Prefetch otimizado: apenas meses adjacentes (M-1, M+1) e mês atual
// Isso melhora a performance da navegação sem sobrecarregar o cliente
@@ -91,10 +67,9 @@ export default function MonthNavigation() {
<div
className="mx-1 space-x-1 capitalize font-semibold"
aria-current={!isDifferentFromCurrent ? "date" : undefined}
aria-label={`Período selecionado: ${currentMonthLabel} de ${currentYear}`}
aria-label={`Período selecionado: ${currentMonthLabel}`}
>
<span>{currentMonthLabel}</span>
<span>{currentYear}</span>
</div>
{isPending && <LoadingSpinner />}

View File

@@ -0,0 +1,90 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
import {
formatPeriod,
formatPeriodForUrl,
formatPeriodParam,
MONTH_NAMES,
parsePeriodParam,
} from "@/lib/utils/period";
const PERIOD_PARAM = "periodo";
export function useMonthPeriod() {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const periodFromParams = searchParams.get(PERIOD_PARAM);
const referenceDate = useMemo(() => new Date(), []);
const defaultPeriod = useMemo(
() =>
formatPeriod(referenceDate.getFullYear(), referenceDate.getMonth() + 1),
[referenceDate],
);
const { period, monthName, year } = useMemo(
() => parsePeriodParam(periodFromParams, referenceDate),
[periodFromParams, referenceDate],
);
const defaultMonth = useMemo(
() => MONTH_NAMES[referenceDate.getMonth()] ?? "",
[referenceDate],
);
const defaultYear = useMemo(
() => referenceDate.getFullYear().toString(),
[referenceDate],
);
const buildHref = useCallback(
(targetPeriod: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(PERIOD_PARAM, formatPeriodForUrl(targetPeriod));
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
const buildHrefFromMonth = useCallback(
(month: string, nextYear: string | number) => {
const parsedYear = Number.parseInt(String(nextYear).trim(), 10);
if (Number.isNaN(parsedYear)) {
return buildHref(defaultPeriod);
}
const param = formatPeriodParam(month, parsedYear);
const parsed = parsePeriodParam(param, referenceDate);
return buildHref(parsed.period);
},
[buildHref, defaultPeriod, referenceDate],
);
const replacePeriod = useCallback(
(targetPeriod: string) => {
if (!targetPeriod) {
return;
}
router.replace(buildHref(targetPeriod), { scroll: false });
},
[buildHref, router],
);
return {
pathname,
period,
currentMonth: monthName,
currentYear: year.toString(),
defaultPeriod,
defaultMonth,
defaultYear,
buildHref,
buildHrefFromMonth,
replacePeriod,
};
}
export { PERIOD_PARAM as MONTH_PERIOD_PARAM };

View File

@@ -1,103 +0,0 @@
"use client";
import { RiCalculatorLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
import { useState } from "react";
import { CalculatorDialogContent } from "@/components/calculadora/calculator-dialog";
import { usePrivacyMode } from "@/components/privacy-provider";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { cn } from "@/lib/utils/ui";
const itemClass =
"flex w-full items-center gap-2.5 rounded-sm px-2 py-2 text-sm text-foreground hover:bg-accent transition-colors cursor-pointer";
export function NavToolsDropdown() {
const { privacyMode, toggle } = usePrivacyMode();
const [calcOpen, setCalcOpen] = useState(false);
return (
<Dialog open={calcOpen} onOpenChange={setCalcOpen}>
<ul className="grid w-52 gap-0.5 p-2">
<li>
<DialogTrigger asChild>
<button type="button" className={cn(itemClass)}>
<span className="text-muted-foreground shrink-0">
<RiCalculatorLine className="size-4" />
</span>
<span className="flex-1 text-left">calculadora</span>
</button>
</DialogTrigger>
</li>
<li>
<button type="button" onClick={toggle} className={cn(itemClass)}>
<span className="text-muted-foreground shrink-0">
{privacyMode ? (
<RiEyeOffLine className="size-4" />
) : (
<RiEyeLine className="size-4" />
)}
</span>
<span className="flex-1 text-left">privacidade</span>
{privacyMode && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-4"
>
Ativo
</Badge>
)}
</button>
</li>
</ul>
<CalculatorDialogContent open={calcOpen} />
</Dialog>
);
}
type MobileToolsProps = {
onClose: () => void;
};
export function MobileTools({ onClose }: MobileToolsProps) {
const { privacyMode, toggle } = usePrivacyMode();
const [calcOpen, setCalcOpen] = useState(false);
return (
<Dialog open={calcOpen} onOpenChange={setCalcOpen}>
<DialogTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
>
<span className="text-muted-foreground shrink-0">
<RiCalculatorLine className="size-4" />
</span>
<span className="flex-1 text-left">calculadora</span>
</button>
</DialogTrigger>
<button
type="button"
onClick={() => {
toggle();
onClose();
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
>
<span className="text-muted-foreground shrink-0">
{privacyMode ? (
<RiEyeOffLine className="size-4" />
) : (
<RiEyeLine className="size-4" />
)}
</span>
<span className="flex-1 text-left">privacidade</span>
{privacyMode && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
Ativo
</Badge>
)}
</button>
<CalculatorDialogContent open={calcOpen} />
</Dialog>
);
}

View File

@@ -1,9 +1,9 @@
import Link from "next/link";
import { AnimatedThemeToggler } from "@/components/animated-theme-toggler";
import { Logo } from "@/components/logo";
import { NotificationBell } from "@/components/notificacoes/notification-bell";
import { RefreshPageButton } from "@/components/refresh-page-button";
import { RefreshPageButton } from "@/components/shared/refresh-page-button";
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
import { Logo } from "../logo";
import { NavMenu } from "./nav-menu";
import { NavbarUser } from "./navbar-user";

View File

@@ -8,6 +8,7 @@ import {
RiFileChartLine,
RiGroupLine,
RiPriceTag3Line,
RiSecurePaymentLine,
RiSparklingLine,
RiStore2Line,
RiTodoLine,
@@ -111,6 +112,11 @@ export const NAV_SECTIONS: NavSection[] = [
icon: <RiBankCard2Line className="size-4" />,
preservePeriod: true,
},
{
href: "/relatorios/analise-parcelas",
label: "análise de parcelas",
icon: <RiSecurePaymentLine className="size-4" />,
},
{
href: "/relatorios/estabelecimentos",
label: "estabelecimentos",

View File

@@ -2,7 +2,9 @@
import { RiDashboardLine, RiMenuLine } from "@remixicon/react";
import { useState } from "react";
import { CalculatorDialogContent } from "@/components/calculadora/calculator-dialog";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import {
NavigationMenu,
NavigationMenuContent,
@@ -26,7 +28,9 @@ import { MobileTools, NavToolsDropdown } from "./nav-tools";
export function NavMenu() {
const [sheetOpen, setSheetOpen] = useState(false);
const [calculatorOpen, setCalculatorOpen] = useState(false);
const close = () => setSheetOpen(false);
const openCalculator = () => setCalculatorOpen(true);
return (
<>
@@ -56,7 +60,7 @@ export function NavMenu() {
Ferramentas
</NavigationMenuTrigger>
<NavigationMenuContent>
<NavToolsDropdown />
<NavToolsDropdown onOpenCalculator={openCalculator} />
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
@@ -114,10 +118,14 @@ export function NavMenu() {
})}
<MobileSectionLabel label="Ferramentas" />
<MobileTools onClose={close} />
<MobileTools onClose={close} onOpenCalculator={openCalculator} />
</nav>
</SheetContent>
</Sheet>
<Dialog open={calculatorOpen} onOpenChange={setCalculatorOpen}>
<CalculatorDialogContent open={calculatorOpen} />
</Dialog>
</>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { RiCalculatorLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
import { usePrivacyMode } from "@/components/privacy-provider";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils/ui";
const itemClass =
"flex w-full items-center gap-2.5 rounded-sm px-2 py-2 text-sm text-foreground hover:bg-accent transition-colors cursor-pointer";
type NavToolsDropdownProps = {
onOpenCalculator: () => void;
};
export function NavToolsDropdown({ onOpenCalculator }: NavToolsDropdownProps) {
const { privacyMode, toggle } = usePrivacyMode();
return (
<ul className="grid w-52 gap-0.5 p-2">
<li>
<button
type="button"
className={cn(itemClass)}
onClick={onOpenCalculator}
>
<span className="text-muted-foreground shrink-0">
<RiCalculatorLine className="size-4" />
</span>
<span className="flex-1 text-left">calculadora</span>
</button>
</li>
<li>
<button type="button" onClick={toggle} className={cn(itemClass)}>
<span className="text-muted-foreground shrink-0">
{privacyMode ? (
<RiEyeOffLine className="size-4" />
) : (
<RiEyeLine className="size-4" />
)}
</span>
<span className="flex-1 text-left">privacidade</span>
{privacyMode && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-4 text-success"
>
Ativo
</Badge>
)}
</button>
</li>
</ul>
);
}
type MobileToolsProps = {
onClose: () => void;
onOpenCalculator: () => void;
};
export function MobileTools({ onClose, onOpenCalculator }: MobileToolsProps) {
const { privacyMode, toggle } = usePrivacyMode();
return (
<>
<button
type="button"
onClick={() => {
onClose();
onOpenCalculator();
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
>
<span className="text-muted-foreground shrink-0">
<RiCalculatorLine className="size-4" />
</span>
<span className="flex-1 text-left">calculadora</span>
</button>
<button
type="button"
onClick={() => {
toggle();
onClose();
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
>
<span className="text-muted-foreground shrink-0">
{privacyMode ? (
<RiEyeOffLine className="size-4" />
) : (
<RiEyeLine className="size-4" />
)}
</span>
<span className="flex-1 text-left">privacidade</span>
{privacyMode && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-4 text-success"
>
Ativo
</Badge>
)}
</button>
</>
);
}

View File

@@ -11,6 +11,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { FeedbackDialogBody } from "@/components/feedback/feedback-dialog";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import {
DropdownMenu,
@@ -24,7 +25,6 @@ import { authClient } from "@/lib/auth/client";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { cn } from "@/lib/utils/ui";
import { version } from "@/package.json";
import { Badge } from "../ui/badge";
const itemClass =
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-accent";

View File

@@ -1,9 +1,9 @@
"use client";
import * as React from "react";
import { Logo } from "@/components/logo";
import { NavMain } from "@/components/sidebar/nav-main";
import { NavSecondary } from "@/components/sidebar/nav-secondary";
import { NavUser } from "@/components/sidebar/nav-user";
import { NavMain } from "@/components/navigation/sidebar/nav-main";
import { NavSecondary } from "@/components/navigation/sidebar/nav-secondary";
import { NavUser } from "@/components/navigation/sidebar/nav-user";
import {
Sidebar,
SidebarContent,

View File

@@ -10,6 +10,7 @@ import {
RiFundsLine,
RiGroupLine,
RiPriceTag3Line,
RiSecurePaymentLine,
RiSettings2Line,
RiSparklingLine,
RiTodoLine,
@@ -165,6 +166,11 @@ export function createSidebarNavData(
url: "/relatorios/uso-cartoes",
icon: RiBankCard2Line,
},
{
title: "Análise de Parcelas",
url: "/relatorios/analise-parcelas",
icon: RiSecurePaymentLine,
},
],
},
],

View File

@@ -85,16 +85,16 @@ export function BudgetDialog({
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
const { formState, resetForm, updateField } =
useFormState<BudgetFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, setFormState, initialState]);
}, [dialogOpen, initialState, resetForm]);
// Clear error when dialog closes
useEffect(() => {
@@ -153,7 +153,7 @@ export function BudgetDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}

View File

@@ -8,7 +8,7 @@ import {
duplicatePreviousMonthBudgetsAction,
} from "@/app/(dashboard)/orcamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "../ui/card";
import { BudgetCard } from "./budget-card";

View File

@@ -2,7 +2,7 @@
import {
RiDeleteBin5Line,
RiEyeLine,
RiFileList2Line,
RiMailSendLine,
RiPencilLine,
RiVerifiedBadgeFill,
@@ -101,7 +101,7 @@ export function PagadorCard({ pagador, onEdit, onRemove }: PagadorCardProps) {
href={`/pagadores/${pagador.id}`}
className={`text-primary flex items-center gap-1 font-medium transition-opacity hover:opacity-80`}
>
<RiEyeLine className="size-4" aria-hidden />
<RiFileList2Line className="size-4" aria-hidden />
detalhes
</Link>

View File

@@ -95,7 +95,7 @@ export function PagadorDialog({
);
// Use form state hook for form management
const { formState, updateField, setFormState } =
const { formState, resetForm, updateField } =
useFormState<PagadorFormValues>(initialState);
const availableAvatars = useMemo(() => {
@@ -111,10 +111,10 @@ export function PagadorDialog({
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, initialState, resetForm]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
@@ -160,7 +160,7 @@ export function PagadorDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}
@@ -168,7 +168,7 @@ export function PagadorDialog({
toast.error(result.error);
});
},
[formState, initialState, mode, pagador?.id, setDialogOpen, setFormState],
[formState, initialState, mode, pagador?.id, resetForm, setDialogOpen],
);
const title = mode === "create" ? "Novo pagador" : "Editar pagador";

View File

@@ -5,7 +5,7 @@ import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { MonthPicker } from "@/components/ui/monthpicker";
import { MonthPicker } from "@/components/ui/month-picker";
import {
Popover,
PopoverContent,

View File

@@ -1,9 +1,10 @@
"use client";
import {
RiArrowGoBackLine,
RiCheckLine,
RiDeleteBinLine,
RiEyeLine,
RiFileList2Line,
RiMoreLine,
} from "@remixicon/react";
import { format, formatDistanceToNow } from "date-fns";
@@ -37,6 +38,7 @@ interface InboxCardProps {
onDiscard?: (item: InboxItem) => void;
onViewDetails?: (item: InboxItem) => void;
onDelete?: (item: InboxItem) => void;
onRestoreToPending?: (item: InboxItem) => void | Promise<void>;
}
function resolveLogoPath(logo: string): string {
@@ -79,6 +81,7 @@ export function InboxCard({
onDiscard,
onViewDetails,
onDelete,
onRestoreToPending,
}: InboxCardProps) {
const matchedLogo = useMemo(
() =>
@@ -161,7 +164,7 @@ export function InboxCard({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onViewDetails?.(item)}>
<RiEyeLine className="mr-2 size-4" />
<RiFileList2Line className="mr-2 size-4" />
Ver detalhes
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onProcess?.(item)}>
@@ -204,16 +207,30 @@ export function InboxCard({
{formattedStatusDate}
</span>
)}
<div className="ml-auto flex items-center gap-1">
{item.status === "discarded" && onRestoreToPending && (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onRestoreToPending(item)}
aria-label="Voltar para pendente"
title="Voltar para pendente"
>
<RiArrowGoBackLine className="size-4" />
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="icon-sm"
className="ml-auto text-muted-foreground hover:text-destructive"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDelete(item)}
>
<RiDeleteBinLine className="size-4" />
</Button>
)}
</div>
</CardFooter>
) : (
<CardFooter className="gap-2 pt-3 pb-4">
@@ -226,10 +243,12 @@ export function InboxCard({
Processar
</Button>
<Button
size="sm"
variant="outline"
size="icon-sm"
variant="ghost"
onClick={() => onDiscard?.(item)}
className="text-muted-foreground hover:text-destructive hover:border-destructive"
className="text-muted-foreground hover:text-destructive"
aria-label="Descartar notificação"
title="Descartar notificação"
>
<RiDeleteBinLine className="size-4" />
</Button>

Some files were not shown because too many files have changed in this diff Show More