diff --git a/CHANGELOG.md b/CHANGELOG.md index 2157d8e..c6604dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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/). +## [1.6.1] - 2026-02-18 + +### Adicionado + +- Aba "Estabelecimentos" no menu lateral (Gestão Financeira): listagem de estabelecimentos com quantidade de lançamentos, criação de novos, exclusão (apenas quando não há lançamentos vinculados) e link "Ver vinculados" para lançamentos filtrados pelo estabelecimento +- Tabela `estabelecimentos` (migration 0019) e sugestões de estabelecimento nos lançamentos passam a incluir nomes cadastrados nessa tabela +- Filtro "Estabelecimento" no drawer de Filtros da página de lançamentos; parâmetro `estabelecimento` na URL para filtrar por nome + +### Alterado + +- Transferências entre contas: nome do estabelecimento passa a ser "Saída - Transf. entre contas" na saída e "Entrada - Transf. entre contas" na entrada e adicionando em anotação no formato "de {conta origem} -> {conta destino}" +- Gráfico de pizza (Gastos por categoria): cores das fatias alinhadas às cores dos ícones das categorias na lista (paleta `getCategoryColor`) +- ChartContainer (Recharts): renderização do gráfico apenas após montagem no cliente e uso de `minWidth`/`minHeight` no ResponsiveContainer para evitar aviso "width(-1) and height(-1)" no console + ## [1.6.0] - 2026-02-18 ### Adicionado diff --git a/app/(dashboard)/contas/actions.ts b/app/(dashboard)/contas/actions.ts index 58dddb6..1c623be 100644 --- a/app/(dashboard)/contas/actions.ts +++ b/app/(dashboard)/contas/actions.ts @@ -22,7 +22,8 @@ import { noteSchema, uuidSchema } from "@/lib/schemas/common"; import { TRANSFER_CATEGORY_NAME, TRANSFER_CONDITION, - TRANSFER_ESTABLISHMENT, + TRANSFER_ESTABLISHMENT_ENTRADA, + TRANSFER_ESTABLISHMENT_SAIDA, TRANSFER_PAYMENT_METHOD, } from "@/lib/transferencias/constants"; import { formatDecimalForDbRequired } from "@/lib/utils/currency"; @@ -341,12 +342,14 @@ export async function transferBetweenAccountsAction( ); } + const transferNote = `de ${fromAccount.name} -> ${toAccount.name}`; + // Create outgoing transaction (transfer from source account) await tx.insert(lancamentos).values({ condition: TRANSFER_CONDITION, - name: `${TRANSFER_ESTABLISHMENT} → ${toAccount.name}`, + name: TRANSFER_ESTABLISHMENT_SAIDA, paymentMethod: TRANSFER_PAYMENT_METHOD, - note: `Transferência para ${toAccount.name}`, + note: transferNote, amount: formatDecimalForDbRequired(-Math.abs(data.amount)), purchaseDate: data.date, transactionType: "Transferência", @@ -362,9 +365,9 @@ export async function transferBetweenAccountsAction( // Create incoming transaction (transfer to destination account) await tx.insert(lancamentos).values({ condition: TRANSFER_CONDITION, - name: `${TRANSFER_ESTABLISHMENT} ← ${fromAccount.name}`, + name: TRANSFER_ESTABLISHMENT_ENTRADA, paymentMethod: TRANSFER_PAYMENT_METHOD, - note: `Transferência de ${fromAccount.name}`, + note: transferNote, amount: formatDecimalForDbRequired(Math.abs(data.amount)), purchaseDate: data.date, transactionType: "Transferência", diff --git a/app/(dashboard)/estabelecimentos/actions.ts b/app/(dashboard)/estabelecimentos/actions.ts new file mode 100644 index 0000000..4b1f38d --- /dev/null +++ b/app/(dashboard)/estabelecimentos/actions.ts @@ -0,0 +1,102 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { estabelecimentos, lancamentos } from "@/db/schema"; +import { + type ActionResult, + handleActionError, + revalidateForEntity, +} from "@/lib/actions/helpers"; +import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; +import { uuidSchema } from "@/lib/schemas/common"; + +const createSchema = z.object({ + name: z + .string({ message: "Informe o nome do estabelecimento." }) + .trim() + .min(1, "Informe o nome do estabelecimento."), +}); + +const deleteSchema = z.object({ + id: uuidSchema("Estabelecimento"), +}); + +export async function createEstabelecimentoAction( + input: z.infer, +): Promise { + try { + const user = await getUser(); + const data = createSchema.parse(input); + + await db.insert(estabelecimentos).values({ + name: data.name, + userId: user.id, + }); + + revalidateForEntity("estabelecimentos"); + + return { success: true, message: "Estabelecimento criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function deleteEstabelecimentoAction( + input: z.infer, +): Promise { + try { + const user = await getUser(); + const data = deleteSchema.parse(input); + + const row = await db.query.estabelecimentos.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(estabelecimentos.id, data.id), + eq(estabelecimentos.userId, user.id), + ), + }); + + if (!row) { + return { + success: false, + error: "Estabelecimento não encontrado.", + }; + } + + const [linked] = await db + .select({ id: lancamentos.id }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.name, row.name), + ), + ) + .limit(1); + + if (linked) { + return { + success: false, + error: + "Não é possível excluir: existem lançamentos vinculados a este estabelecimento. Remova ou altere os lançamentos primeiro.", + }; + } + + await db + .delete(estabelecimentos) + .where( + and( + eq(estabelecimentos.id, data.id), + eq(estabelecimentos.userId, user.id), + ), + ); + + revalidateForEntity("estabelecimentos"); + + return { success: true, message: "Estabelecimento excluído com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/app/(dashboard)/estabelecimentos/data.ts b/app/(dashboard)/estabelecimentos/data.ts new file mode 100644 index 0000000..fb2940f --- /dev/null +++ b/app/(dashboard)/estabelecimentos/data.ts @@ -0,0 +1,66 @@ +import { count, eq } from "drizzle-orm"; +import { estabelecimentos, lancamentos } from "@/db/schema"; +import { db } from "@/lib/db"; + +export type EstabelecimentoRow = { + name: string; + lancamentosCount: number; + estabelecimentoId: string | null; +}; + +export async function fetchEstabelecimentosForUser( + userId: string, +): Promise { + const [countsByName, estabelecimentosRows] = await Promise.all([ + db + .select({ + name: lancamentos.name, + count: count().as("count"), + }) + .from(lancamentos) + .where(eq(lancamentos.userId, userId)) + .groupBy(lancamentos.name), + db.query.estabelecimentos.findMany({ + columns: { id: true, name: true }, + where: eq(estabelecimentos.userId, userId), + }), + ]); + + const map = new Map< + string, + { lancamentosCount: number; estabelecimentoId: string | null } + >(); + + for (const row of countsByName) { + const name = row.name?.trim(); + if (name == null || name.length === 0) continue; + map.set(name, { + lancamentosCount: Number(row.count ?? 0), + estabelecimentoId: null, + }); + } + + for (const row of estabelecimentosRows) { + const name = row.name?.trim(); + if (name == null || name.length === 0) continue; + const existing = map.get(name); + if (existing) { + existing.estabelecimentoId = row.id; + } else { + map.set(name, { + lancamentosCount: 0, + estabelecimentoId: row.id, + }); + } + } + + return Array.from(map.entries()) + .map(([name, data]) => ({ + name, + lancamentosCount: data.lancamentosCount, + estabelecimentoId: data.estabelecimentoId, + })) + .sort((a, b) => + a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }), + ); +} diff --git a/app/(dashboard)/estabelecimentos/layout.tsx b/app/(dashboard)/estabelecimentos/layout.tsx new file mode 100644 index 0000000..8c5ec5a --- /dev/null +++ b/app/(dashboard)/estabelecimentos/layout.tsx @@ -0,0 +1,23 @@ +import { RiStore2Line } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; + +export const metadata = { + title: "Estabelecimentos | OpenMonetis", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Estabelecimentos" + subtitle="Gerencie os estabelecimentos dos seus lançamentos. Crie novos, exclua os que não têm lançamentos vinculados e abra o que está vinculado a cada um." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/estabelecimentos/loading.tsx b/app/(dashboard)/estabelecimentos/loading.tsx new file mode 100644 index 0000000..8f2159c --- /dev/null +++ b/app/(dashboard)/estabelecimentos/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ +
+
+
+ + + + +
+
+
+ ); +} diff --git a/app/(dashboard)/estabelecimentos/page.tsx b/app/(dashboard)/estabelecimentos/page.tsx new file mode 100644 index 0000000..71a7eba --- /dev/null +++ b/app/(dashboard)/estabelecimentos/page.tsx @@ -0,0 +1,14 @@ +import { EstabelecimentosPage } from "@/components/estabelecimentos/estabelecimentos-page"; +import { getUserId } from "@/lib/auth/server"; +import { fetchEstabelecimentosForUser } from "./data"; + +export default async function Page() { + const userId = await getUserId(); + const rows = await fetchEstabelecimentosForUser(userId); + + return ( +
+ +
+ ); +} diff --git a/app/(dashboard)/lancamentos/actions.ts b/app/(dashboard)/lancamentos/actions.ts index b6364a8..a120b62 100644 --- a/app/(dashboard)/lancamentos/actions.ts +++ b/app/(dashboard)/lancamentos/actions.ts @@ -7,6 +7,7 @@ import { cartoes, categorias, contas, + estabelecimentos, lancamentos, pagadores, } from "@/db/schema"; @@ -1639,43 +1640,64 @@ export async function deleteMultipleLancamentosAction( } } -// Get unique establishment names from the last 3 months +// Get unique establishment names: from estabelecimentos table + last 3 months from lancamentos export async function getRecentEstablishmentsAction(): Promise { try { const user = await getUser(); - // Calculate date 3 months ago const threeMonthsAgo = new Date(); threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); - // Fetch establishment names from the last 3 months - const results = await db - .select({ name: lancamentos.name }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, user.id), - gte(lancamentos.purchaseDate, threeMonthsAgo), - ), - ) - .orderBy(desc(lancamentos.purchaseDate)); - - // Remove duplicates and filter empty names - const uniqueNames = Array.from( - new Set( - results - .map((r) => r.name) - .filter( - (name): name is string => - name != null && - name.trim().length > 0 && - !name.toLowerCase().startsWith("pagamento fatura"), + const [estabelecimentosRows, lancamentosResults] = await Promise.all([ + db.query.estabelecimentos.findMany({ + columns: { name: true }, + where: eq(estabelecimentos.userId, user.id), + }), + db + .select({ name: lancamentos.name }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + gte(lancamentos.purchaseDate, threeMonthsAgo), ), - ), - ); + ) + .orderBy(desc(lancamentos.purchaseDate)), + ]); - // Return top 50 most recent unique establishments - return uniqueNames.slice(0, 100); + const fromTable = estabelecimentosRows + .map((r) => r.name) + .filter( + (name): name is string => + name != null && name.trim().length > 0, + ); + const fromLancamentos = lancamentosResults + .map((r) => r.name) + .filter( + (name): name is string => + name != null && + name.trim().length > 0 && + !name.toLowerCase().startsWith("pagamento fatura"), + ); + + const seen = new Set(); + const unique: string[] = []; + for (const name of fromTable) { + const key = name.trim(); + if (!seen.has(key)) { + seen.add(key); + unique.push(key); + } + } + for (const name of fromLancamentos) { + const key = name.trim(); + if (!seen.has(key)) { + seen.add(key); + unique.push(key); + } + } + + return unique.slice(0, 100); } catch (error) { console.error("Error fetching recent establishments:", error); return []; diff --git a/components/dashboard/expenses-by-category-widget-with-chart.tsx b/components/dashboard/expenses-by-category-widget-with-chart.tsx index 0dd2de3..904244e 100644 --- a/components/dashboard/expenses-by-category-widget-with-chart.tsx +++ b/components/dashboard/expenses-by-category-widget-with-chart.tsx @@ -16,6 +16,7 @@ import { useIsMobile } from "@/hooks/use-mobile"; import MoneyValues from "@/components/money-values"; import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category"; +import { getCategoryColor } from "@/lib/utils/category-colors"; import { formatPeriodForUrl } from "@/lib/utils/period"; import { WidgetEmptyState } from "../widget-empty-state"; @@ -51,24 +52,15 @@ export function ExpensesByCategoryWidgetWithChart({ const isMobile = useIsMobile(); const periodParam = formatPeriodForUrl(period); - // Configuração do chart com cores do CSS + // Configuração do chart com as mesmas cores dos ícones das categorias (getCategoryColor) const chartConfig = useMemo(() => { const config: ChartConfig = {}; - const colors = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", - "var(--chart-1)", - "var(--chart-2)", - ]; if (data.categories.length <= 7) { data.categories.forEach((category, index) => { config[category.categoryId] = { label: category.categoryName, - color: colors[index % colors.length], + color: getCategoryColor(index), }; }); } else { @@ -77,12 +69,12 @@ export function ExpensesByCategoryWidgetWithChart({ top7.forEach((category, index) => { config[category.categoryId] = { label: category.categoryName, - color: colors[index % colors.length], + color: getCategoryColor(index), }; }); config.outros = { label: "Outros", - color: "var(--chart-6)", + color: getCategoryColor(7), }; } diff --git a/components/estabelecimentos/estabelecimento-create-dialog.tsx b/components/estabelecimentos/estabelecimento-create-dialog.tsx new file mode 100644 index 0000000..be7056e --- /dev/null +++ b/components/estabelecimentos/estabelecimento-create-dialog.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { createEstabelecimentoAction } from "@/app/(dashboard)/estabelecimentos/actions"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RiAddCircleLine } from "@remixicon/react"; + +interface EstabelecimentoCreateDialogProps { + trigger?: React.ReactNode; +} + +export function EstabelecimentoCreateDialog({ + trigger, +}: EstabelecimentoCreateDialogProps) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [isPending, startTransition] = useTransition(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + + startTransition(async () => { + const result = await createEstabelecimentoAction({ name: trimmed }); + if (result.success) { + toast.success(result.message); + setName(""); + setOpen(false); + } else { + toast.error(result.error); + } + }); + }; + + return ( + + + {trigger ?? ( + + )} + + +
+ + Novo estabelecimento + + Adicione um nome para usar nos lançamentos. Ele aparecerá na lista + e nas sugestões ao criar ou editar lançamentos. + + +
+
+ + setName(e.target.value)} + placeholder="Ex: Supermercado, Posto, Farmácia" + disabled={isPending} + autoFocus + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/components/estabelecimentos/estabelecimentos-page.tsx b/components/estabelecimentos/estabelecimentos-page.tsx new file mode 100644 index 0000000..a208bd5 --- /dev/null +++ b/components/estabelecimentos/estabelecimentos-page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { RiDeleteBin5Line, RiExternalLinkLine } from "@remixicon/react"; +import Link from "next/link"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { deleteEstabelecimentoAction } from "@/app/(dashboard)/estabelecimentos/actions"; +import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; +import { EstabelecimentoCreateDialog } from "./estabelecimento-create-dialog"; +import type { EstabelecimentoRow } from "@/app/(dashboard)/estabelecimentos/data"; + +interface EstabelecimentosPageProps { + rows: EstabelecimentoRow[]; +} + +function buildLancamentosUrl(name: string): string { + const params = new URLSearchParams(); + params.set("estabelecimento", name); + return `/lancamentos?${params.toString()}`; +} + +export function EstabelecimentosPage({ rows }: EstabelecimentosPageProps) { + const [deleteOpen, setDeleteOpen] = useState(false); + const [rowToDelete, setRowToDelete] = useState( + null, + ); + + const handleDeleteRequest = useCallback((row: EstabelecimentoRow) => { + setRowToDelete(row); + setDeleteOpen(true); + }, []); + + const handleDeleteOpenChange = useCallback((open: boolean) => { + setDeleteOpen(open); + if (!open) setRowToDelete(null); + }, []); + + const handleDeleteConfirm = useCallback(async () => { + if (!rowToDelete?.estabelecimentoId) return; + + const result = await deleteEstabelecimentoAction({ + id: rowToDelete.estabelecimentoId, + }); + + if (result.success) { + toast.success(result.message); + setDeleteOpen(false); + setRowToDelete(null); + return; + } + + toast.error(result.error); + throw new Error(result.error); + }, [rowToDelete]); + + const canDelete = (row: EstabelecimentoRow) => + row.lancamentosCount === 0 && row.estabelecimentoId != null; + + return ( + <> +
+
+ +
+ + {rows.length === 0 ? ( +
+ Nenhum estabelecimento ainda. Crie um ou use a lista que será + preenchida conforme você adiciona lançamentos. +
+ ) : ( +
+ + + + Estabelecimento + Lançamentos + Ações + + + + {rows.map((row) => ( + + +
+ + {row.name} +
+
+ + {row.lancamentosCount} + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ + + + ); +} diff --git a/components/lancamentos/page/lancamentos-page.tsx b/components/lancamentos/page/lancamentos-page.tsx index 7fe938f..ac6b739 100644 --- a/components/lancamentos/page/lancamentos-page.tsx +++ b/components/lancamentos/page/lancamentos-page.tsx @@ -386,6 +386,7 @@ export function LancamentosPage({ pagadorFilterOptions={pagadorFilterOptions} categoriaFilterOptions={categoriaFilterOptions} contaCartaoFilterOptions={contaCartaoFilterOptions} + estabelecimentosOptions={estabelecimentos} selectedPeriod={selectedPeriod} onCreate={allowCreate ? handleCreate : undefined} onMassAdd={allowCreate ? handleMassAdd : undefined} diff --git a/components/lancamentos/shared/estabelecimento-input.tsx b/components/lancamentos/shared/estabelecimento-input.tsx index ab8a8bb..c538595 100644 --- a/components/lancamentos/shared/estabelecimento-input.tsx +++ b/components/lancamentos/shared/estabelecimento-input.tsx @@ -33,7 +33,7 @@ export function EstabelecimentoInput({ value, onChange, estabelecimentos = [], - placeholder = "Ex.: Padaria", + placeholder = "Ex.: Padaria, Transferência, Saldo inicial", required = false, maxLength = 20, }: EstabelecimentoInputProps) { diff --git a/components/lancamentos/table/lancamentos-filters.tsx b/components/lancamentos/table/lancamentos-filters.tsx index ec26803..a5e4ecd 100644 --- a/components/lancamentos/table/lancamentos-filters.tsx +++ b/components/lancamentos/table/lancamentos-filters.tsx @@ -122,6 +122,7 @@ interface LancamentosFiltersProps { pagadorOptions: LancamentoFilterOption[]; categoriaOptions: LancamentoFilterOption[]; contaCartaoOptions: ContaCartaoFilterOption[]; + estabelecimentosOptions?: string[]; className?: string; exportButton?: ReactNode; hideAdvancedFilters?: boolean; @@ -131,6 +132,7 @@ export function LancamentosFilters({ pagadorOptions, categoriaOptions, contaCartaoOptions, + estabelecimentosOptions = [], className, exportButton, hideAdvancedFilters = false, @@ -235,6 +237,16 @@ export function LancamentosFilters({ ? contaCartaoOptions.find((option) => option.slug === contaCartaoValue) : null; + const estabelecimentoParam = searchParams.get("estabelecimento"); + const estabelecimentoOptionsForSelect = [ + ...(estabelecimentoParam && + estabelecimentoParam.trim() && + !estabelecimentosOptions.includes(estabelecimentoParam.trim()) + ? [estabelecimentoParam.trim()] + : []), + ...estabelecimentosOptions, + ]; + const [categoriaOpen, setCategoriaOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); @@ -244,7 +256,8 @@ export function LancamentosFilters({ searchParams.get("pagamento") || searchParams.get("pagador") || searchParams.get("categoria") || - searchParams.get("contaCartao"); + searchParams.get("contaCartao") || + searchParams.get("estabelecimento"); const handleResetFilters = () => { handleReset(); @@ -518,6 +531,45 @@ export function LancamentosFilters({ + + {estabelecimentoOptionsForSelect.length > 0 || + estabelecimentoParam?.trim() ? ( +
+ + +
+ ) : null} diff --git a/components/lancamentos/table/lancamentos-table.tsx b/components/lancamentos/table/lancamentos-table.tsx index b57fc9b..068274d 100644 --- a/components/lancamentos/table/lancamentos-table.tsx +++ b/components/lancamentos/table/lancamentos-table.tsx @@ -715,6 +715,7 @@ type LancamentosTableProps = { pagadorFilterOptions?: LancamentoFilterOption[]; categoriaFilterOptions?: LancamentoFilterOption[]; contaCartaoFilterOptions?: ContaCartaoFilterOption[]; + estabelecimentosOptions?: string[]; selectedPeriod?: string; onCreate?: (type: "Despesa" | "Receita") => void; onMassAdd?: () => void; @@ -741,6 +742,7 @@ export function LancamentosTable({ pagadorFilterOptions = [], categoriaFilterOptions = [], contaCartaoFilterOptions = [], + estabelecimentosOptions = [], selectedPeriod, onCreate, onMassAdd, @@ -921,6 +923,7 @@ export function LancamentosTable({ pagadorOptions={pagadorFilterOptions} categoriaOptions={categoriaFilterOptions} contaCartaoOptions={contaCartaoFilterOptions} + estabelecimentosOptions={estabelecimentosOptions} className="w-full lg:flex-1 lg:justify-end" hideAdvancedFilters={hasOtherUserData} exportButton={ diff --git a/components/sidebar/nav-link.tsx b/components/sidebar/nav-link.tsx index d9bd771..790091c 100644 --- a/components/sidebar/nav-link.tsx +++ b/components/sidebar/nav-link.tsx @@ -13,6 +13,7 @@ import { RiPriceTag3Line, RiSettings2Line, RiSparklingLine, + RiStore2Line, RiTodoLine, } from "@remixicon/react"; @@ -125,6 +126,11 @@ export function createSidebarNavData( url: "/orcamentos", icon: RiFundsLine, }, + { + title: "Estabelecimentos", + url: "/estabelecimentos", + icon: RiStore2Line, + }, ], }, { diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx index 0eff464..206d35c 100644 --- a/components/ui/chart.tsx +++ b/components/ui/chart.tsx @@ -49,24 +49,36 @@ function ChartContainer({ }) { const uniqueId = React.useId(); const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); return (
-
- - {children} - +
+ {mounted ? ( + + {children} + + ) : null}
diff --git a/db/schema.ts b/db/schema.ts index fa9b6b9..e2dbec8 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -190,6 +190,30 @@ export const categorias = pgTable( }), ); +export const estabelecimentos = pgTable( + "estabelecimentos", + { + id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), + name: text("nome").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { + mode: "date", + withTimezone: true, + }) + .notNull() + .defaultNow(), + }, + (table) => ({ + userIdIdx: index("estabelecimentos_user_id_idx").on(table.userId), + userIdNameUnique: uniqueIndex("estabelecimentos_user_id_nome_key").on( + table.userId, + table.name, + ), + }), +); + export const pagadores = pgTable( "pagadores", { @@ -635,6 +659,7 @@ export const userRelations = relations(user, ({ many, one }) => ({ cartoes: many(cartoes), categorias: many(categorias), contas: many(contas), + estabelecimentos: many(estabelecimentos), faturas: many(faturas), lancamentos: many(lancamentos), orcamentos: many(orcamentos), @@ -676,6 +701,16 @@ export const categoriasRelations = relations(categorias, ({ one, many }) => ({ orcamentos: many(orcamentos), })); +export const estabelecimentosRelations = relations( + estabelecimentos, + ({ one }) => ({ + user: one(user, { + fields: [estabelecimentos.userId], + references: [user.id], + }), + }), +); + export const pagadoresRelations = relations(pagadores, ({ one, many }) => ({ user: one(user, { fields: [pagadores.userId], diff --git a/drizzle/0019_add_estabelecimentos.sql b/drizzle/0019_add_estabelecimentos.sql new file mode 100644 index 0000000..b7cb94d --- /dev/null +++ b/drizzle/0019_add_estabelecimentos.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS "estabelecimentos" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "nome" text NOT NULL, + "user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "estabelecimentos_user_id_idx" ON "estabelecimentos" ("user_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "estabelecimentos_user_id_nome_key" ON "estabelecimentos" ("user_id", "nome"); + +DO $$ BEGIN + ALTER TABLE "estabelecimentos" ADD CONSTRAINT "estabelecimentos_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/lib/actions/helpers.ts b/lib/actions/helpers.ts index 0271d80..c901a64 100644 --- a/lib/actions/helpers.ts +++ b/lib/actions/helpers.ts @@ -25,6 +25,7 @@ export const revalidateConfig = { cartoes: ["/cartoes", "/contas", "/lancamentos"], contas: ["/contas", "/lancamentos"], categorias: ["/categorias"], + estabelecimentos: ["/estabelecimentos", "/lancamentos"], orcamentos: ["/orcamentos"], pagadores: ["/pagadores"], anotacoes: ["/anotacoes", "/anotacoes/arquivadas"], diff --git a/lib/lancamentos/page-helpers.ts b/lib/lancamentos/page-helpers.ts index 9674985..11cc7e0 100644 --- a/lib/lancamentos/page-helpers.ts +++ b/lib/lancamentos/page-helpers.ts @@ -37,6 +37,7 @@ export type LancamentoSearchFilters = { categoriaFilter: string | null; contaCartaoFilter: string | null; searchFilter: string | null; + estabelecimentoFilter: string | null; }; type BaseSluggedOption = { @@ -122,6 +123,7 @@ export const extractLancamentoSearchFilters = ( categoriaFilter: getSingleParam(params, "categoria"), contaCartaoFilter: getSingleParam(params, "contaCartao"), searchFilter: getSingleParam(params, "q"), + estabelecimentoFilter: getSingleParam(params, "estabelecimento"), }); const normalizeLabel = (value: string | null | undefined) => @@ -368,6 +370,10 @@ export const buildLancamentoWhere = ({ } } + if (filters.estabelecimentoFilter?.trim()) { + where.push(eq(lancamentos.name, filters.estabelecimentoFilter.trim())); + } + const searchPattern = buildSearchPattern(filters.searchFilter); if (searchPattern) { where.push( diff --git a/lib/transferencias/constants.ts b/lib/transferencias/constants.ts index af25310..71fcf54 100644 --- a/lib/transferencias/constants.ts +++ b/lib/transferencias/constants.ts @@ -1,5 +1,7 @@ export const TRANSFER_CATEGORY_NAME = "Transferência interna"; export const TRANSFER_ESTABLISHMENT = "Transf. entre contas"; +export const TRANSFER_ESTABLISHMENT_SAIDA = "Saída - Transf. entre contas"; +export const TRANSFER_ESTABLISHMENT_ENTRADA = "Entrada - Transf. entre contas"; export const TRANSFER_PAGADOR = "Admin"; export const TRANSFER_PAYMENT_METHOD = "Pix"; export const TRANSFER_CONDITION = "À vista"; diff --git a/package.json b/package.json index 779802a..8702c97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmonetis", - "version": "1.6.0", + "version": "1.6.1", "private": true, "scripts": { "dev": "next dev --turbopack",