Merge pull request #24 from felipegcoutinho/feat/melhorias-gerais-do-app
Refatora hooks compartilhados e ajusta month picker
This commit is contained in:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
FilterSkeleton,
|
||||
InvoiceSummaryCardSkeleton,
|
||||
TransactionsTableSkeleton,
|
||||
} from "@/components/skeletons";
|
||||
} from "@/components/shared/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
AccountStatementCardSkeleton,
|
||||
FilterSkeleton,
|
||||
TransactionsTableSkeleton,
|
||||
} from "@/components/skeletons";
|
||||
} from "@/components/shared/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
FilterSkeleton,
|
||||
TransactionsTableSkeleton,
|
||||
} from "@/components/skeletons";
|
||||
} from "@/components/shared/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 só 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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
InstallmentAnalysisData,
|
||||
InstallmentGroup,
|
||||
PendingInvoice,
|
||||
} from "@/lib/dashboard/expenses/installment-analysis";
|
||||
|
||||
export type { InstallmentAnalysisData, InstallmentGroup, PendingInvoice };
|
||||
export type { InstallmentAnalysisData, InstallmentGroup };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RiArrowDownFill, RiCheckLine } from "@remixicon/react";
|
||||
import {
|
||||
calculateLastInstallmentDate,
|
||||
formatCurrentInstallment,
|
||||
formatLastInstallmentDate,
|
||||
formatPurchaseDate,
|
||||
formatLastInstallmentDate,
|
||||
formatCurrentInstallment,
|
||||
} from "@/lib/installments/utils";
|
||||
|
||||
type InstallmentTimelineProps = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -25,7 +25,7 @@ interface UseLogoSelectionProps {
|
||||
* mode: 'create',
|
||||
* currentLogo: formState.logo,
|
||||
* currentName: formState.name,
|
||||
* onUpdate: (updates) => setFormState(prev => ({ ...prev, ...updates }))
|
||||
* onUpdate: (updates) => updateFields(updates)
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@@ -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 />}
|
||||
|
||||
90
components/month-picker/use-month-period.ts
Normal file
90
components/month-picker/use-month-period.ts
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
components/navigation/navbar/nav-tools.tsx
Normal file
106
components/navigation/navbar/nav-tools.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user