11 Commits

Author SHA1 Message Date
Felipe Coutinho
791fec7751 chore(release): v2.4.3
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:47:05 +00:00
Felipe Coutinho
114e2b4011 chore(deps): bump dependencies e schema do biome
- @aws-sdk/client-s3 + s3-request-presigner: 3.1032 → 3.1037
- @better-auth/passkey: 1.6.5 → 1.6.9
- better-auth: 1.6.5 → 1.6.9
- @tanstack/react-query: 5.99.2 → 5.100.3
- @biomejs/biome: 2.4.12 → 2.4.13 (com $schema atualizado)
- @tailwindcss/postcss + tailwindcss: 4.2.2 → 4.2.4
- resend: 6.12.0 → 6.12.2
- knip: 6.4.1 → 6.7.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:58 +00:00
Felipe Coutinho
f15a003cef fix(docker): healthcheck usar 127.0.0.1 para hosts com IPv6 (#44)
Containers em hosts com IPv6 habilitado tentavam conectar via ::1
e falhavam por timeout antes de cair no fallback IPv4. Fixar
127.0.0.1 elimina a ambiguidade.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:18 +00:00
Felipe Coutinho
7f07a9cbf6 fix(i18n): corrigir rótulos pt-br em categorias e dialog de antecipação
- updateCategoryAction: mensagem de sucesso "Category atualizada com
  sucesso." → "Categoria atualizada com sucesso."
- AnticipateInstallmentsDialog: rótulos "Period" → "Fatura" e
  "Category" → "Categoria"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:12 +00:00
Felipe Coutinho
5fa234884e style(ui): refresh em badges de tipo, radio buttons e antecipação
- TransactionTypeBadge: substitui StatusDot por ícones direcionais
  (RiArrowRightDownLine receita, RiArrowRightUpLine despesa,
  RiArrowLeftRightLine transferência), adiciona borda e shadow sutil
  e dessaturação no dark mode; rótulo "Transferência" abreviado
  para "Transf."
- RadioGroup: indicador trocado de RiCircleLine por RiCheckLine com
  fundo sólido primary no estado selecionado
- Tabela de seleção de parcelas no dialog de antecipação reduzida
  para três colunas (estabelecimento, fatura, valor); coluna de
  vencimento removida e nome do estabelecimento absorve a parcela
- Inter agora carrega explicitamente os pesos 500, 600 e 700

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:46:05 +00:00
Felipe Coutinho
b453b432ed perf(logos): pré-resolver mapeamentos Logo.dev no servidor
Cada EstablishmentLogo dispara um GET para /api/logo/mapping por
nome único (deduplicado pelo React Query, mas ainda N requests por
página). Em /dashboard, /transactions e /payers/[payerId] agora
fazemos uma única query SQL em batch (fetchEstablishmentLogoMap) e
semeamos o cache do React Query antes do primeiro render via novo
LogoPrefetchProvider — eliminando os requests da rede.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:45:54 +00:00
Felipe Coutinho
7f05d2a681 fix(attachments): limpar arquivos órfãos no S3 em deleções e reset
Três caminhos de deleção não chamavam o cleanup de storage, deixando
arquivos órfãos no S3:

- deleteTransactionBulkAction: deleções por escopo de série (período,
  futuras, todas) agora coletam attachments vinculados antes do delete
  e disparam cleanupAttachmentsAfterTransactionDelete
- deleteMultipleTransactionsAction: mesma correção para seleção
  múltipla de lançamentos
- resetUserAppData: reset de conta em Ajustes coleta os fileKeys
  antes de truncar e remove os objetos do S3 em paralelo

Também ajusta deleteS3Object para ignorar NoSuchKey silenciosamente,
necessário para providers S3-compatíveis como Cloudflare R2 que não
são idempotentes nessa operação.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:45:45 +00:00
Felipe Coutinho
b14f487824 feat(transactions): edição cooperativa e visibilidade de divisões
Adiciona splitGroupId para vincular as duas shares de um lançamento
dividido (schema + índice + migration 0026). Habilita:

- Edição de par dividido com escolha de escopo (apenas este lado ou
  ambos) via novo SplitPairDialog e updateTransactionSplitPairAction
- Filtro "Somente divididos" (isDivided) na tabela de lançamentos
- Visibilidade de anexos para pessoas com acesso compartilhado via
  payerShares; upload e detach em massa expandem para shares irmãs
- Cópia independente de anexos no fluxo "Importar para Minha Conta"
  (novo fileKey, novo userId, S3 CopyObject) com seção read-only
  "Anexos que serão copiados" no dialog de importação
- Ícone de clipe na tabela de lançamentos da página da pessoa via
  EXISTS em fetchPagadorLancamentos

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:45:35 +00:00
Felipe Coutinho
5b03824a72 fix(security): remover header CSP de respostas de API
CSP não tem efeito em respostas JSON e expunha domínios
internos (Umami, Supabase, logo.dev) em endpoints públicos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:44:18 +00:00
Felipe Coutinho
74dda549f5 style(format): corrigir ordenação de imports em 3 arquivos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:57:05 +00:00
Felipe Coutinho
137b63f256 style(format): corrigir formatação Biome em 5 arquivos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:54:56 +00:00
53 changed files with 4616 additions and 988 deletions

View File

@@ -7,6 +7,43 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [Unreleased]
## [2.4.3] - 2026-04-25
Esta versão amplia o trabalho com lançamentos divididos: anexos passam a ser visíveis para pessoas com acesso compartilhado, a importação para conta própria copia os arquivos de forma independente e a edição ganha a opção de aplicar a alteração nos dois lados do par. Três caminhos de deleção foram corrigidos para não deixar arquivos órfãos no storage. Também traz refresh visual nos badges de tipo e radio buttons, prefetch server-side de logos para reduzir chamadas de API no dashboard, e ajustes pontuais no healthcheck do container e em rótulos da UI.
### Adicionado
- Schema: coluna `split_group_id` (uuid, nullable) em `lancamentos` com índice `(user_id, split_group_id)` — liga as shares do mesmo evento de divisão
- Split: `buildLancamentoRecords` atribui um `splitGroupId` único por cycle (parcelado, recorrente ou único) para ambas as shares
- Split: edição cooperativa via `updateTransactionSplitPairAction` — ao editar um lançamento dividido, novo dialog `SplitPairDialog` permite escolher entre aplicar somente neste lado ou nos dois lados (nome, data, categoria e demais campos compartilhados; valor e payer permanecem por share)
- Importação: "Importar para Minha Conta" agora copia os anexos do lançamento-fonte para a conta de quem está importando (novo arquivo, novo `userId`, novo `fileKey` — cópia independente via S3 CopyObject). `createSchema` ganhou campo opcional `importFromTransactionId`; helper `copyAttachmentsForImport` valida acesso à fonte via ownership direto ou `payerShares`
- Importação: dialog "Importar para Minha Conta" exibe seção read-only "Anexos que serão copiados" listando os anexos do lançamento-fonte antes da confirmação
- Filtros: nova chave `isDivided` na tabela de lançamentos — toggle "Somente divididos" no drawer de filtros mantém o estado na URL
- Performance: prefetch server-side de mapeamentos Logo.dev no `/dashboard`, `/transactions` e `/payers/[payerId]` — uma única query SQL em batch (`fetchEstablishmentLogoMap`) semeia o cache do React Query antes do primeiro render, eliminando os N requests para `/api/logo/mapping`
### Alterado
- Anexos: `fetchTransactionAttachments` e `fetchTransactionAttachmentsAction` passam a autorizar leitura por acesso à transação (direto ou via `payerShares`), permitindo que pessoas com pagador compartilhado visualizem anexos de lançamentos divididos
- Anexos: upload (`confirmAttachmentUploadAction`) e detach em massa (`detachAttachmentBulkAction`) agora expandem `transactionIds` para incluir shares irmãs via `splitGroupId` — o vínculo em `transaction_attachments` é replicado para manter simetria
- Anexos: delete/detach continuam restritos ao criador (sem alteração de escrita); dashboard (`fetchAttachmentsForPeriod`) permanece listando apenas os anexos do próprio usuário
- Migração: lançamentos divididos criados antes desta versão ficam com `split_group_id` NULL e mantêm o comportamento antigo (anexos não visíveis para a contraparte); apenas splits novos são afetados
- Storage: `deleteS3Object` passa a ignorar `NoSuchKey` silenciosamente — providers S3-compatíveis (ex.: Cloudflare R2) lançam esse erro ao deletar objeto inexistente, ao contrário do comportamento idempotente do S3 padrão
- UI/Badges: `TransactionTypeBadge` redesenhado — substitui o `StatusDot` por ícones direcionais (`RiArrowRightDownLine` receita, `RiArrowRightUpLine` despesa, `RiArrowLeftRightLine` transferência), com borda visível, shadow sutil e variantes dark mode dessaturadas; rótulo "Transferência" abreviado para "Transf."
- UI/Forms: indicador do `RadioGroup` trocado de círculo (`RiCircleLine`) por check (`RiCheckLine`) com fundo sólido `primary` no estado selecionado
- UI/Antecipação: tabela de seleção de parcelas reduzida de quatro para três colunas (estabelecimento + fatura + valor) — informações de parcela e vencimento absorvidas pela coluna do estabelecimento
- Tipografia: fonte Inter agora carrega explicitamente os pesos 500, 600 e 700 (antes derivava de 400)
- Deps: better-auth 1.6.5 → 1.6.9, @aws-sdk/client-s3 3.1032 → 3.1037, @tanstack/react-query 5.99.2 → 5.100.3, @biomejs/biome 2.4.12 → 2.4.13, tailwindcss 4.2.2 → 4.2.4, resend 6.12.0 → 6.12.2
### Corrigido
- Anexos: deleção em massa por série (`deleteTransactionBulkAction`) não chamava cleanup de storage — arquivos ficavam órfãos no S3 após apagar "este e futuros" ou "todos" de uma série parcelada/recorrente com anexo
- Anexos: deleção múltipla por seleção (`deleteMultipleTransactionsAction`) não chamava cleanup de storage — mesmo problema ao selecionar vários lançamentos com anexo e deletar em lote
- Anexos: reset de conta em Ajustes (`resetUserAppData`) não limpava o storage — todos os arquivos do usuário ficavam órfãos no S3 após a operação de zeragem
- Página da pessoa (`/payers/[payerId]`): `fetchPagadorLancamentos` agora calcula `hasAttachments` via `EXISTS`, fazendo o ícone de clipe aparecer na tabela de lançamentos (antes só aparecia em `/transactions`)
- Categorias: mensagem de sucesso ao atualizar exibia "Category atualizada com sucesso." — corrigido para "Categoria atualizada com sucesso."
- Antecipação: rótulos "Category" e "Período" no dialog corrigidos para "Categoria" e "Fatura"
- Docker: healthcheck do container `app` agora usa `127.0.0.1:3000` em vez de `localhost:3000`, evitando connection timeout em hosts com IPv6 (resolvendo [#44](https://github.com/felipegcoutinho/openmonetis/issues/44))
## [2.4.2] - 2026-04-20 ## [2.4.2] - 2026-04-20
Esta versão é quase toda sobre organização e polimento. O código interno do Dashboard foi reestruturado — módulos espalhados pela raiz da feature foram agrupados em subdiretórios coesos e a arquitetura de widgets foi renovada com um novo `widget-registry`. A sidebar lateral foi aposentada em favor de uma navegação concentrada na navbar. A interface passou por um refinamento visual amplo: cards redesenhados, dark mode mais consistente e efeitos decorativos removidos para uma composição mais limpa. As imagens de preview da landing page foram atualizadas. Por fim, a integração com Logo.dev ganhou uma arquitetura mais segura — o token agora é lido apenas no servidor e nunca chega ao cliente. O conceito de "Pagador" foi renomeado para "Pessoa" em toda a interface. Esta versão é quase toda sobre organização e polimento. O código interno do Dashboard foi reestruturado — módulos espalhados pela raiz da feature foram agrupados em subdiretórios coesos e a arquitetura de widgets foi renovada com um novo `widget-registry`. A sidebar lateral foi aposentada em favor de uma navegação concentrada na navbar. A interface passou por um refinamento visual amplo: cards redesenhados, dark mode mais consistente e efeitos decorativos removidos para uma composição mais limpa. As imagens de preview da landing page foram atualizadas. Por fim, a integração com Logo.dev ganhou uma arquitetura mais segura — o token agora é lido apenas no servidor e nunca chega ao cliente. O conceito de "Pagador" foi renomeado para "Pessoa" em toda a interface.

View File

@@ -109,7 +109,7 @@ USER nextjs
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1 CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
# Entrypoint: roda migrations e depois executa o CMD # Entrypoint: roda migrations e depois executa o CMD
ENTRYPOINT ["/app/docker-entrypoint.sh"] ENTRYPOINT ["/app/docker-entrypoint.sh"]

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.4.2-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.4.3-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -41,7 +41,7 @@ services:
condition: service_healthy condition: service_healthy
required: false required: false
healthcheck: healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"] test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View File

@@ -0,0 +1,2 @@
ALTER TABLE "lancamentos" ADD COLUMN "split_group_id" uuid;--> statement-breakpoint
CREATE INDEX "lancamentos_user_id_split_group_id_idx" ON "lancamentos" USING btree ("user_id","split_group_id");

File diff suppressed because it is too large Load Diff

View File

@@ -183,6 +183,13 @@
"when": 1776351838548, "when": 1776351838548,
"tag": "0025_burly_colonel_america", "tag": "0025_burly_colonel_america",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1777042423451,
"tag": "0026_bored_eternity",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.4.2", "version": "2.4.3",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {
@@ -35,9 +35,9 @@
"@ai-sdk/anthropic": "^3.0.71", "@ai-sdk/anthropic": "^3.0.71",
"@ai-sdk/google": "^3.0.64", "@ai-sdk/google": "^3.0.64",
"@ai-sdk/openai": "^3.0.53", "@ai-sdk/openai": "^3.0.53",
"@aws-sdk/client-s3": "^3.1032.0", "@aws-sdk/client-s3": "^3.1037.0",
"@aws-sdk/s3-request-presigner": "^3.1032.0", "@aws-sdk/s3-request-presigner": "^3.1037.0",
"@better-auth/passkey": "^1.6.5", "@better-auth/passkey": "^1.6.9",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -64,11 +64,11 @@
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.99.2", "@tanstack/react-query": "^5.100.3",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
"ai": "^6.0.168", "ai": "^6.0.168",
"better-auth": "1.6.5", "better-auth": "1.6.9",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -86,7 +86,7 @@
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"recharts": "3.8.1", "recharts": "3.8.1",
"resend": "^6.12.0", "resend": "^6.12.2",
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"vaul": "1.1.2", "vaul": "1.1.2",
@@ -98,8 +98,8 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.12", "@biomejs/biome": "2.4.13",
"@tailwindcss/postcss": "4.2.2", "@tailwindcss/postcss": "4.2.4",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "25.6.0", "@types/node": "25.6.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
@@ -107,8 +107,8 @@
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.4.1", "knip": "^6.7.0",
"tailwindcss": "4.2.2", "tailwindcss": "4.2.4",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "6.0.3" "typescript": "6.0.3"
} }

1217
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,5 +5,6 @@ export const inter = Inter({
display: "swap", display: "swap",
variable: "--font-inter", variable: "--font-inter",
fallback: ["ui-sans-serif", "system-ui"], fallback: ["ui-sans-serif", "system-ui"],
weight: ["500", "600", "700"],
preload: true, preload: true,
}); });

