mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
791fec7751 | ||
|
|
114e2b4011 | ||
|
|
f15a003cef | ||
|
|
7f07a9cbf6 | ||
|
|
5fa234884e | ||
|
|
b453b432ed | ||
|
|
7f05d2a681 | ||
|
|
b14f487824 | ||
|
|
5b03824a72 | ||
|
|
74dda549f5 | ||
|
|
137b63f256 |
37
CHANGELOG.md
37
CHANGELOG.md
@@ -7,6 +7,43 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [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
|
||||
|
||||
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.
|
||||
|
||||
@@ -109,7 +109,7 @@ USER nextjs
|
||||
|
||||
# Health check
|
||||
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 ["/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
@@ -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": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
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
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
2
drizzle/0026_bored_eternity.sql
Normal file
2
drizzle/0026_bored_eternity.sql
Normal 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");
|
||||
2916
drizzle/meta/0026_snapshot.json
Normal file
2916
drizzle/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,13 @@
|
||||
"when": 1776351838548,
|
||||
"tag": "0025_burly_colonel_america",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1777042423451,
|
||||
"tag": "0026_bored_eternity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
22
package.json
22
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
@@ -35,9 +35,9 @@
|
||||
"@ai-sdk/anthropic": "^3.0.71",
|
||||
"@ai-sdk/google": "^3.0.64",
|
||||
"@ai-sdk/openai": "^3.0.53",
|
||||
"@aws-sdk/client-s3": "^3.1032.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1032.0",
|
||||
"@better-auth/passkey": "^1.6.5",
|
||||
"@aws-sdk/client-s3": "^3.1037.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1037.0",
|
||||
"@better-auth/passkey": "^1.6.9",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -64,11 +64,11 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@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-virtual": "^3.13.24",
|
||||
"ai": "^6.0.168",
|
||||
"better-auth": "1.6.5",
|
||||
"better-auth": "1.6.9",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -86,7 +86,7 @@
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.5",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "^6.12.0",
|
||||
"resend": "^6.12.2",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"vaul": "1.1.2",
|
||||
@@ -98,8 +98,8 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.12",
|
||||
"@tailwindcss/postcss": "4.2.2",
|
||||
"@biomejs/biome": "2.4.13",
|
||||
"@tailwindcss/postcss": "4.2.4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
@@ -107,8 +107,8 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"knip": "^6.4.1",
|
||||
"tailwindcss": "4.2.2",
|
||||
"knip": "^6.7.0",
|
||||
"tailwindcss": "4.2.4",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
|
||||
1217
pnpm-lock.yaml
generated
1217
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,5 +5,6 @@ export const inter = Inter({
|
||||
display: "swap",
|
||||
variable: "--font-inter",
|
||||
fallback: ["ui-sans-serif", "system-ui"],
|
||||
weight: ["500", "600", "700"],
|
||||
preload: true,
|
||||
});
|
||||
|
||||
@@ -2,10 +2,13 @@ import { connection } from "next/server";
|
||||
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
||||
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
||||
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 { getSingleParam } from "@/features/transactions/page-helpers";
|
||||
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
@@ -25,17 +28,24 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
await fetchDashboardPageData(user.id, selectedPeriod);
|
||||
const { dashboardWidgets } = preferences;
|
||||
|
||||
const logoMappings = await prefetchLogoMappings(
|
||||
user.id,
|
||||
extractDashboardLogoNames(dashboardData),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4">
|
||||
<DashboardWelcome name={user.name} />
|
||||
<MonthNavigation />
|
||||
<DashboardMetricsCards metrics={dashboardData.metrics} />
|
||||
<DashboardGridEditable
|
||||
data={dashboardData}
|
||||
period={selectedPeriod}
|
||||
initialPreferences={dashboardWidgets}
|
||||
quickActionOptions={quickActionOptions}
|
||||
/>
|
||||
<LogoPrefetchProvider mappings={logoMappings}>
|
||||
<DashboardGridEditable
|
||||
data={dashboardData}
|
||||
period={selectedPeriod}
|
||||
initialPreferences={dashboardWidgets}
|
||||
quickActionOptions={quickActionOptions}
|
||||
/>
|
||||
</LogoPrefetchProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
fetchRecentEstablishments,
|
||||
fetchTransactionFilterSources,
|
||||
} from "@/features/transactions/queries";
|
||||
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import {
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||
import { getPayerAccess } from "@/shared/lib/payers/access";
|
||||
import {
|
||||
fetchPagadorBoletoItems,
|
||||
@@ -82,6 +84,7 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
||||
searchFilter: null,
|
||||
settledFilter: null,
|
||||
attachmentFilter: null,
|
||||
dividedFilter: null,
|
||||
};
|
||||
|
||||
const createEmptySlugMaps = (): SlugMaps => ({
|
||||
@@ -307,104 +310,113 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
lancamentoCount: transactionData.length,
|
||||
};
|
||||
|
||||
const logoMappings = await prefetchLogoMappings(dataOwnerId, [
|
||||
...transactionData.map((t) => t.name),
|
||||
...boletoItems.map((b) => b.name),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthNavigation />
|
||||
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="profile">Perfil</TabsTrigger>
|
||||
<TabsTrigger value="painel">Painel</TabsTrigger>
|
||||
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
||||
</TabsList>
|
||||
<PayerHeaderCard
|
||||
payer={payerData}
|
||||
selectedPeriod={selectedPeriod}
|
||||
summary={summaryPreview}
|
||||
/>
|
||||
<LogoPrefetchProvider mappings={logoMappings}>
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="profile">Perfil</TabsTrigger>
|
||||
<TabsTrigger value="painel">Painel</TabsTrigger>
|
||||
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
||||
</TabsList>
|
||||
<PayerHeaderCard
|
||||
payer={payerData}
|
||||
selectedPeriod={selectedPeriod}
|
||||
summary={summaryPreview}
|
||||
/>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<PagadorInfoCard payer={payerData} />
|
||||
{canEdit && payerData.shareCode ? (
|
||||
<PayerSharingCard
|
||||
payerId={pagador.id}
|
||||
shareCode={payerData.shareCode}
|
||||
shares={payerSharesData}
|
||||
/>
|
||||
) : null}
|
||||
{!canEdit && currentUserShare ? (
|
||||
<PayerLeaveShareCard
|
||||
shareId={currentUserShare.id}
|
||||
pagadorName={payerData.name}
|
||||
createdAt={currentUserShare.createdAt}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<PagadorInfoCard payer={payerData} />
|
||||
{canEdit && payerData.shareCode ? (
|
||||
<PayerSharingCard
|
||||
payerId={pagador.id}
|
||||
shareCode={payerData.shareCode}
|
||||
shares={payerSharesData}
|
||||
/>
|
||||
) : null}
|
||||
{!canEdit && currentUserShare ? (
|
||||
<PayerLeaveShareCard
|
||||
shareId={currentUserShare.id}
|
||||
pagadorName={payerData.name}
|
||||
createdAt={currentUserShare.createdAt}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="painel" className="space-y-4">
|
||||
<section className="grid gap-3 lg:grid-cols-2">
|
||||
<PayerMonthlySummaryCard
|
||||
periodLabel={periodLabel}
|
||||
breakdown={monthlyBreakdown}
|
||||
/>
|
||||
<PayerHistoryCard data={historyData} />
|
||||
</section>
|
||||
<TabsContent value="painel" className="space-y-4">
|
||||
<section className="grid gap-3 lg:grid-cols-2">
|
||||
<PayerMonthlySummaryCard
|
||||
periodLabel={periodLabel}
|
||||
breakdown={monthlyBreakdown}
|
||||
/>
|
||||
<PayerHistoryCard data={historyData} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 lg:grid-cols-3">
|
||||
<ExpandableWidgetCard
|
||||
title="Minhas Faturas"
|
||||
subtitle="Valores por cartão neste período"
|
||||
icon={<RiBankCard2Line className="size-4" />}
|
||||
>
|
||||
<PayerCardUsageCard items={cardUsage} />
|
||||
</ExpandableWidgetCard>
|
||||
<ExpandableWidgetCard
|
||||
title="Boletos"
|
||||
subtitle="Boletos registrados neste período"
|
||||
icon={<RiBarcodeLine className="size-4" />}
|
||||
>
|
||||
<PayerBoletoCard items={boletoItems} />
|
||||
</ExpandableWidgetCard>
|
||||
<ExpandableWidgetCard
|
||||
title="Status de Pagamento"
|
||||
subtitle="Situação das despesas no período"
|
||||
icon={<RiWallet3Line className="size-4" />}
|
||||
>
|
||||
<PayerPaymentStatusCard data={paymentStatus} />
|
||||
</ExpandableWidgetCard>
|
||||
</section>
|
||||
</TabsContent>
|
||||
<section className="grid gap-3 lg:grid-cols-3">
|
||||
<ExpandableWidgetCard
|
||||
title="Minhas Faturas"
|
||||
subtitle="Valores por cartão neste período"
|
||||
icon={<RiBankCard2Line className="size-4" />}
|
||||
>
|
||||
<PayerCardUsageCard items={cardUsage} />
|
||||
</ExpandableWidgetCard>
|
||||
<ExpandableWidgetCard
|
||||
title="Boletos"
|
||||
subtitle="Boletos registrados neste período"
|
||||
icon={<RiBarcodeLine className="size-4" />}
|
||||
>
|
||||
<PayerBoletoCard items={boletoItems} />
|
||||
</ExpandableWidgetCard>
|
||||
<ExpandableWidgetCard
|
||||
title="Status de Pagamento"
|
||||
subtitle="Situação das despesas no período"
|
||||
icon={<RiWallet3Line className="size-4" />}
|
||||
>
|
||||
<PayerPaymentStatusCard data={paymentStatus} />
|
||||
</ExpandableWidgetCard>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="lancamentos">
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
currentUserId={userId}
|
||||
transactions={transactionData}
|
||||
payerOptions={optionSets.payerOptions}
|
||||
splitPayerOptions={optionSets.splitPayerOptions}
|
||||
defaultPayerId={pagador.id}
|
||||
accountOptions={optionSets.accountOptions}
|
||||
cardOptions={optionSets.cardOptions}
|
||||
categoryOptions={optionSets.categoryOptions}
|
||||
payerFilterOptions={payerFilterOptions}
|
||||
categoryFilterOptions={optionSets.categoryFilterOptions}
|
||||
accountCardFilterOptions={optionSets.accountCardFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={canEdit}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
||||
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
|
||||
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
||||
importAccountOptions={loggedUserOptionSets?.accountOptions}
|
||||
importCardOptions={loggedUserOptionSets?.cardOptions}
|
||||
importCategoryOptions={loggedUserOptionSets?.categoryOptions}
|
||||
/>
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="lancamentos">
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
currentUserId={userId}
|
||||
transactions={transactionData}
|
||||
payerOptions={optionSets.payerOptions}
|
||||
splitPayerOptions={optionSets.splitPayerOptions}
|
||||
defaultPayerId={pagador.id}
|
||||
accountOptions={optionSets.accountOptions}
|
||||
cardOptions={optionSets.cardOptions}
|
||||
categoryOptions={optionSets.categoryOptions}
|
||||
payerFilterOptions={payerFilterOptions}
|
||||
categoryFilterOptions={optionSets.categoryFilterOptions}
|
||||
accountCardFilterOptions={optionSets.accountCardFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={canEdit}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
||||
importSplitPayerOptions={
|
||||
loggedUserOptionSets?.splitPayerOptions
|
||||
}
|
||||
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
||||
importAccountOptions={loggedUserOptionSets?.accountOptions}
|
||||
importCardOptions={loggedUserOptionSets?.cardOptions}
|
||||
importCategoryOptions={loggedUserOptionSets?.categoryOptions}
|
||||
/>
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</LogoPrefetchProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
fetchTransactionFilterSources,
|
||||
fetchTransactionsPage,
|
||||
} from "@/features/transactions/queries";
|
||||
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
@@ -74,38 +76,45 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
payerRows: filterSources.payerRows,
|
||||
});
|
||||
|
||||
const logoMappings = await prefetchLogoMappings(
|
||||
userId,
|
||||
transactionData.map((t) => t.name),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthNavigation />
|
||||
<TransactionsPage
|
||||
currentUserId={userId}
|
||||
transactions={transactionData}
|
||||
payerOptions={payerOptions}
|
||||
splitPayerOptions={splitPayerOptions}
|
||||
defaultPayerId={defaultPayerId}
|
||||
accountOptions={accountOptions}
|
||||
cardOptions={cardOptions}
|
||||
categoryOptions={categoryOptions}
|
||||
payerFilterOptions={payerFilterOptions}
|
||||
categoryFilterOptions={categoryFilterOptions}
|
||||
accountCardFilterOptions={accountCardFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
pagination={{
|
||||
page: transactionsPage.page,
|
||||
pageSize: transactionsPage.pageSize,
|
||||
totalItems: transactionsPage.totalItems,
|
||||
totalPages: transactionsPage.totalPages,
|
||||
}}
|
||||
exportContext={{
|
||||
source: "transactions",
|
||||
period: selectedPeriod,
|
||||
filters: searchFilters,
|
||||
}}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
<LogoPrefetchProvider mappings={logoMappings}>
|
||||
<TransactionsPage
|
||||
currentUserId={userId}
|
||||
transactions={transactionData}
|
||||
payerOptions={payerOptions}
|
||||
splitPayerOptions={splitPayerOptions}
|
||||
defaultPayerId={defaultPayerId}
|
||||
accountOptions={accountOptions}
|
||||
cardOptions={cardOptions}
|
||||
categoryOptions={categoryOptions}
|
||||
payerFilterOptions={payerFilterOptions}
|
||||
categoryFilterOptions={categoryFilterOptions}
|
||||
accountCardFilterOptions={accountCardFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
pagination={{
|
||||
page: transactionsPage.page,
|
||||
pageSize: transactionsPage.pageSize,
|
||||
totalItems: transactionsPage.totalItems,
|
||||
totalPages: transactionsPage.totalPages,
|
||||
}}
|
||||
exportContext={{
|
||||
source: "transactions",
|
||||
period: selectedPeriod,
|
||||
filters: searchFilters,
|
||||
}}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
</LogoPrefetchProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,6 +670,7 @@ export const transactions = pgTable(
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
seriesId: uuid("series_id"),
|
||||
splitGroupId: uuid("split_group_id"),
|
||||
transferId: uuid("transfer_id"),
|
||||
ofxFitId: text("ofx_fit_id"),
|
||||
importBatchId: text("import_batch_id"),
|
||||
@@ -702,6 +703,11 @@ export const transactions = pgTable(
|
||||
),
|
||||
// Índice para buscar parcelas de uma série
|
||||
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
|
||||
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
|
||||
// Índice para filtrar por condição (aberto, realizado, cancelado)
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function updateCategoryAction(
|
||||
|
||||
revalidateForEntity("categories", user.id);
|
||||
|
||||
return { success: true, message: "Category atualizada com sucesso." };
|
||||
return { success: true, message: "Categoria atualizada com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
|
||||
@@ -51,9 +51,7 @@ type UniqueCategory = {
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
async function fetchAllCategories(
|
||||
userId: string,
|
||||
): Promise<CategoryOption[]> {
|
||||
async function fetchAllCategories(userId: string): Promise<CategoryOption[]> {
|
||||
const result = await db
|
||||
.select({
|
||||
id: categories.id,
|
||||
|
||||
@@ -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 = {
|
||||
name?: string | null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import type { PaymentOverviewTab } from "@/features/dashboard/payments/payment-overview-tabs";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
|
||||
28
src/features/dashboard/extract-logo-names.ts
Normal file
28
src/features/dashboard/extract-logo-names.ts
Normal 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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
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 { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
|
||||
import type {
|
||||
@@ -15,7 +16,6 @@ import type {
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-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 { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
|
||||
import {
|
||||
|
||||
@@ -151,7 +151,9 @@ export const InboxCard = memo(function InboxCard({
|
||||
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden py-2">
|
||||
{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">
|
||||
{item.originalText}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { and, desc, eq, type SQL } from "drizzle-orm";
|
||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||
import {
|
||||
cards,
|
||||
categories,
|
||||
financialAccounts,
|
||||
payerShares,
|
||||
payers,
|
||||
transactionAttachments,
|
||||
transactions,
|
||||
user as usersTable,
|
||||
} from "@/db/schema";
|
||||
@@ -73,6 +74,10 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
|
||||
financialAccount: financialAccounts,
|
||||
card: cards,
|
||||
category: categories,
|
||||
hasAttachments: sql<boolean>`EXISTS (
|
||||
SELECT 1 FROM ${transactionAttachments}
|
||||
WHERE ${transactionAttachments.transactionId} = ${transactions.id}
|
||||
)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(payers, eq(transactions.payerId, payers.id))
|
||||
@@ -85,12 +90,12 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
|
||||
.where(and(...filters))
|
||||
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
|
||||
|
||||
// Transformar resultado para o formato esperado
|
||||
return transactionRows.map((row) => ({
|
||||
...row.transaction,
|
||||
payer: row.payer,
|
||||
financialAccount: row.financialAccount,
|
||||
card: row.card,
|
||||
category: row.category,
|
||||
hasAttachments: row.hasAttachments,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/shared/lib/payers/constants";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
||||
import { deleteS3Object } from "@/shared/lib/storage/presign";
|
||||
|
||||
type ActionResponse<T = void> = {
|
||||
success: boolean;
|
||||
@@ -85,6 +86,11 @@ async function resetUserAppData(
|
||||
const avatarUrl = user.image ?? DEFAULT_PAYER_AVATAR;
|
||||
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 tx
|
||||
.delete(schema.payerShares)
|
||||
@@ -115,6 +121,9 @@ async function resetUserAppData(
|
||||
await tx
|
||||
.delete(schema.transactions)
|
||||
.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.cards).where(eq(schema.cards.userId, userId));
|
||||
await tx
|
||||
@@ -147,6 +156,14 @@ async function resetUserAppData(
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
userAttachments.map((att) =>
|
||||
deleteS3Object(att.fileKey).catch((err) => {
|
||||
console.error("Falha ao remover anexo do S3 no reset:", err);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
@@ -99,8 +99,7 @@ export function DeleteAccountForm() {
|
||||
Preferências do app, insights salvos e tokens do Companion
|
||||
</li>
|
||||
<li className="font-medium text-foreground">
|
||||
Categorias padrão e pessoa admin serão recriadas
|
||||
automaticamente
|
||||
Categorias padrão e pessoa admin serão recriadas automaticamente
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
deleteTransactionAction as deleteTransactionActionImpl,
|
||||
toggleTransactionSettlementAction as toggleTransactionSettlementActionImpl,
|
||||
updateTransactionAction as updateTransactionActionImpl,
|
||||
updateTransactionSplitPairAction as updateTransactionSplitPairActionImpl,
|
||||
} from "./actions/single-actions";
|
||||
|
||||
export async function createTransactionAction(
|
||||
@@ -62,6 +63,12 @@ export async function deleteMultipleTransactionsAction(
|
||||
return deleteMultipleTransactionsActionImpl(...args);
|
||||
}
|
||||
|
||||
export async function updateTransactionSplitPairAction(
|
||||
...args: Parameters<typeof updateTransactionSplitPairActionImpl>
|
||||
): ReturnType<typeof updateTransactionSplitPairActionImpl> {
|
||||
return updateTransactionSplitPairActionImpl(...args);
|
||||
}
|
||||
|
||||
export async function exportTransactionsDataAction(
|
||||
...args: Parameters<typeof exportTransactionsDataActionImpl>
|
||||
): ReturnType<typeof exportTransactionsDataActionImpl> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
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 { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||
import {
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import {
|
||||
createPresignedGetUrl,
|
||||
createPresignedPutUrl,
|
||||
deleteS3Object,
|
||||
headS3Object,
|
||||
@@ -98,6 +97,46 @@ function signUploadToken(payload: UploadTokenPayload): string {
|
||||
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 {
|
||||
try {
|
||||
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(
|
||||
transactionIds.map((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({
|
||||
attachmentId: 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) {
|
||||
await db
|
||||
.delete(transactionAttachments)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||
import {
|
||||
PAYMENT_METHODS,
|
||||
TRANSACTION_CONDITIONS,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
||||
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
|
||||
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||
import {
|
||||
centsToDecimalString,
|
||||
type DeleteBulkInput,
|
||||
@@ -78,71 +79,64 @@ export async function deleteTransactionBulkAction(
|
||||
};
|
||||
}
|
||||
|
||||
let scopeFilter: ReturnType<typeof and>;
|
||||
let successMessage: string;
|
||||
|
||||
if (data.scope === "current") {
|
||||
await db
|
||||
.delete(transactions)
|
||||
.where(
|
||||
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||
);
|
||||
|
||||
revalidate(user.id);
|
||||
return { success: true, message: "Lançamento removido com sucesso." };
|
||||
scopeFilter = eq(transactions.id, data.id);
|
||||
successMessage = "Lançamento removido com sucesso.";
|
||||
} else if (data.scope === "period") {
|
||||
scopeFilter = and(
|
||||
eq(transactions.seriesId, existing.seriesId),
|
||||
eq(transactions.period, existing.period ?? ""),
|
||||
);
|
||||
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") {
|
||||
await db
|
||||
.delete(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.seriesId, existing.seriesId),
|
||||
eq(transactions.userId, user.id),
|
||||
eq(transactions.period, existing.period ?? ""),
|
||||
),
|
||||
);
|
||||
const targetRows = await db
|
||||
.select({ id: transactions.id })
|
||||
.from(transactions)
|
||||
.where(and(scopeFilter, eq(transactions.userId, user.id)));
|
||||
|
||||
revalidate(user.id);
|
||||
return {
|
||||
success: true,
|
||||
message: "Todos os lançamentos do período foram removidos.",
|
||||
};
|
||||
const targetIds = targetRows.map((r) => r.id);
|
||||
|
||||
if (targetIds.length === 0) {
|
||||
return { success: false, error: "Nenhum lançamento encontrado." };
|
||||
}
|
||||
|
||||
if (data.scope === "future") {
|
||||
await db
|
||||
.delete(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.seriesId, existing.seriesId),
|
||||
eq(transactions.userId, user.id),
|
||||
sql`${transactions.period} >= ${existing.period}`,
|
||||
),
|
||||
);
|
||||
const linkedAttachments = await db
|
||||
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||
.from(transactionAttachments)
|
||||
.innerJoin(
|
||||
attachments,
|
||||
eq(transactionAttachments.attachmentId, attachments.id),
|
||||
)
|
||||
.where(inArray(transactionAttachments.transactionId, targetIds));
|
||||
|
||||
revalidate(user.id);
|
||||
return {
|
||||
success: true,
|
||||
message: "Lançamentos removidos com sucesso.",
|
||||
};
|
||||
}
|
||||
await db
|
||||
.delete(transactions)
|
||||
.where(
|
||||
and(
|
||||
inArray(transactions.id, targetIds),
|
||||
eq(transactions.userId, user.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (data.scope === "all") {
|
||||
await db
|
||||
.delete(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.seriesId, existing.seriesId),
|
||||
eq(transactions.userId, user.id),
|
||||
),
|
||||
);
|
||||
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
|
||||
|
||||
revalidate(user.id);
|
||||
return {
|
||||
success: true,
|
||||
message: "Todos os lançamentos da série foram removidos.",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: "Escopo de ação inválido." };
|
||||
revalidate(user.id);
|
||||
return { success: true, message: successMessage };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
@@ -759,6 +753,15 @@ export async function deleteMultipleTransactionsAction(
|
||||
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
|
||||
.delete(transactions)
|
||||
.where(
|
||||
@@ -768,6 +771,8 @@ export async function deleteMultipleTransactionsAction(
|
||||
),
|
||||
);
|
||||
|
||||
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
|
||||
|
||||
const notificationData = existing
|
||||
.filter(
|
||||
(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
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
|
||||
.extend({
|
||||
id: uuidSchema("Lançamento"),
|
||||
@@ -544,6 +549,7 @@ export const buildLancamentoRecords = ({
|
||||
seriesId,
|
||||
}: BuildTransactionRecordsParams): TransactionInsert[] => {
|
||||
const records: TransactionInsert[] = [];
|
||||
const isSplit = (data.isSplit ?? false) && shares.length > 1;
|
||||
|
||||
const basePayload = {
|
||||
name: data.name,
|
||||
@@ -562,6 +568,8 @@ export const buildLancamentoRecords = ({
|
||||
seriesId,
|
||||
};
|
||||
|
||||
const cycleSplitGroupId = () => (isSplit ? randomUUID() : null);
|
||||
|
||||
const resolveSettledValue = (cycleIndex: number) => {
|
||||
if (shouldNullifySettled) {
|
||||
return null;
|
||||
@@ -588,6 +596,7 @@ export const buildLancamentoRecords = ({
|
||||
const installmentDueDate = dueDate
|
||||
? addMonthsToDate(dueDate, installment)
|
||||
: null;
|
||||
const splitGroupId = cycleSplitGroupId();
|
||||
|
||||
shares.forEach((share, shareIndex) => {
|
||||
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
||||
@@ -603,6 +612,7 @@ export const buildLancamentoRecords = ({
|
||||
currentInstallment: installment + 1,
|
||||
recurrenceCount: null,
|
||||
dueDate: installmentDueDate,
|
||||
splitGroupId,
|
||||
boletoPaymentDate:
|
||||
data.paymentMethod === "Boleto" && settled
|
||||
? boletoPaymentDate
|
||||
@@ -623,6 +633,7 @@ export const buildLancamentoRecords = ({
|
||||
const recurrenceDueDate = dueDate
|
||||
? addMonthsToDate(dueDate, index)
|
||||
: null;
|
||||
const splitGroupId = cycleSplitGroupId();
|
||||
|
||||
shares.forEach((share) => {
|
||||
const settled = resolveSettledValue(index);
|
||||
@@ -635,6 +646,7 @@ export const buildLancamentoRecords = ({
|
||||
isSettled: settled,
|
||||
recurrenceCount: recurrenceTotal,
|
||||
dueDate: recurrenceDueDate,
|
||||
splitGroupId,
|
||||
boletoPaymentDate:
|
||||
data.paymentMethod === "Boleto" && settled
|
||||
? boletoPaymentDate
|
||||
@@ -646,6 +658,8 @@ export const buildLancamentoRecords = ({
|
||||
return records;
|
||||
}
|
||||
|
||||
const splitGroupId = cycleSplitGroupId();
|
||||
|
||||
shares.forEach((share) => {
|
||||
const settled = resolveSettledValue(0);
|
||||
records.push({
|
||||
@@ -656,6 +670,7 @@ export const buildLancamentoRecords = ({
|
||||
period,
|
||||
isSettled: settled,
|
||||
dueDate,
|
||||
splitGroupId,
|
||||
boletoPaymentDate:
|
||||
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
||||
searchFilter: z.string().nullable(),
|
||||
settledFilter: z.string().nullable(),
|
||||
attachmentFilter: z.string().nullable(),
|
||||
dividedFilter: z.string().nullable(),
|
||||
}),
|
||||
accountId: z.string().min(1).nullable().optional(),
|
||||
cardId: z.string().min(1).nullable().optional(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import {
|
||||
attachments,
|
||||
financialAccounts,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getBusinessTodayDate,
|
||||
parseLocalDateString,
|
||||
} from "@/shared/utils/date";
|
||||
import { copyAttachmentsForImport } from "../attachment-copy";
|
||||
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||
import {
|
||||
buildLancamentoRecords,
|
||||
@@ -138,6 +139,14 @@ export async function createTransactionAction(
|
||||
.values(records)
|
||||
.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(
|
||||
records.map((record) => ({
|
||||
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(
|
||||
input: ToggleSettlementInput,
|
||||
): Promise<ActionResult> {
|
||||
|
||||
107
src/features/transactions/attachment-copy.ts
Normal file
107
src/features/transactions/attachment-copy.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
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 { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||
|
||||
export type TransactionAttachmentListItem = {
|
||||
@@ -17,16 +18,24 @@ export async function fetchTransactionAttachments(
|
||||
transactionId: string,
|
||||
): Promise<TransactionAttachmentListItem[]> {
|
||||
const [transaction] = await db
|
||||
.select({ id: transactions.id })
|
||||
.select({
|
||||
id: transactions.id,
|
||||
userId: transactions.userId,
|
||||
payerId: transactions.payerId,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(eq(transactions.id, transactionId), eq(transactions.userId, userId)),
|
||||
);
|
||||
.where(eq(transactions.id, transactionId));
|
||||
|
||||
if (!transaction) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (transaction.userId !== userId) {
|
||||
if (!transaction.payerId) return [];
|
||||
const access = await getPayerAccess(userId, transaction.payerId);
|
||||
if (!access) return [];
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
attachmentId: transactionAttachments.attachmentId,
|
||||
@@ -37,19 +46,9 @@ export async function fetchTransactionAttachments(
|
||||
createdAt: attachments.createdAt,
|
||||
})
|
||||
.from(transactionAttachments)
|
||||
.innerJoin(
|
||||
transactions,
|
||||
and(
|
||||
eq(transactionAttachments.transactionId, transactions.id),
|
||||
eq(transactions.userId, userId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
attachments,
|
||||
and(
|
||||
eq(transactionAttachments.attachmentId, attachments.id),
|
||||
eq(attachments.userId, userId),
|
||||
),
|
||||
eq(transactionAttachments.attachmentId, attachments.id),
|
||||
)
|
||||
.where(eq(transactionAttachments.transactionId, transactionId));
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ export function AnticipateInstallmentsDialog({
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="anticipation-period">Período</FieldLabel>
|
||||
<FieldLabel htmlFor="anticipation-period">Fatura</FieldLabel>
|
||||
<FieldContent>
|
||||
<PeriodPicker
|
||||
value={formState.anticipationPeriod}
|
||||
@@ -292,7 +292,7 @@ export function AnticipateInstallmentsDialog({
|
||||
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="anticipation-categoria">
|
||||
Category
|
||||
Categoria
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
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) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
@@ -63,11 +56,11 @@ export function InstallmentSelectionTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<TableHead>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === installments.length &&
|
||||
@@ -77,9 +70,8 @@ export function InstallmentSelectionTable({
|
||||
aria-label="Selecionar todas as parcelas"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Parcela</TableHead>
|
||||
<TableHead>Período</TableHead>
|
||||
<TableHead>Vencimento</TableHead>
|
||||
<TableHead>Estabelecimento</TableHead>
|
||||
<TableHead>Fatura</TableHead>
|
||||
<TableHead className="text-right">Valor</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -103,6 +95,7 @@ export function InstallmentSelectionTable({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{inst.name}{" "}
|
||||
<Badge variant="outline">
|
||||
{formatCurrentInstallment(
|
||||
inst.currentInstallment ?? 0,
|
||||
@@ -110,12 +103,11 @@ export function InstallmentSelectionTable({
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="font-medium">
|
||||
{formatShortPeriodLabel(inst.period)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(inst.dueDate)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right font-medium">
|
||||
<MoneyValues amount={Number(inst.amount)} />
|
||||
</TableCell>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,26 @@ export interface TransactionDialogProps {
|
||||
pendingDetachIds: string[];
|
||||
pendingUploadFiles: File[];
|
||||
}) => 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 {
|
||||
|
||||
@@ -78,6 +78,7 @@ export function TransactionDialog({
|
||||
onSuccess,
|
||||
maxSizeMb,
|
||||
onBulkEditRequest,
|
||||
onSplitEditRequest,
|
||||
}: TransactionDialogProps) {
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
@@ -321,6 +322,10 @@ export function TransactionDialog({
|
||||
formState.boletoPaymentDate
|
||||
? formState.boletoPaymentDate
|
||||
: undefined,
|
||||
importFromTransactionId:
|
||||
mode === "create" && isImporting && transaction?.id
|
||||
? transaction.id
|
||||
: undefined,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
@@ -365,6 +370,11 @@ export function TransactionDialog({
|
||||
}
|
||||
|
||||
const hasSeriesId = Boolean(transaction?.seriesId);
|
||||
const hasSplitPair = Boolean(
|
||||
transaction?.isDivided &&
|
||||
transaction?.splitGroupId &&
|
||||
!transaction?.seriesId,
|
||||
);
|
||||
|
||||
if (hasSeriesId && onBulkEditRequest) {
|
||||
// Para lançamentos em série, passa os arquivos para a página confirmar
|
||||
@@ -398,6 +408,39 @@ export function TransactionDialog({
|
||||
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
|
||||
const updatePayload: UpdateTransactionInput = {
|
||||
id: transaction?.id ?? "",
|
||||
@@ -609,6 +652,17 @@ export function TransactionDialog({
|
||||
formState={formState}
|
||||
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
|
||||
files={pendingFiles}
|
||||
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
deleteTransactionAction,
|
||||
deleteTransactionBulkAction,
|
||||
toggleTransactionSettlementAction,
|
||||
updateTransactionAction,
|
||||
updateTransactionBulkAction,
|
||||
updateTransactionSplitPairAction,
|
||||
} from "@/features/transactions/actions";
|
||||
import {
|
||||
confirmAttachmentUploadAction,
|
||||
@@ -31,6 +33,10 @@ import {
|
||||
MassAddDialog,
|
||||
type MassAddFormData,
|
||||
} from "../dialogs/mass-add-dialog";
|
||||
import {
|
||||
SplitPairDialog,
|
||||
type SplitPairScope,
|
||||
} from "../dialogs/split-pair-dialog";
|
||||
import { TransactionDetailsDialog } from "../dialogs/transaction-details-dialog";
|
||||
import { TransactionDialog } from "../dialogs/transaction-dialog/transaction-dialog";
|
||||
import { TransactionsTable } from "../table/transactions-table";
|
||||
@@ -125,6 +131,26 @@ export function TransactionsPage({
|
||||
);
|
||||
const [bulkEditOpen, setBulkEditOpen] = 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<{
|
||||
id: string;
|
||||
purchaseDate: string;
|
||||
@@ -394,6 +420,90 @@ export function TransactionsPage({
|
||||
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) => {
|
||||
setSelectedTransaction(item);
|
||||
setEditOpen(true);
|
||||
@@ -557,6 +667,7 @@ export function TransactionsPage({
|
||||
transaction={selectedTransaction ?? undefined}
|
||||
defaultPeriod={selectedPeriod}
|
||||
onBulkEditRequest={handleBulkEditRequest}
|
||||
onSplitEditRequest={handleSplitEditRequest}
|
||||
maxSizeMb={attachmentMaxSizeMb}
|
||||
/>
|
||||
|
||||
@@ -626,6 +737,14 @@ export function TransactionsPage({
|
||||
onConfirm={handleBulkEdit}
|
||||
/>
|
||||
|
||||
<SplitPairDialog
|
||||
open={pendingSplitEditData !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingSplitEditData(null);
|
||||
}}
|
||||
onConfirm={handleSplitEdit}
|
||||
/>
|
||||
|
||||
{allowCreate && massAddOpen ? (
|
||||
<MassAddDialog
|
||||
open={massAddOpen}
|
||||
|
||||
@@ -228,9 +228,7 @@ function buildColumns({
|
||||
className="text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">
|
||||
Dividido entre pessoas
|
||||
</span>
|
||||
<span className="sr-only">Dividido entre pessoas</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
|
||||
@@ -265,7 +265,8 @@ export function TransactionsFilters({
|
||||
searchParams.get("category") ||
|
||||
searchParams.get("accountCard") ||
|
||||
searchParams.get("settled") ||
|
||||
searchParams.get("hasAttachment");
|
||||
searchParams.get("hasAttachment") ||
|
||||
searchParams.get("isDivided");
|
||||
|
||||
const handleResetFilters = () => {
|
||||
handleReset();
|
||||
@@ -628,6 +629,23 @@ export function TransactionsFilters({
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<DrawerFooter>
|
||||
|
||||
@@ -33,6 +33,7 @@ export type TransactionItem = {
|
||||
isAnticipated: boolean;
|
||||
anticipationId: string | null;
|
||||
seriesId: string | null;
|
||||
splitGroupId: string | null;
|
||||
hasAttachments: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export type TransactionExportFilters = {
|
||||
searchFilter: string | null;
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
dividedFilter: string | null;
|
||||
};
|
||||
|
||||
export type TransactionsExportContext = {
|
||||
|
||||
@@ -45,6 +45,7 @@ export type TransactionSearchFilters = {
|
||||
searchFilter: string | null;
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
dividedFilter: string | null;
|
||||
};
|
||||
|
||||
type BaseSluggedOption = {
|
||||
@@ -134,6 +135,7 @@ export const extractTransactionSearchFilters = (
|
||||
searchFilter: getSingleParam(params, "q"),
|
||||
settledFilter: getSingleParam(params, "settled"),
|
||||
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
||||
dividedFilter: getSingleParam(params, "isDivided"),
|
||||
});
|
||||
|
||||
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);
|
||||
if (searchPattern) {
|
||||
where.push(
|
||||
@@ -468,6 +474,7 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
|
||||
isAnticipated: item.isAnticipated ?? false,
|
||||
anticipationId: item.anticipationId ?? null,
|
||||
seriesId: item.seriesId ?? null,
|
||||
splitGroupId: item.splitGroupId ?? null,
|
||||
hasAttachments: item.hasAttachments ?? false,
|
||||
readonly:
|
||||
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||
|
||||
@@ -101,7 +101,9 @@ export default async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
response.headers.set("Content-Security-Policy", buildCsp());
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
response.headers.set("Content-Security-Policy", buildCsp());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export type {
|
||||
export { CategoryIconBadge } from "./category-icon-badge";
|
||||
export { EstablishmentLogo } from "./establishment-logo";
|
||||
export { EstablishmentLogoPicker } from "./establishment-logo-picker";
|
||||
export { LogoPrefetchProvider } from "./logo-prefetch-provider";
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import {
|
||||
type RemixiconComponentType,
|
||||
RiArrowLeftRightLine,
|
||||
RiArrowRightDownLine,
|
||||
RiArrowRightUpLine,
|
||||
} from "@remixicon/react";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import StatusDot from "./status-dot";
|
||||
|
||||
type FinancialKind =
|
||||
| "receita"
|
||||
@@ -26,29 +31,33 @@ type TransactionTypeBadgeProps = {
|
||||
type BadgeConfig = {
|
||||
label: string;
|
||||
className: string;
|
||||
dotClassName: string;
|
||||
Icon: RemixiconComponentType;
|
||||
};
|
||||
|
||||
const BADGE_CONFIG: Record<FinancialKindKey, BadgeConfig> = {
|
||||
receita: {
|
||||
label: "Receita",
|
||||
className: "bg-success/10 text-success dark:bg-success/10",
|
||||
dotClassName: "bg-success/80",
|
||||
className:
|
||||
"border-success/30 bg-success/5 text-success dark:saturate-90 dark:border-success/50 dark:bg-transparent",
|
||||
Icon: RiArrowRightDownLine,
|
||||
},
|
||||
despesa: {
|
||||
label: "Despesa",
|
||||
className: "bg-destructive/10 text-destructive dark:bg-destructive/10",
|
||||
dotClassName: "bg-destructive/80",
|
||||
className:
|
||||
"border-destructive/30 bg-destructive/5 text-destructive dark:saturate-90 dark:border-destructive/50 dark:bg-transparent",
|
||||
Icon: RiArrowRightUpLine,
|
||||
},
|
||||
transferência: {
|
||||
label: "Transferência",
|
||||
className: "bg-info/10 text-info dark:bg-info/10",
|
||||
dotClassName: "bg-info/80",
|
||||
label: "Transf.",
|
||||
className:
|
||||
"border-info/30 bg-info/5 text-info dark:saturate-90 dark:border-info/50 dark:bg-transparent",
|
||||
Icon: RiArrowLeftRightLine,
|
||||
},
|
||||
"saldo inicial": {
|
||||
label: "Saldo Inicial",
|
||||
className: "bg-success/10 text-success dark:bg-success/10",
|
||||
dotClassName: "bg-success/80",
|
||||
className:
|
||||
"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 config = normalizedKind ? BADGE_CONFIG[normalizedKind] : null;
|
||||
const label = config?.label ?? kind;
|
||||
const Icon = config?.Icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-kind={normalizedKind ?? "custom"}
|
||||
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 ??
|
||||
"bg-muted/30 text-muted-foreground dark:bg-muted/20",
|
||||
"border-muted-foreground/30 bg-muted/20 text-muted-foreground dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<StatusDot
|
||||
color={config?.dotClassName ?? "bg-muted-foreground/60"}
|
||||
className="size-1.5"
|
||||
/>
|
||||
{Icon ? <Icon className="size-3.5" /> : null}
|
||||
<span>{label}</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { cn } from "@/shared/utils/ui";
|
||||
|
||||
@@ -26,16 +26,16 @@ function RadioGroupItem({
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.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.Item>
|
||||
);
|
||||
|
||||
31
src/shared/lib/logo/prefetch-server.ts
Normal file
31
src/shared/lib/logo/prefetch-server.ts
Normal 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;
|
||||
}
|
||||
5
src/shared/lib/logo/types.ts
Normal file
5
src/shared/lib/logo/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type LogoPrefetchEntry = {
|
||||
nameKey: string;
|
||||
domain: string | null;
|
||||
logoUrl: string | null;
|
||||
};
|
||||
@@ -48,5 +48,16 @@ export async function deleteS3Object(fileKey: string): Promise<void> {
|
||||
Bucket: S3_BUCKET,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user