View File

@@ -2,10 +2,13 @@ import { connection } from "next/server";
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable"; import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards"; import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome"; import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
import { extractDashboardLogoNames } from "@/features/dashboard/extract-logo-names";
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries"; import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
import { getSingleParam } from "@/features/transactions/page-helpers"; import { getSingleParam } from "@/features/transactions/page-helpers";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>; type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -25,17 +28,24 @@ export default async function Page({ searchParams }: PageProps) {
await fetchDashboardPageData(user.id, selectedPeriod); await fetchDashboardPageData(user.id, selectedPeriod);
const { dashboardWidgets } = preferences; const { dashboardWidgets } = preferences;
const logoMappings = await prefetchLogoMappings(
user.id,
extractDashboardLogoNames(dashboardData),
);
return ( return (
<main className="flex flex-col gap-4"> <main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} /> <DashboardWelcome name={user.name} />
<MonthNavigation /> <MonthNavigation />
<DashboardMetricsCards metrics={dashboardData.metrics} /> <DashboardMetricsCards metrics={dashboardData.metrics} />
<DashboardGridEditable <LogoPrefetchProvider mappings={logoMappings}>
data={dashboardData} <DashboardGridEditable
period={selectedPeriod} data={dashboardData}
initialPreferences={dashboardWidgets} period={selectedPeriod}
quickActionOptions={quickActionOptions} initialPreferences={dashboardWidgets}
/> quickActionOptions={quickActionOptions}
/>
</LogoPrefetchProvider>
</main> </main>
); );
} }

View File

@@ -41,6 +41,7 @@ import {
fetchRecentEstablishments, fetchRecentEstablishments,
fetchTransactionFilterSources, fetchTransactionFilterSources,
} from "@/features/transactions/queries"; } from "@/features/transactions/queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card"; import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { import {
@@ -50,6 +51,7 @@ import {
TabsTrigger, TabsTrigger,
} from "@/shared/components/ui/tabs"; } from "@/shared/components/ui/tabs";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
import { getPayerAccess } from "@/shared/lib/payers/access"; import { getPayerAccess } from "@/shared/lib/payers/access";
import { import {
fetchPagadorBoletoItems, fetchPagadorBoletoItems,
@@ -82,6 +84,7 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
searchFilter: null, searchFilter: null,
settledFilter: null, settledFilter: null,
attachmentFilter: null, attachmentFilter: null,
dividedFilter: null,
}; };
const createEmptySlugMaps = (): SlugMaps => ({ const createEmptySlugMaps = (): SlugMaps => ({
@@ -307,104 +310,113 @@ export default async function Page({ params, searchParams }: PageProps) {
lancamentoCount: transactionData.length, lancamentoCount: transactionData.length,
}; };
const logoMappings = await prefetchLogoMappings(dataOwnerId, [
...transactionData.map((t) => t.name),
...boletoItems.map((b) => b.name),
]);
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<MonthNavigation /> <MonthNavigation />
<Tabs defaultValue="profile" className="w-full"> <LogoPrefetchProvider mappings={logoMappings}>
<TabsList className="mb-2"> <Tabs defaultValue="profile" className="w-full">
<TabsTrigger value="profile">Perfil</TabsTrigger> <TabsList className="mb-2">
<TabsTrigger value="painel">Painel</TabsTrigger> <TabsTrigger value="profile">Perfil</TabsTrigger>
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger> <TabsTrigger value="painel">Painel</TabsTrigger>
</TabsList> <TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
<PayerHeaderCard </TabsList>
payer={payerData} <PayerHeaderCard
selectedPeriod={selectedPeriod} payer={payerData}
summary={summaryPreview} selectedPeriod={selectedPeriod}
/> summary={summaryPreview}
/>
<TabsContent value="profile" className="space-y-4"> <TabsContent value="profile" className="space-y-4">
<PagadorInfoCard payer={payerData} /> <PagadorInfoCard payer={payerData} />
{canEdit && payerData.shareCode ? ( {canEdit && payerData.shareCode ? (
<PayerSharingCard <PayerSharingCard
payerId={pagador.id} payerId={pagador.id}
shareCode={payerData.shareCode} shareCode={payerData.shareCode}
shares={payerSharesData} shares={payerSharesData}
/> />
) : null} ) : null}
{!canEdit && currentUserShare ? ( {!canEdit && currentUserShare ? (
<PayerLeaveShareCard <PayerLeaveShareCard
shareId={currentUserShare.id} shareId={currentUserShare.id}
pagadorName={payerData.name} pagadorName={payerData.name}
createdAt={currentUserShare.createdAt} createdAt={currentUserShare.createdAt}
/> />
) : null} ) : null}
</TabsContent> </TabsContent>
<TabsContent value="painel" className="space-y-4"> <TabsContent value="painel" className="space-y-4">
<section className="grid gap-3 lg:grid-cols-2"> <section className="grid gap-3 lg:grid-cols-2">
<PayerMonthlySummaryCard <PayerMonthlySummaryCard
periodLabel={periodLabel} periodLabel={periodLabel}
breakdown={monthlyBreakdown} breakdown={monthlyBreakdown}
/> />
<PayerHistoryCard data={historyData} /> <PayerHistoryCard data={historyData} />
</section> </section>
<section className="grid gap-3 lg:grid-cols-3"> <section className="grid gap-3 lg:grid-cols-3">
<ExpandableWidgetCard <ExpandableWidgetCard
title="Minhas Faturas" title="Minhas Faturas"
subtitle="Valores por cartão neste período" subtitle="Valores por cartão neste período"
icon={<RiBankCard2Line className="size-4" />} icon={<RiBankCard2Line className="size-4" />}
> >
<PayerCardUsageCard items={cardUsage} /> <PayerCardUsageCard items={cardUsage} />
</ExpandableWidgetCard> </ExpandableWidgetCard>
<ExpandableWidgetCard <ExpandableWidgetCard
title="Boletos" title="Boletos"
subtitle="Boletos registrados neste período" subtitle="Boletos registrados neste período"
icon={<RiBarcodeLine className="size-4" />} icon={<RiBarcodeLine className="size-4" />}
> >
<PayerBoletoCard items={boletoItems} /> <PayerBoletoCard items={boletoItems} />
</ExpandableWidgetCard> </ExpandableWidgetCard>
<ExpandableWidgetCard <ExpandableWidgetCard
title="Status de Pagamento" title="Status de Pagamento"
subtitle="Situação das despesas no período" subtitle="Situação das despesas no período"
icon={<RiWallet3Line className="size-4" />} icon={<RiWallet3Line className="size-4" />}
> >
<PayerPaymentStatusCard data={paymentStatus} /> <PayerPaymentStatusCard data={paymentStatus} />
</ExpandableWidgetCard> </ExpandableWidgetCard>
</section> </section>
</TabsContent> </TabsContent>
<TabsContent value="lancamentos"> <TabsContent value="lancamentos">
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<LancamentosSection <LancamentosSection
currentUserId={userId} currentUserId={userId}
transactions={transactionData} transactions={transactionData}
payerOptions={optionSets.payerOptions} payerOptions={optionSets.payerOptions}
splitPayerOptions={optionSets.splitPayerOptions} splitPayerOptions={optionSets.splitPayerOptions}
defaultPayerId={pagador.id} defaultPayerId={pagador.id}
accountOptions={optionSets.accountOptions} accountOptions={optionSets.accountOptions}
cardOptions={optionSets.cardOptions} cardOptions={optionSets.cardOptions}
categoryOptions={optionSets.categoryOptions} categoryOptions={optionSets.categoryOptions}
payerFilterOptions={payerFilterOptions} payerFilterOptions={payerFilterOptions}
categoryFilterOptions={optionSets.categoryFilterOptions} categoryFilterOptions={optionSets.categoryFilterOptions}
accountCardFilterOptions={optionSets.accountCardFilterOptions} accountCardFilterOptions={optionSets.accountCardFilterOptions}
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
allowCreate={canEdit} allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50} attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions} importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions} importSplitPayerOptions={
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId} loggedUserOptionSets?.splitPayerOptions
importAccountOptions={loggedUserOptionSets?.accountOptions} }
importCardOptions={loggedUserOptionSets?.cardOptions} importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
importCategoryOptions={loggedUserOptionSets?.categoryOptions} importAccountOptions={loggedUserOptionSets?.accountOptions}
/> importCardOptions={loggedUserOptionSets?.cardOptions}
</section> importCategoryOptions={loggedUserOptionSets?.categoryOptions}
</TabsContent> />
</Tabs> </section>
</TabsContent>
</Tabs>
</LogoPrefetchProvider>
</main> </main>
); );
} }

View File

@@ -17,8 +17,10 @@ import {
fetchTransactionFilterSources, fetchTransactionFilterSources,
fetchTransactionsPage, fetchTransactionsPage,
} from "@/features/transactions/queries"; } from "@/features/transactions/queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>; type PageSearchParams = Promise<ResolvedSearchParams>;
@@ -74,38 +76,45 @@ export default async function Page({ searchParams }: PageProps) {
payerRows: filterSources.payerRows, payerRows: filterSources.payerRows,
}); });
const logoMappings = await prefetchLogoMappings(
userId,
transactionData.map((t) => t.name),
);
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<MonthNavigation /> <MonthNavigation />
<TransactionsPage <LogoPrefetchProvider mappings={logoMappings}>
currentUserId={userId} <TransactionsPage
transactions={transactionData} currentUserId={userId}
payerOptions={payerOptions} transactions={transactionData}
splitPayerOptions={splitPayerOptions} payerOptions={payerOptions}
defaultPayerId={defaultPayerId} splitPayerOptions={splitPayerOptions}
accountOptions={accountOptions} defaultPayerId={defaultPayerId}
cardOptions={cardOptions} accountOptions={accountOptions}
categoryOptions={categoryOptions} cardOptions={cardOptions}
payerFilterOptions={payerFilterOptions} categoryOptions={categoryOptions}
categoryFilterOptions={categoryFilterOptions} payerFilterOptions={payerFilterOptions}
accountCardFilterOptions={accountCardFilterOptions} categoryFilterOptions={categoryFilterOptions}
selectedPeriod={selectedPeriod} accountCardFilterOptions={accountCardFilterOptions}
estabelecimentos={estabelecimentos} selectedPeriod={selectedPeriod}
pagination={{ estabelecimentos={estabelecimentos}
page: transactionsPage.page, pagination={{
pageSize: transactionsPage.pageSize, page: transactionsPage.page,
totalItems: transactionsPage.totalItems, pageSize: transactionsPage.pageSize,
totalPages: transactionsPage.totalPages, totalItems: transactionsPage.totalItems,
}} totalPages: transactionsPage.totalPages,
exportContext={{ }}
source: "transactions", exportContext={{
period: selectedPeriod, source: "transactions",
filters: searchFilters, period: selectedPeriod,
}} filters: searchFilters,
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} }}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
/> attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</LogoPrefetchProvider>
</main> </main>
); );
} }

View File

@@ -670,6 +670,7 @@ export const transactions = pgTable(
onUpdate: "cascade", onUpdate: "cascade",
}), }),
seriesId: uuid("series_id"), seriesId: uuid("series_id"),
splitGroupId: uuid("split_group_id"),
transferId: uuid("transfer_id"), transferId: uuid("transfer_id"),
ofxFitId: text("ofx_fit_id"), ofxFitId: text("ofx_fit_id"),
importBatchId: text("import_batch_id"), importBatchId: text("import_batch_id"),
@@ -702,6 +703,11 @@ export const transactions = pgTable(
), ),
// Índice para buscar parcelas de uma série // Índice para buscar parcelas de uma série
seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId), seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId),
// Índice para buscar shares de um split (userId + splitGroupId)
userIdSplitGroupIdIdx: index("lancamentos_user_id_split_group_id_idx").on(
table.userId,
table.splitGroupId,
),
// Índice para buscar transferências relacionadas // Índice para buscar transferências relacionadas
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId), transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
// Índice para filtrar por condição (aberto, realizado, cancelado) // Índice para filtrar por condição (aberto, realizado, cancelado)

View File

@@ -116,7 +116,7 @@ export async function updateCategoryAction(
revalidateForEntity("categories", user.id); revalidateForEntity("categories", user.id);
return { success: true, message: "Category atualizada com sucesso." }; return { success: true, message: "Categoria atualizada com sucesso." };
} catch (error) { } catch (error) {
return handleActionError(error); return handleActionError(error);
} }

View File

@@ -51,9 +51,7 @@ type UniqueCategory = {
icon: string | null; icon: string | null;
}; };
async function fetchAllCategories( async function fetchAllCategories(userId: string): Promise<CategoryOption[]> {
userId: string,
): Promise<CategoryOption[]> {
const result = await db const result = await db
.select({ .select({
id: categories.id, id: categories.id,

View File

@@ -1,4 +1,7 @@
import { formatCurrentDate, getGreeting } from "@/features/dashboard/widget-registry/welcome-widget"; import {
formatCurrentDate,
getGreeting,
} from "@/features/dashboard/widget-registry/welcome-widget";
type DashboardWelcomeProps = { type DashboardWelcomeProps = {
name?: string | null; name?: string | null;

View File

@@ -1,6 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
import { buildInstallmentExpenseDisplay } from "@/features/dashboard/expenses/installment-expenses-helpers"; import { buildInstallmentExpenseDisplay } from "@/features/dashboard/expenses/installment-expenses-helpers";
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Progress } from "@/shared/components/ui/progress"; import { Progress } from "@/shared/components/ui/progress";

View File

@@ -1,7 +1,7 @@
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react"; import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
import type { PaymentOverviewTab } from "@/features/dashboard/payments/payment-overview-tabs";
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries"; import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries"; import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
import type { PaymentOverviewTab } from "@/features/dashboard/payments/payment-overview-tabs";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,

View File

@@ -0,0 +1,28 @@
import type { DashboardData } from "./fetch-dashboard-data";
/**
* Coleta todos os nomes de estabelecimentos exibidos nos widgets do
* dashboard que renderizam `<EstablishmentLogo />`. Usado para
* pré-resolver os mapeamentos Logo.dev no servidor.
*/
export function extractDashboardLogoNames(data: DashboardData): string[] {
const names: string[] = [];
for (const bill of data.billsSnapshot.bills) names.push(bill.name);
for (const expense of data.recurringExpensesData.expenses)
names.push(expense.name);
for (const expense of data.installmentExpensesData.expenses)
names.push(expense.name);
for (const establishment of data.topEstablishmentsData.establishments)
names.push(establishment.name);
for (const expense of data.topExpensesAll.expenses) names.push(expense.name);
for (const expense of data.topExpensesCardOnly.expenses)
names.push(expense.name);
for (const transactions of Object.values(
data.purchasesByCategoryData.transactionsByCategory,
)) {
for (const transaction of transactions) names.push(transaction.name);
}
return names;
}

View File

@@ -6,6 +6,7 @@ import {
transactions, transactions,
} from "@/db/schema"; } from "@/db/schema";
import type { DashboardBillsSnapshot } from "@/features/dashboard/bills/bills-queries"; import type { DashboardBillsSnapshot } from "@/features/dashboard/bills/bills-queries";
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries"; import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries"; import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
import type { import type {
@@ -15,7 +16,6 @@ import type {
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries"; import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries"; import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries"; import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries"; import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters"; import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
import { import {

View File

@@ -151,7 +151,9 @@ export const InboxCard = memo(function InboxCard({
<CardContent className="min-h-0 flex-1 overflow-hidden py-2"> <CardContent className="min-h-0 flex-1 overflow-hidden py-2">
{item.originalTitle && ( {item.originalTitle && (
<p className="mb-1 line-clamp-2 text-sm font-medium">{item.originalTitle}</p> <p className="mb-1 line-clamp-2 text-sm font-medium">
{item.originalTitle}
</p>
)} )}
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground"> <p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
{item.originalText} {item.originalText}

View File

@@ -1,10 +1,11 @@
import { and, desc, eq, type SQL } from "drizzle-orm"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import { import {
cards, cards,
categories, categories,
financialAccounts, financialAccounts,
payerShares, payerShares,
payers, payers,
transactionAttachments,
transactions, transactions,
user as usersTable, user as usersTable,
} from "@/db/schema"; } from "@/db/schema";
@@ -73,6 +74,10 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
financialAccount: financialAccounts, financialAccount: financialAccounts,
card: cards, card: cards,
category: categories, category: categories,
hasAttachments: sql<boolean>`EXISTS (
SELECT 1 FROM ${transactionAttachments}
WHERE ${transactionAttachments.transactionId} = ${transactions.id}
)`,
}) })
.from(transactions) .from(transactions)
.leftJoin(payers, eq(transactions.payerId, payers.id)) .leftJoin(payers, eq(transactions.payerId, payers.id))
@@ -85,12 +90,12 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
.where(and(...filters)) .where(and(...filters))
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt)); .orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
// Transformar resultado para o formato esperado
return transactionRows.map((row) => ({ return transactionRows.map((row) => ({
...row.transaction, ...row.transaction,
payer: row.payer, payer: row.payer,
financialAccount: row.financialAccount, financialAccount: row.financialAccount,
card: row.card, card: row.card,
category: row.category, category: row.category,
hasAttachments: row.hasAttachments,
})); }));
} }

View File

@@ -18,6 +18,7 @@ import {
} from "@/shared/lib/payers/constants"; } from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils"; import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
import { deleteS3Object } from "@/shared/lib/storage/presign";
type ActionResponse<T = void> = { type ActionResponse<T = void> = {
success: boolean; success: boolean;
@@ -85,6 +86,11 @@ async function resetUserAppData(
const avatarUrl = user.image ?? DEFAULT_PAYER_AVATAR; const avatarUrl = user.image ?? DEFAULT_PAYER_AVATAR;
const defaultPayerStatus = PAYER_STATUS_OPTIONS[0]; const defaultPayerStatus = PAYER_STATUS_OPTIONS[0];
const userAttachments = await db
.select({ id: schema.attachments.id, fileKey: schema.attachments.fileKey })
.from(schema.attachments)
.where(eq(schema.attachments.userId, userId));
await db.transaction(async (tx: typeof db) => { await db.transaction(async (tx: typeof db) => {
await tx await tx
.delete(schema.payerShares) .delete(schema.payerShares)
@@ -115,6 +121,9 @@ async function resetUserAppData(
await tx await tx
.delete(schema.transactions) .delete(schema.transactions)
.where(eq(schema.transactions.userId, userId)); .where(eq(schema.transactions.userId, userId));
await tx
.delete(schema.attachments)
.where(eq(schema.attachments.userId, userId));
await tx.delete(schema.invoices).where(eq(schema.invoices.userId, userId)); await tx.delete(schema.invoices).where(eq(schema.invoices.userId, userId));
await tx.delete(schema.cards).where(eq(schema.cards.userId, userId)); await tx.delete(schema.cards).where(eq(schema.cards.userId, userId));
await tx await tx
@@ -147,6 +156,14 @@ async function resetUserAppData(
userId, userId,
}); });
}); });
await Promise.all(
userAttachments.map((att) =>
deleteS3Object(att.fileKey).catch((err) => {
console.error("Falha ao remover anexo do S3 no reset:", err);
}),
),
);
} }
// Actions // Actions

View File

@@ -99,8 +99,7 @@ export function DeleteAccountForm() {
Preferências do app, insights salvos e tokens do Companion Preferências do app, insights salvos e tokens do Companion
</li> </li>
<li className="font-medium text-foreground"> <li className="font-medium text-foreground">
Categorias padrão e pessoa admin serão recriadas Categorias padrão e pessoa admin serão recriadas automaticamente
automaticamente
</li> </li>
</ul> </ul>

View File

@@ -12,6 +12,7 @@ import {
deleteTransactionAction as deleteTransactionActionImpl, deleteTransactionAction as deleteTransactionActionImpl,
toggleTransactionSettlementAction as toggleTransactionSettlementActionImpl, toggleTransactionSettlementAction as toggleTransactionSettlementActionImpl,
updateTransactionAction as updateTransactionActionImpl, updateTransactionAction as updateTransactionActionImpl,
updateTransactionSplitPairAction as updateTransactionSplitPairActionImpl,
} from "./actions/single-actions"; } from "./actions/single-actions";
export async function createTransactionAction( export async function createTransactionAction(
@@ -62,6 +63,12 @@ export async function deleteMultipleTransactionsAction(
return deleteMultipleTransactionsActionImpl(...args); return deleteMultipleTransactionsActionImpl(...args);
} }
export async function updateTransactionSplitPairAction(
...args: Parameters<typeof updateTransactionSplitPairActionImpl>
): ReturnType<typeof updateTransactionSplitPairActionImpl> {
return updateTransactionSplitPairActionImpl(...args);
}
export async function exportTransactionsDataAction( export async function exportTransactionsDataAction(
...args: Parameters<typeof exportTransactionsDataActionImpl> ...args: Parameters<typeof exportTransactionsDataActionImpl>
): ReturnType<typeof exportTransactionsDataActionImpl> { ): ReturnType<typeof exportTransactionsDataActionImpl> {

View File

@@ -1,7 +1,7 @@
"use server"; "use server";
import crypto, { randomUUID } from "node:crypto"; import crypto, { randomUUID } from "node:crypto";
import { and, count, eq, inArray } from "drizzle-orm"; import { and, count, eq, inArray, isNotNull } from "drizzle-orm";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { attachments, transactionAttachments, transactions } from "@/db/schema"; import { attachments, transactionAttachments, transactions } from "@/db/schema";
import { import {
@@ -15,7 +15,6 @@ import {
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { import {
createPresignedGetUrl,
createPresignedPutUrl, createPresignedPutUrl,
deleteS3Object, deleteS3Object,
headS3Object, headS3Object,
@@ -98,6 +97,46 @@ function signUploadToken(payload: UploadTokenPayload): string {
return `${encodedPayload}.${signature}`; return `${encodedPayload}.${signature}`;
} }
async function expandSplitSiblings(
transactionIds: string[],
userId: string,
): Promise<string[]> {
if (transactionIds.length === 0) return transactionIds;
const groupRows = await db
.select({ splitGroupId: transactions.splitGroupId })
.from(transactions)
.where(
and(
inArray(transactions.id, transactionIds),
eq(transactions.userId, userId),
isNotNull(transactions.splitGroupId),
),
);
const splitGroupIds = [
...new Set(
groupRows
.map((r) => r.splitGroupId)
.filter((v): v is string => v !== null),
),
];
if (splitGroupIds.length === 0) return transactionIds;
const siblingRows = await db
.select({ id: transactions.id })
.from(transactions)
.where(
and(
inArray(transactions.splitGroupId, splitGroupIds),
eq(transactions.userId, userId),
),
);
return [...new Set([...transactionIds, ...siblingRows.map((r) => r.id)])];
}
function verifyUploadToken(token: string): UploadTokenPayload | null { function verifyUploadToken(token: string): UploadTokenPayload | null {
try { try {
const [encodedPayload, signature] = token.split("."); const [encodedPayload, signature] = token.split(".");
@@ -281,6 +320,8 @@ export async function confirmAttachmentUploadAction(input: {
} }
} }
transactionIds = await expandSplitSiblings(transactionIds, user.id);
await db.insert(transactionAttachments).values( await db.insert(transactionAttachments).values(
transactionIds.map((tid) => ({ transactionIds.map((tid) => ({
transactionId: tid, transactionId: tid,
@@ -359,69 +400,6 @@ export async function detachTransactionAttachmentAction(input: {
} }
} }
export async function fetchTransactionAttachmentsAction(
transactionId: string,
): Promise<
Array<{
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: Date;
url: string;
}>
> {
const user = await getUser();
const [transaction] = await db
.select({ id: transactions.id })
.from(transactions)
.where(
and(eq(transactions.id, transactionId), eq(transactions.userId, user.id)),
);
if (!transaction) {
return [];
}
const rows = await db
.select({
attachmentId: transactionAttachments.attachmentId,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
fileKey: attachments.fileKey,
createdAt: attachments.createdAt,
})
.from(transactionAttachments)
.innerJoin(
transactions,
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, user.id),
),
)
.innerJoin(
attachments,
and(
eq(transactionAttachments.attachmentId, attachments.id),
eq(attachments.userId, user.id),
),
)
.where(eq(transactionAttachments.transactionId, transactionId));
return Promise.all(
rows.map(async (row) => ({
attachmentId: row.attachmentId,
fileName: row.fileName,
fileSize: row.fileSize,
mimeType: row.mimeType,
createdAt: row.createdAt,
url: await createPresignedGetUrl(row.fileKey),
})),
);
}
const detachBulkSchema = z.object({ const detachBulkSchema = z.object({
attachmentId: z.string().uuid(), attachmentId: z.string().uuid(),
transactionId: z.string().uuid(), transactionId: z.string().uuid(),
@@ -497,6 +475,11 @@ export async function detachAttachmentBulkAction(input: {
} }
} }
targetTransactionIds = await expandSplitSiblings(
targetTransactionIds,
user.id,
);
if (targetTransactionIds.length > 0) { if (targetTransactionIds.length > 0) {
await db await db
.delete(transactionAttachments) .delete(transactionAttachments)

View File

@@ -1,7 +1,7 @@
"use server"; "use server";
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
import { transactions } from "@/db/schema"; import { attachments, transactionAttachments, transactions } from "@/db/schema";
import { import {
PAYMENT_METHODS, PAYMENT_METHODS,
TRANSACTION_CONDITIONS, TRANSACTION_CONDITIONS,
@@ -17,6 +17,7 @@ import {
import type { ActionResult } from "@/shared/lib/types/actions"; import type { ActionResult } from "@/shared/lib/types/actions";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date"; import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period"; import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import { import {
centsToDecimalString, centsToDecimalString,
type DeleteBulkInput, type DeleteBulkInput,
@@ -78,71 +79,64 @@ export async function deleteTransactionBulkAction(
}; };
} }
let scopeFilter: ReturnType<typeof and>;
let successMessage: string;
if (data.scope === "current") { if (data.scope === "current") {
await db scopeFilter = eq(transactions.id, data.id);
.delete(transactions) successMessage = "Lançamento removido com sucesso.";
.where( } else if (data.scope === "period") {
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)), scopeFilter = and(
); eq(transactions.seriesId, existing.seriesId),
eq(transactions.period, existing.period ?? ""),
revalidate(user.id); );
return { success: true, message: "Lançamento removido com sucesso." }; successMessage = "Todos os lançamentos do período foram removidos.";
} else if (data.scope === "future") {
scopeFilter = and(
eq(transactions.seriesId, existing.seriesId),
sql`${transactions.period} >= ${existing.period}`,
);
successMessage = "Lançamentos removidos com sucesso.";
} else if (data.scope === "all") {
scopeFilter = eq(transactions.seriesId, existing.seriesId);
successMessage = "Todos os lançamentos da série foram removidos.";
} else {
return { success: false, error: "Escopo de ação inválido." };
} }
if (data.scope === "period") { const targetRows = await db
await db .select({ id: transactions.id })
.delete(transactions) .from(transactions)
.where( .where(and(scopeFilter, eq(transactions.userId, user.id)));
and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
eq(transactions.period, existing.period ?? ""),
),
);
revalidate(user.id); const targetIds = targetRows.map((r) => r.id);
return {
success: true, if (targetIds.length === 0) {
message: "Todos os lançamentos do período foram removidos.", return { success: false, error: "Nenhum lançamento encontrado." };
};
} }
if (data.scope === "future") { const linkedAttachments = await db
await db .select({ id: attachments.id, fileKey: attachments.fileKey })
.delete(transactions) .from(transactionAttachments)
.where( .innerJoin(
and( attachments,
eq(transactions.seriesId, existing.seriesId), eq(transactionAttachments.attachmentId, attachments.id),
eq(transactions.userId, user.id), )
sql`${transactions.period} >= ${existing.period}`, .where(inArray(transactionAttachments.transactionId, targetIds));
),
);
revalidate(user.id); await db
return { .delete(transactions)
success: true, .where(
message: "Lançamentos removidos com sucesso.", and(
}; inArray(transactions.id, targetIds),
} eq(transactions.userId, user.id),
),
);
if (data.scope === "all") { await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
await db
.delete(transactions)
.where(
and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
),
);
revalidate(user.id); revalidate(user.id);
return { return { success: true, message: successMessage };
success: true,
message: "Todos os lançamentos da série foram removidos.",
};
}
return { success: false, error: "Escopo de ação inválido." };
} catch (error) { } catch (error) {
return handleActionError(error); return handleActionError(error);
} }
@@ -759,6 +753,15 @@ export async function deleteMultipleTransactionsAction(
return { success: false, error: "Nenhum lançamento encontrado." }; return { success: false, error: "Nenhum lançamento encontrado." };
} }
const linkedAttachments = await db
.select({ id: attachments.id, fileKey: attachments.fileKey })
.from(transactionAttachments)
.innerJoin(
attachments,
eq(transactionAttachments.attachmentId, attachments.id),
)
.where(inArray(transactionAttachments.transactionId, data.ids));
await db await db
.delete(transactions) .delete(transactions)
.where( .where(
@@ -768,6 +771,8 @@ export async function deleteMultipleTransactionsAction(
), ),
); );
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
const notificationData = existing const notificationData = existing
.filter( .filter(
( (

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -394,7 +395,11 @@ const refineLancamento = (
} }
}; };
export const createSchema = baseFields.superRefine(refineLancamento); export const createSchema = baseFields
.extend({
importFromTransactionId: uuidSchema("Lançamento fonte").optional(),
})
.superRefine(refineLancamento);
export const updateSchema = baseFields export const updateSchema = baseFields
.extend({ .extend({
id: uuidSchema("Lançamento"), id: uuidSchema("Lançamento"),
@@ -544,6 +549,7 @@ export const buildLancamentoRecords = ({
seriesId, seriesId,
}: BuildTransactionRecordsParams): TransactionInsert[] => { }: BuildTransactionRecordsParams): TransactionInsert[] => {
const records: TransactionInsert[] = []; const records: TransactionInsert[] = [];
const isSplit = (data.isSplit ?? false) && shares.length > 1;
const basePayload = { const basePayload = {
name: data.name, name: data.name,
@@ -562,6 +568,8 @@ export const buildLancamentoRecords = ({
seriesId, seriesId,
}; };
const cycleSplitGroupId = () => (isSplit ? randomUUID() : null);
const resolveSettledValue = (cycleIndex: number) => { const resolveSettledValue = (cycleIndex: number) => {
if (shouldNullifySettled) { if (shouldNullifySettled) {
return null; return null;
@@ -588,6 +596,7 @@ export const buildLancamentoRecords = ({
const installmentDueDate = dueDate const installmentDueDate = dueDate
? addMonthsToDate(dueDate, installment) ? addMonthsToDate(dueDate, installment)
: null; : null;
const splitGroupId = cycleSplitGroupId();
shares.forEach((share, shareIndex) => { shares.forEach((share, shareIndex) => {
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0; const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
@@ -603,6 +612,7 @@ export const buildLancamentoRecords = ({
currentInstallment: installment + 1, currentInstallment: installment + 1,
recurrenceCount: null, recurrenceCount: null,
dueDate: installmentDueDate, dueDate: installmentDueDate,
splitGroupId,
boletoPaymentDate: boletoPaymentDate:
data.paymentMethod === "Boleto" && settled data.paymentMethod === "Boleto" && settled
? boletoPaymentDate ? boletoPaymentDate
@@ -623,6 +633,7 @@ export const buildLancamentoRecords = ({
const recurrenceDueDate = dueDate const recurrenceDueDate = dueDate
? addMonthsToDate(dueDate, index) ? addMonthsToDate(dueDate, index)
: null; : null;
const splitGroupId = cycleSplitGroupId();
shares.forEach((share) => { shares.forEach((share) => {
const settled = resolveSettledValue(index); const settled = resolveSettledValue(index);
@@ -635,6 +646,7 @@ export const buildLancamentoRecords = ({
isSettled: settled, isSettled: settled,
recurrenceCount: recurrenceTotal, recurrenceCount: recurrenceTotal,
dueDate: recurrenceDueDate, dueDate: recurrenceDueDate,
splitGroupId,
boletoPaymentDate: boletoPaymentDate:
data.paymentMethod === "Boleto" && settled data.paymentMethod === "Boleto" && settled
? boletoPaymentDate ? boletoPaymentDate
@@ -646,6 +658,8 @@ export const buildLancamentoRecords = ({
return records; return records;
} }
const splitGroupId = cycleSplitGroupId();
shares.forEach((share) => { shares.forEach((share) => {
const settled = resolveSettledValue(0); const settled = resolveSettledValue(0);
records.push({ records.push({
@@ -656,6 +670,7 @@ export const buildLancamentoRecords = ({
period, period,
isSettled: settled, isSettled: settled,
dueDate, dueDate,
splitGroupId,
boletoPaymentDate: boletoPaymentDate:
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null, data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
}); });

View File

@@ -33,6 +33,7 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
searchFilter: z.string().nullable(), searchFilter: z.string().nullable(),
settledFilter: z.string().nullable(), settledFilter: z.string().nullable(),
attachmentFilter: z.string().nullable(), attachmentFilter: z.string().nullable(),
dividedFilter: z.string().nullable(),
}), }),
accountId: z.string().min(1).nullable().optional(), accountId: z.string().min(1).nullable().optional(),
cardId: z.string().min(1).nullable().optional(), cardId: z.string().min(1).nullable().optional(),

View File

@@ -1,7 +1,7 @@
"use server"; "use server";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm"; import { and, eq, ne } from "drizzle-orm";
import { import {
attachments, attachments,
financialAccounts, financialAccounts,
@@ -21,6 +21,7 @@ import {
getBusinessTodayDate, getBusinessTodayDate,
parseLocalDateString, parseLocalDateString,
} from "@/shared/utils/date"; } from "@/shared/utils/date";
import { copyAttachmentsForImport } from "../attachment-copy";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments"; import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import { import {
buildLancamentoRecords, buildLancamentoRecords,
@@ -138,6 +139,14 @@ export async function createTransactionAction(
.values(records) .values(records)
.returning({ id: transactions.id }); .returning({ id: transactions.id });
if (data.importFromTransactionId && inserted.length > 0) {
await copyAttachmentsForImport({
sourceTransactionId: data.importFromTransactionId,
targetTransactionIds: inserted.map((r) => r.id),
targetUserId: user.id,
});
}
const notificationEntries = buildEntriesByPayer( const notificationEntries = buildEntriesByPayer(
records.map((record) => ({ records.map((record) => ({
payerId: record.payerId ?? null, payerId: record.payerId ?? null,
@@ -437,6 +446,134 @@ export async function deleteTransactionAction(
} }
} }
export async function updateTransactionSplitPairAction(
input: UpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateSchema.parse(input);
const ownershipError = await validateAllOwnership(user.id, {
payerId: data.payerId,
categoryId: data.categoryId,
accountId: data.accountId,
cardId: data.cardId,
});
if (ownershipError) {
return { success: false, error: ownershipError };
}
const existing = await db.query.transactions.findFirst({
columns: {
id: true,
period: true,
transactionType: true,
condition: true,
paymentMethod: true,
accountId: true,
cardId: true,
categoryId: true,
splitGroupId: true,
},
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
const period = resolvePeriod(data.purchaseDate, data.period);
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
const amountCents = Math.round(Math.abs(data.amount) * 100);
const normalizedAmount = centsToDecimalString(amountCents * amountSign);
const normalizedSettled =
data.paymentMethod === "Cartão de crédito"
? null
: (data.isSettled ?? false);
const shouldSetBoletoPaymentDate =
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
? data.boletoPaymentDate
? parseLocalDateString(data.boletoPaymentDate)
: getBusinessTodayDate()
: null;
const targetCardId = data.cardId ?? existing.cardId;
const movedInvoice =
data.paymentMethod === "Cartão de crédito" &&
targetCardId &&
(targetCardId !== existing.cardId || period !== existing.period);
if (movedInvoice) {
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
period,
]);
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`,
};
}
}
const purchaseDate = parseLocalDateString(data.purchaseDate);
const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null;
const sharedPayload = {
name: data.name,
purchaseDate,
transactionType: data.transactionType,
condition: data.condition,
paymentMethod: data.paymentMethod,
accountId: data.accountId ?? null,
cardId: data.cardId ?? null,
categoryId: data.categoryId ?? null,
note: data.note ?? null,
dueDate,
period,
isSettled: normalizedSettled,
boletoPaymentDate: boletoPaymentDateValue,
};
await db.transaction(async (tx: typeof db) => {
await tx
.update(transactions)
.set({
...sharedPayload,
amount: normalizedAmount,
payerId: data.payerId ?? null,
installmentCount: data.installmentCount ?? null,
recurrenceCount: data.recurrenceCount ?? null,
})
.where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
);
if (existing.splitGroupId) {
await tx
.update(transactions)
.set(sharedPayload)
.where(
and(
eq(transactions.splitGroupId, existing.splitGroupId),
eq(transactions.userId, user.id),
ne(transactions.id, data.id),
),
);
}
});
revalidate(user.id);
return { success: true, message: "Lançamentos atualizados com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function toggleTransactionSettlementAction( export async function toggleTransactionSettlementAction(
input: ToggleSettlementInput, input: ToggleSettlementInput,
): Promise<ActionResult> { ): Promise<ActionResult> {

View File

@@ -0,0 +1,107 @@
import { randomUUID } from "node:crypto";
import { CopyObjectCommand } from "@aws-sdk/client-s3";
import { eq } from "drizzle-orm";
import { attachments, transactionAttachments, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getPayerAccess } from "@/shared/lib/payers/access";
import { deleteS3Object } from "@/shared/lib/storage/presign";
import { S3_BUCKET, s3 } from "@/shared/lib/storage/s3-client";
const SAFE_EXTENSION = /^[a-z0-9]{1,10}$/i;
function sanitizeExtension(fileKey: string): string {
const ext = fileKey.split(".").pop() ?? "";
return SAFE_EXTENSION.test(ext) ? ext.toLowerCase() : "bin";
}
export async function copyAttachmentsForImport({
sourceTransactionId,
targetTransactionIds,
targetUserId,
}: {
sourceTransactionId: string;
targetTransactionIds: string[];
targetUserId: string;
}): Promise<void> {
if (targetTransactionIds.length === 0) return;
const [source] = await db
.select({
id: transactions.id,
userId: transactions.userId,
payerId: transactions.payerId,
})
.from(transactions)
.where(eq(transactions.id, sourceTransactionId));
if (!source) return;
if (source.userId !== targetUserId) {
if (!source.payerId) return;
const access = await getPayerAccess(targetUserId, source.payerId);
if (!access) return;
}
const sourceAttachments = await db
.select({
fileKey: attachments.fileKey,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
})
.from(transactionAttachments)
.innerJoin(
attachments,
eq(transactionAttachments.attachmentId, attachments.id),
)
.where(eq(transactionAttachments.transactionId, sourceTransactionId));
if (sourceAttachments.length === 0) return;
for (const src of sourceAttachments) {
const newFileKey = `${targetUserId}/${randomUUID()}.${sanitizeExtension(src.fileKey)}`;
try {
await s3.send(
new CopyObjectCommand({
Bucket: S3_BUCKET,
CopySource: `${S3_BUCKET}/${src.fileKey}`,
Key: newFileKey,
ContentType: src.mimeType,
MetadataDirective: "COPY",
}),
);
} catch (error) {
console.error("Falha ao copiar anexo no S3:", error);
continue;
}
try {
const [newAttachment] = await db
.insert(attachments)
.values({
userId: targetUserId,
fileKey: newFileKey,
fileName: src.fileName,
fileSize: src.fileSize,
mimeType: src.mimeType,
})
.returning({ id: attachments.id });
if (!newAttachment) {
await deleteS3Object(newFileKey);
continue;
}
await db.insert(transactionAttachments).values(
targetTransactionIds.map((tid) => ({
transactionId: tid,
attachmentId: newAttachment.id,
})),
);
} catch (error) {
console.error("Falha ao registrar anexo copiado:", error);
await deleteS3Object(newFileKey).catch(() => {});
}
}
}

View File

@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { attachments, transactionAttachments, transactions } from "@/db/schema"; import { attachments, transactionAttachments, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getPayerAccess } from "@/shared/lib/payers/access";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign"; import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
export type TransactionAttachmentListItem = { export type TransactionAttachmentListItem = {
@@ -17,16 +18,24 @@ export async function fetchTransactionAttachments(
transactionId: string, transactionId: string,
): Promise<TransactionAttachmentListItem[]> { ): Promise<TransactionAttachmentListItem[]> {
const [transaction] = await db const [transaction] = await db
.select({ id: transactions.id }) .select({
id: transactions.id,
userId: transactions.userId,
payerId: transactions.payerId,
})
.from(transactions) .from(transactions)
.where( .where(eq(transactions.id, transactionId));
and(eq(transactions.id, transactionId), eq(transactions.userId, userId)),
);
if (!transaction) { if (!transaction) {
return []; return [];
} }
if (transaction.userId !== userId) {
if (!transaction.payerId) return [];
const access = await getPayerAccess(userId, transaction.payerId);
if (!access) return [];
}
const rows = await db const rows = await db
.select({ .select({
attachmentId: transactionAttachments.attachmentId, attachmentId: transactionAttachments.attachmentId,
@@ -37,19 +46,9 @@ export async function fetchTransactionAttachments(
createdAt: attachments.createdAt, createdAt: attachments.createdAt,
}) })
.from(transactionAttachments) .from(transactionAttachments)
.innerJoin(
transactions,
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, userId),
),
)
.innerJoin( .innerJoin(
attachments, attachments,
and( eq(transactionAttachments.attachmentId, attachments.id),
eq(transactionAttachments.attachmentId, attachments.id),
eq(attachments.userId, userId),
),
) )
.where(eq(transactionAttachments.transactionId, transactionId)); .where(eq(transactionAttachments.transactionId, transactionId));

View File

@@ -240,7 +240,7 @@ export function AnticipateInstallmentsDialog({
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
<Field className="gap-1"> <Field className="gap-1">
<FieldLabel htmlFor="anticipation-period">Período</FieldLabel> <FieldLabel htmlFor="anticipation-period">Fatura</FieldLabel>
<FieldContent> <FieldContent>
<PeriodPicker <PeriodPicker
value={formState.anticipationPeriod} value={formState.anticipationPeriod}
@@ -292,7 +292,7 @@ export function AnticipateInstallmentsDialog({
<Field className="gap-1"> <Field className="gap-1">
<FieldLabel htmlFor="anticipation-categoria"> <FieldLabel htmlFor="anticipation-categoria">
Category Categoria
</FieldLabel> </FieldLabel>
<FieldContent> <FieldContent>
<Select <Select

View File

@@ -1,7 +1,5 @@
"use client"; "use client";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Checkbox } from "@/shared/components/ui/checkbox"; import { Checkbox } from "@/shared/components/ui/checkbox";
@@ -44,11 +42,6 @@ export function InstallmentSelectionTable({
} }
}; };
const formatDate = (date: Date | null) => {
if (!date) return "—";
return format(date, "dd/MM/yyyy", { locale: ptBR });
};
if (installments.length === 0) { if (installments.length === 0) {
return ( return (
<div className="rounded-lg border border-dashed p-8 text-center"> <div className="rounded-lg border border-dashed p-8 text-center">
@@ -63,11 +56,11 @@ export function InstallmentSelectionTable({
} }
return ( return (
<div className="overflow-hidden rounded-lg border"> <div className="overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-12"> <TableHead>
<Checkbox <Checkbox
checked={ checked={
selectedIds.length === installments.length && selectedIds.length === installments.length &&
@@ -77,9 +70,8 @@ export function InstallmentSelectionTable({
aria-label="Selecionar todas as parcelas" aria-label="Selecionar todas as parcelas"
/> />
</TableHead> </TableHead>
<TableHead>Parcela</TableHead> <TableHead>Estabelecimento</TableHead>
<TableHead>Período</TableHead> <TableHead>Fatura</TableHead>
<TableHead>Vencimento</TableHead>
<TableHead className="text-right">Valor</TableHead> <TableHead className="text-right">Valor</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -103,6 +95,7 @@ export function InstallmentSelectionTable({
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
{inst.name}{" "}
<Badge variant="outline"> <Badge variant="outline">
{formatCurrentInstallment( {formatCurrentInstallment(
inst.currentInstallment ?? 0, inst.currentInstallment ?? 0,
@@ -110,12 +103,11 @@ export function InstallmentSelectionTable({
)} )}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="font-medium"> <TableCell className="font-medium">
{formatShortPeriodLabel(inst.period)} {formatShortPeriodLabel(inst.period)}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">
{formatDate(inst.dueDate)}
</TableCell>
<TableCell className="text-right font-medium"> <TableCell className="text-right font-medium">
<MoneyValues amount={Number(inst.amount)} /> <MoneyValues amount={Number(inst.amount)} />
</TableCell> </TableCell>

View File

@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
export type SplitPairScope = "current" | "both";
type SplitPairDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (scope: SplitPairScope) => void;
};
export function SplitPairDialog({
open,
onOpenChange,
onConfirm,
}: SplitPairDialogProps) {
const [scope, setScope] = useState<SplitPairScope>("current");
const handleConfirm = () => {
onConfirm(scope);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Editar lançamento dividido</DialogTitle>
<DialogDescription>
Este lançamento está dividido com outra pessoa. Escolha o que deseja
editar:
</DialogDescription>
</DialogHeader>
<RadioGroup
value={scope}
onValueChange={(v) => setScope(v as SplitPairScope)}
>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<RadioGroupItem
value="current"
id="split-current"
className="mt-0.5"
/>
<div className="flex-1">
<Label
htmlFor="split-current"
className="text-sm cursor-pointer font-medium"
>
Apenas este lançamento
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração somente neste lado da divisão
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="both" id="split-both" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="split-both"
className="text-sm cursor-pointer font-medium"
>
Atualizar os dois lançamentos
</Label>
<p className="text-xs text-muted-foreground">
Aplica nome, data, categoria e outros campos compartilhados
nos dois lados da divisão
</p>
</div>
</div>
</div>
</RadioGroup>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancelar
</Button>
<Button type="button" onClick={handleConfirm}>
Confirmar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -49,6 +49,26 @@ export interface TransactionDialogProps {
pendingDetachIds: string[]; pendingDetachIds: string[];
pendingUploadFiles: File[]; pendingUploadFiles: File[];
}) => void; }) => void;
onSplitEditRequest?: (data: {
id: string;
purchaseDate: string;
period: string;
name: string;
transactionType: string;
amount: number;
condition: string;
paymentMethod: string;
categoryId: string | undefined;
note: string;
payerId: string | undefined;
accountId: string | undefined;
cardId: string | undefined;
isSettled: boolean | null;
dueDate: string | null;
boletoPaymentDate: string | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => void;
} }
export interface BaseFieldSectionProps { export interface BaseFieldSectionProps {

View File

@@ -78,6 +78,7 @@ export function TransactionDialog({
onSuccess, onSuccess,
maxSizeMb, maxSizeMb,
onBulkEditRequest, onBulkEditRequest,
onSplitEditRequest,
}: TransactionDialogProps) { }: TransactionDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState( const [dialogOpen, setDialogOpen] = useControlledState(
open, open,
@@ -321,6 +322,10 @@ export function TransactionDialog({
formState.boletoPaymentDate formState.boletoPaymentDate
? formState.boletoPaymentDate ? formState.boletoPaymentDate
: undefined, : undefined,
importFromTransactionId:
mode === "create" && isImporting && transaction?.id
? transaction.id
: undefined,
}; };
startTransition(async () => { startTransition(async () => {
@@ -365,6 +370,11 @@ export function TransactionDialog({
} }
const hasSeriesId = Boolean(transaction?.seriesId); const hasSeriesId = Boolean(transaction?.seriesId);
const hasSplitPair = Boolean(
transaction?.isDivided &&
transaction?.splitGroupId &&
!transaction?.seriesId,
);
if (hasSeriesId && onBulkEditRequest) { if (hasSeriesId && onBulkEditRequest) {
// Para lançamentos em série, passa os arquivos para a página confirmar // Para lançamentos em série, passa os arquivos para a página confirmar
@@ -398,6 +408,39 @@ export function TransactionDialog({
return; return;
} }
if (hasSplitPair && onSplitEditRequest) {
onSplitEditRequest({
id: transaction?.id ?? "",
purchaseDate: formState.purchaseDate,
period: formState.period,
name: formState.name.trim(),
transactionType: formState.transactionType,
amount: sanitizedAmount,
condition: formState.condition,
paymentMethod: formState.paymentMethod,
categoryId: formState.categoryId,
note: formState.note.trim() || "",
payerId: formState.payerId,
accountId: formState.accountId,
cardId: formState.cardId,
isSettled:
formState.paymentMethod === "Cartão de crédito"
? null
: Boolean(formState.isSettled),
dueDate:
formState.paymentMethod === "Boleto"
? formState.dueDate || null
: null,
boletoPaymentDate:
mode === "update" && formState.paymentMethod === "Boleto"
? formState.boletoPaymentDate || null
: null,
pendingDetachIds,
pendingUploadFiles,
});
return;
}
// Atualização normal para lançamentos únicos // Atualização normal para lançamentos únicos
const updatePayload: UpdateTransactionInput = { const updatePayload: UpdateTransactionInput = {
id: transaction?.id ?? "", id: transaction?.id ?? "",
@@ -609,6 +652,17 @@ export function TransactionDialog({
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
/> />
{isImportMode && transaction?.id && (
<div className="space-y-2">
<Label className="text-xs font-medium leading-none">
Anexos que serão copiados
</Label>
<AttachmentSection
transactionId={transaction.id}
readonly
/>
</div>
)}
<AttachmentFilePicker <AttachmentFilePicker
files={pendingFiles} files={pendingFiles}
onAdd={(file) => setPendingFiles((prev) => [...prev, file])} onAdd={(file) => setPendingFiles((prev) => [...prev, file])}

View File

@@ -8,7 +8,9 @@ import {
deleteTransactionAction, deleteTransactionAction,
deleteTransactionBulkAction, deleteTransactionBulkAction,
toggleTransactionSettlementAction, toggleTransactionSettlementAction,
updateTransactionAction,
updateTransactionBulkAction, updateTransactionBulkAction,
updateTransactionSplitPairAction,
} from "@/features/transactions/actions"; } from "@/features/transactions/actions";
import { import {
confirmAttachmentUploadAction, confirmAttachmentUploadAction,
@@ -31,6 +33,10 @@ import {
MassAddDialog, MassAddDialog,
type MassAddFormData, type MassAddFormData,
} from "../dialogs/mass-add-dialog"; } from "../dialogs/mass-add-dialog";
import {
SplitPairDialog,
type SplitPairScope,
} from "../dialogs/split-pair-dialog";
import { TransactionDetailsDialog } from "../dialogs/transaction-details-dialog"; import { TransactionDetailsDialog } from "../dialogs/transaction-details-dialog";
import { TransactionDialog } from "../dialogs/transaction-dialog/transaction-dialog"; import { TransactionDialog } from "../dialogs/transaction-dialog/transaction-dialog";
import { TransactionsTable } from "../table/transactions-table"; import { TransactionsTable } from "../table/transactions-table";
@@ -125,6 +131,26 @@ export function TransactionsPage({
); );
const [bulkEditOpen, setBulkEditOpen] = useState(false); const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [pendingSplitEditData, setPendingSplitEditData] = useState<{
id: string;
name: string;
purchaseDate: string;
period: string;
transactionType: string;
amount: number;
condition: string;
paymentMethod: string;
payerId: string | undefined;
accountId: string | undefined;
cardId: string | undefined;
categoryId: string | undefined;
note: string;
isSettled: boolean | null;
dueDate: string | null;
boletoPaymentDate: string | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
} | null>(null);
const [pendingEditData, setPendingEditData] = useState<{ const [pendingEditData, setPendingEditData] = useState<{
id: string; id: string;
purchaseDate: string; purchaseDate: string;
@@ -394,6 +420,90 @@ export function TransactionsPage({
setMassAddOpen(true); setMassAddOpen(true);
}; };
const handleSplitEditRequest = (
data: NonNullable<typeof pendingSplitEditData>,
) => {
setPendingSplitEditData(data);
setEditOpen(false);
};
const handleSplitEdit = async (scope: SplitPairScope) => {
if (!pendingSplitEditData) {
return;
}
const payload = {
id: pendingSplitEditData.id,
name: pendingSplitEditData.name,
purchaseDate: pendingSplitEditData.purchaseDate,
period: pendingSplitEditData.period,
transactionType: pendingSplitEditData.transactionType as Parameters<
typeof updateTransactionAction
>[0]["transactionType"],
amount: pendingSplitEditData.amount,
condition: pendingSplitEditData.condition as Parameters<
typeof updateTransactionAction
>[0]["condition"],
paymentMethod: pendingSplitEditData.paymentMethod as Parameters<
typeof updateTransactionAction
>[0]["paymentMethod"],
payerId: pendingSplitEditData.payerId ?? null,
accountId: pendingSplitEditData.accountId ?? null,
cardId: pendingSplitEditData.cardId ?? null,
categoryId: pendingSplitEditData.categoryId ?? null,
note: pendingSplitEditData.note,
isSettled: pendingSplitEditData.isSettled,
dueDate: pendingSplitEditData.dueDate ?? undefined,
boletoPaymentDate: pendingSplitEditData.boletoPaymentDate ?? undefined,
isSplit: false,
};
const action =
scope === "both"
? updateTransactionSplitPairAction
: updateTransactionAction;
const result = await action(payload);
if (!result.success) {
toast.error(result.error);
throw new Error(result.error);
}
await Promise.all(
pendingSplitEditData.pendingDetachIds.map((attachmentId) =>
detachAttachmentBulkAction({
attachmentId,
transactionId: pendingSplitEditData.id,
scope: "current",
}),
),
);
await Promise.all(
pendingSplitEditData.pendingUploadFiles.map(async (file) => {
const presign = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId: pendingSplitEditData.id,
});
if (!presign.success) return;
await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope: "current",
});
}),
);
toast.success(result.message);
setPendingSplitEditData(null);
};
const handleEdit = (item: TransactionItem) => { const handleEdit = (item: TransactionItem) => {
setSelectedTransaction(item); setSelectedTransaction(item);
setEditOpen(true); setEditOpen(true);
@@ -557,6 +667,7 @@ export function TransactionsPage({
transaction={selectedTransaction ?? undefined} transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
onBulkEditRequest={handleBulkEditRequest} onBulkEditRequest={handleBulkEditRequest}
onSplitEditRequest={handleSplitEditRequest}
maxSizeMb={attachmentMaxSizeMb} maxSizeMb={attachmentMaxSizeMb}
/> />
@@ -626,6 +737,14 @@ export function TransactionsPage({
onConfirm={handleBulkEdit} onConfirm={handleBulkEdit}
/> />
<SplitPairDialog
open={pendingSplitEditData !== null}
onOpenChange={(open) => {
if (!open) setPendingSplitEditData(null);
}}
onConfirm={handleSplitEdit}
/>
{allowCreate && massAddOpen ? ( {allowCreate && massAddOpen ? (
<MassAddDialog <MassAddDialog
open={massAddOpen} open={massAddOpen}

View File

@@ -228,9 +228,7 @@ function buildColumns({
className="text-muted-foreground" className="text-muted-foreground"
aria-hidden aria-hidden
/> />
<span className="sr-only"> <span className="sr-only">Dividido entre pessoas</span>
Dividido entre pessoas
</span>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">

View File

@@ -265,7 +265,8 @@ export function TransactionsFilters({
searchParams.get("category") || searchParams.get("category") ||
searchParams.get("accountCard") || searchParams.get("accountCard") ||
searchParams.get("settled") || searchParams.get("settled") ||
searchParams.get("hasAttachment"); searchParams.get("hasAttachment") ||
searchParams.get("isDivided");
const handleResetFilters = () => { const handleResetFilters = () => {
handleReset(); handleReset();
@@ -628,6 +629,23 @@ export function TransactionsFilters({
}} }}
/> />
</div> </div>
<div className="flex items-center justify-between">
<label
htmlFor="filter-is-divided"
className="text-sm font-medium cursor-pointer"
>
Somente divididos
</label>
<Switch
id="filter-is-divided"
checked={searchParams.get("isDivided") === "true"}
disabled={isPending}
onCheckedChange={(checked) => {
handleFilterChange("isDivided", checked ? "true" : null);
}}
/>
</div>
</div> </div>
<DrawerFooter> <DrawerFooter>

View File

@@ -33,6 +33,7 @@ export type TransactionItem = {
isAnticipated: boolean; isAnticipated: boolean;
anticipationId: string | null; anticipationId: string | null;
seriesId: string | null; seriesId: string | null;
splitGroupId: string | null;
hasAttachments: boolean; hasAttachments: boolean;
readonly?: boolean; readonly?: boolean;
}; };

View File

@@ -8,6 +8,7 @@ export type TransactionExportFilters = {
searchFilter: string | null; searchFilter: string | null;
settledFilter: string | null; settledFilter: string | null;
attachmentFilter: string | null; attachmentFilter: string | null;
dividedFilter: string | null;
}; };
export type TransactionsExportContext = { export type TransactionsExportContext = {

View File

@@ -45,6 +45,7 @@ export type TransactionSearchFilters = {
searchFilter: string | null; searchFilter: string | null;
settledFilter: string | null; settledFilter: string | null;
attachmentFilter: string | null; attachmentFilter: string | null;
dividedFilter: string | null;
}; };
type BaseSluggedOption = { type BaseSluggedOption = {
@@ -134,6 +135,7 @@ export const extractTransactionSearchFilters = (
searchFilter: getSingleParam(params, "q"), searchFilter: getSingleParam(params, "q"),
settledFilter: getSingleParam(params, "settled"), settledFilter: getSingleParam(params, "settled"),
attachmentFilter: getSingleParam(params, "hasAttachment"), attachmentFilter: getSingleParam(params, "hasAttachment"),
dividedFilter: getSingleParam(params, "isDivided"),
}); });
export const resolveTransactionPagination = ( export const resolveTransactionPagination = (
@@ -402,6 +404,10 @@ export const buildTransactionWhere = ({
); );
} }
if (filters.dividedFilter === "true") {
where.push(eq(transactions.isDivided, true));
}
const searchPattern = buildSearchPattern(filters.searchFilter); const searchPattern = buildSearchPattern(filters.searchFilter);
if (searchPattern) { if (searchPattern) {
where.push( where.push(
@@ -468,6 +474,7 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
isAnticipated: item.isAnticipated ?? false, isAnticipated: item.isAnticipated ?? false,
anticipationId: item.anticipationId ?? null, anticipationId: item.anticipationId ?? null,
seriesId: item.seriesId ?? null, seriesId: item.seriesId ?? null,
splitGroupId: item.splitGroupId ?? null,
hasAttachments: item.hasAttachments ?? false, hasAttachments: item.hasAttachments ?? false,
readonly: readonly:
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) || Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||

View File

@@ -101,7 +101,9 @@ export default async function proxy(request: NextRequest) {
} }
const response = NextResponse.next(); const response = NextResponse.next();
response.headers.set("Content-Security-Policy", buildCsp()); if (!pathname.startsWith("/api/")) {
response.headers.set("Content-Security-Policy", buildCsp());
}
return response; return response;
} }

View File

@@ -5,3 +5,4 @@ export type {
export { CategoryIconBadge } from "./category-icon-badge"; export { CategoryIconBadge } from "./category-icon-badge";
export { EstablishmentLogo } from "./establishment-logo"; export { EstablishmentLogo } from "./establishment-logo";
export { EstablishmentLogoPicker } from "./establishment-logo-picker"; export { EstablishmentLogoPicker } from "./establishment-logo-picker";
export { LogoPrefetchProvider } from "./logo-prefetch-provider";

View File

@@ -0,0 +1,36 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useRef } from "react";
import { logoQueryKeys } from "@/shared/lib/logo";
import type { LogoPrefetchEntry } from "@/shared/lib/logo/types";
type LogoPrefetchProviderProps = {
mappings: LogoPrefetchEntry[];
children: ReactNode;
};
/**
* Semeia o cache do React Query com mapeamentos de logo já resolvidos
* no servidor. Evita que cada `EstablishmentLogo` dispare seu próprio
* GET para `/api/logo/mapping` no primeiro render.
*/
export function LogoPrefetchProvider({
mappings,
children,
}: LogoPrefetchProviderProps) {
const queryClient = useQueryClient();
const seeded = useRef(false);
if (!seeded.current) {
for (const { nameKey, domain, logoUrl } of mappings) {
queryClient.setQueryData(logoQueryKeys.mapping(nameKey), {
domain,
logoUrl,
});
}
seeded.current = true;
}
return <>{children}</>;
}

View File

@@ -1,6 +1,11 @@
import {
type RemixiconComponentType,
RiArrowLeftRightLine,
RiArrowRightDownLine,
RiArrowRightUpLine,
} from "@remixicon/react";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
import StatusDot from "./status-dot";
type FinancialKind = type FinancialKind =
| "receita" | "receita"
@@ -26,29 +31,33 @@ type TransactionTypeBadgeProps = {
type BadgeConfig = { type BadgeConfig = {
label: string; label: string;
className: string; className: string;
dotClassName: string; Icon: RemixiconComponentType;
}; };
const BADGE_CONFIG: Record<FinancialKindKey, BadgeConfig> = { const BADGE_CONFIG: Record<FinancialKindKey, BadgeConfig> = {
receita: { receita: {
label: "Receita", label: "Receita",
className: "bg-success/10 text-success dark:bg-success/10", className:
dotClassName: "bg-success/80", "border-success/30 bg-success/5 text-success dark:saturate-90 dark:border-success/50 dark:bg-transparent",
Icon: RiArrowRightDownLine,
}, },
despesa: { despesa: {
label: "Despesa", label: "Despesa",
className: "bg-destructive/10 text-destructive dark:bg-destructive/10", className:
dotClassName: "bg-destructive/80", "border-destructive/30 bg-destructive/5 text-destructive dark:saturate-90 dark:border-destructive/50 dark:bg-transparent",
Icon: RiArrowRightUpLine,
}, },
transferência: { transferência: {
label: "Transferência", label: "Transf.",
className: "bg-info/10 text-info dark:bg-info/10", className:
dotClassName: "bg-info/80", "border-info/30 bg-info/5 text-info dark:saturate-90 dark:border-info/50 dark:bg-transparent",
Icon: RiArrowLeftRightLine,
}, },
"saldo inicial": { "saldo inicial": {
label: "Saldo Inicial", label: "Saldo Inicial",
className: "bg-success/10 text-success dark:bg-success/10", className:
dotClassName: "bg-success/80", "border-success/30 bg-success/5 text-success dark:saturate-90 dark:border-success/50 dark:bg-transparent",
Icon: RiArrowRightDownLine,
}, },
}; };
@@ -66,22 +75,20 @@ export function TransactionTypeBadge({
const normalizedKind = normalizeKind(kind); const normalizedKind = normalizeKind(kind);
const config = normalizedKind ? BADGE_CONFIG[normalizedKind] : null; const config = normalizedKind ? BADGE_CONFIG[normalizedKind] : null;
const label = config?.label ?? kind; const label = config?.label ?? kind;
const Icon = config?.Icon;
return ( return (
<Badge <Badge
variant="outline" variant="outline"
data-kind={normalizedKind ?? "custom"} data-kind={normalizedKind ?? "custom"}
className={cn( className={cn(
"h-6 gap-1 border-none rounded-md px-2 py-0 text-xs shadow-none", "h-6 gap-1 rounded-sm border px-2 py-0 text-xs font-medium shadow-xs",
config?.className ?? config?.className ??
"bg-muted/30 text-muted-foreground dark:bg-muted/20", "border-muted-foreground/30 bg-muted/20 text-muted-foreground dark:bg-transparent",
className, className,
)} )}
> >
<StatusDot {Icon ? <Icon className="size-3.5" /> : null}
color={config?.dotClassName ?? "bg-muted-foreground/60"}
className="size-1.5"
/>
<span>{label}</span> <span>{label}</span>
</Badge> </Badge>
); );

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { RiCircleLine } from "@remixicon/react"; import { RiCheckLine } from "@remixicon/react";
import type * as React from "react"; import type * as React from "react";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
@@ -26,16 +26,16 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
data-slot="radio-group-item" data-slot="radio-group-item"
className={cn( className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
> >
<RadioGroupPrimitive.Indicator <RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator" data-slot="radio-group-indicator"
className="relative flex items-center justify-center" className="grid place-content-center text-current transition-none"
> >
<RiCircleLine className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" /> <RiCheckLine className="size-3.5" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
); );

View File

@@ -0,0 +1,31 @@
import "server-only";
import { fetchEstablishmentLogoMap } from "./establishment-logo-queries";
import { toNameKey } from "./index";
import { buildLogoDevUrl } from "./server";
import type { LogoPrefetchEntry } from "./types";
export async function prefetchLogoMappings(
userId: string,
names: string[],
): Promise<LogoPrefetchEntry[]> {
const uniqueNames = [
...new Set(
names.filter((n) => typeof n === "string" && n.trim().length > 0),
),
];
if (uniqueNames.length === 0) return [];
const map = await fetchEstablishmentLogoMap(userId, uniqueNames);
const seen = new Set<string>();
const entries: LogoPrefetchEntry[] = [];
for (const name of uniqueNames) {
const nameKey = toNameKey(name);
if (seen.has(nameKey)) continue;
seen.add(nameKey);
const domain = map.get(nameKey) ?? null;
entries.push({ nameKey, domain, logoUrl: buildLogoDevUrl(domain) });
}
return entries;
}

View File

@@ -0,0 +1,5 @@
export type LogoPrefetchEntry = {
nameKey: string;
domain: string | null;
logoUrl: string | null;
};

View File

@@ -48,5 +48,16 @@ export async function deleteS3Object(fileKey: string): Promise<void> {
Bucket: S3_BUCKET, Bucket: S3_BUCKET,
Key: fileKey, Key: fileKey,
}); });
await s3.send(command); try {
await s3.send(command);
} catch (err) {
if (
err instanceof Error &&
"Code" in err &&
(err as { Code: string }).Code === "NoSuchKey"
) {
return;
}
throw err;
}
} }