mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
8 Commits
32da4f906e
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
839d7d0866 | ||
|
|
7cd7d95245 | ||
|
|
9bd762f7a3 | ||
|
|
9b76db4ce9 | ||
|
|
91457b6490 | ||
|
|
a0a71623d7 | ||
|
|
00e624b8bc | ||
|
|
f82043127a |
@@ -22,6 +22,13 @@ BETTER_AUTH_URL=http://localhost:3000
|
|||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# === S3 Server (Opcional) ===
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
|
||||||
# === Email (Opcional) ===
|
# === Email (Opcional) ===
|
||||||
# Provider: Resend (https://resend.com)
|
# Provider: Resend (https://resend.com)
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
|
|||||||
59
.github/workflows/release.yml
vendored
Normal file
59
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Read version from package.json
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(jq -r '.version' package.json)
|
||||||
|
echo "value=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check if tag already exists
|
||||||
|
id: tag_check
|
||||||
|
run: |
|
||||||
|
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Extract changelog for this version
|
||||||
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.value }}"
|
||||||
|
# Extrai o bloco entre ## [X.Y.Z] e o próximo ## [
|
||||||
|
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||||
|
# Remove linhas em branco do início e fim
|
||||||
|
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
|
||||||
|
{
|
||||||
|
echo "notes<<EOF"
|
||||||
|
echo "$NOTES"
|
||||||
|
echo "EOF"
|
||||||
|
} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create tag and GitHub Release
|
||||||
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
|
name: ${{ steps.version.outputs.tag }}
|
||||||
|
body: ${{ steps.changelog.outputs.notes }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -7,6 +7,21 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-03-28
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Lançamentos: suporte a anexos em transações com upload direto para storage compatível com S3, persistência em tabelas dedicadas (`anexos` e `lancamento_anexos`) e ações de visualizar/remover no detalhe do lançamento
|
||||||
|
- Infraestrutura: novo workflow `.github/workflows/release.yml` para criar tag e GitHub Release automaticamente a partir da versão do `package.json` e da entrada correspondente no `CHANGELOG.md`
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Anexos: upload agora exige token assinado por arquivo, valida propriedade da transação também na leitura/remoção e confere tamanho/tipo do objeto no storage antes de persistir o vínculo no banco
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Lançamentos: criação de transações no cartão de crédito agora bloqueia períodos cujas faturas já estão pagas, evitando divergência no relatório de análise de parcelas
|
||||||
|
|
||||||
## [2.0.3] - 2026-03-26
|
## [2.0.3] - 2026-03-26
|
||||||
|
|
||||||
### Corrigido
|
### Corrigido
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
||||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog.
|
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog.
|
||||||
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
||||||
|
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -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.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
- [Início Rápido (manual)](#-início-rápido)
|
- [Início Rápido (manual)](#-início-rápido)
|
||||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||||
- [Docker](#-docker)
|
- [Docker](#-docker)
|
||||||
|
- [Storage S3 Compatível](#-storage-s3-compatível)
|
||||||
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
||||||
- [Arquitetura](#-arquitetura)
|
- [Arquitetura](#-arquitetura)
|
||||||
- [Contribuindo](#-contribuindo)
|
- [Contribuindo](#-contribuindo)
|
||||||
@@ -238,6 +239,30 @@ DB_PORT=5433 # Padrão: 5432
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ☁️ Storage S3 Compatível
|
||||||
|
|
||||||
|
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
|
||||||
|
|
||||||
|
### Variáveis
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibilidade
|
||||||
|
|
||||||
|
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
|
||||||
|
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](/home/ubuntu/github/openmonetis/src/shared/lib/storage/s3-client.ts).
|
||||||
|
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
|
||||||
|
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
|
||||||
|
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔐 Variáveis de Ambiente
|
## 🔐 Variáveis de Ambiente
|
||||||
|
|
||||||
Copie `.env.example` para `.env` e configure:
|
Copie `.env.example` para `.env` e configure:
|
||||||
@@ -258,6 +283,13 @@ POSTGRES_USER=openmonetis
|
|||||||
POSTGRES_PASSWORD=openmonetis_dev_password
|
POSTGRES_PASSWORD=openmonetis_dev_password
|
||||||
POSTGRES_DB=openmonetis_db
|
POSTGRES_DB=openmonetis_db
|
||||||
|
|
||||||
|
# S3 Server (opcional, necessario para anexos)
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
|
||||||
# Multi-domínio (landing-only no domínio público)
|
# Multi-domínio (landing-only no domínio público)
|
||||||
# PUBLIC_DOMAIN=openmonetis.com
|
# PUBLIC_DOMAIN=openmonetis.com
|
||||||
|
|
||||||
|
|||||||
37
drizzle/0023_sturdy_wolfpack.sql
Normal file
37
drizzle/0023_sturdy_wolfpack.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE "anexos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"chave_arquivo" text NOT NULL,
|
||||||
|
"nome_arquivo" text NOT NULL,
|
||||||
|
"tamanho_bytes" integer NOT NULL,
|
||||||
|
"mime_type" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "anexos_chave_arquivo_unique" UNIQUE("chave_arquivo")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "dashboard_notification_states" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"notification_key" text NOT NULL,
|
||||||
|
"fingerprint" text NOT NULL,
|
||||||
|
"read_at" timestamp with time zone,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "lancamento_anexos" (
|
||||||
|
"lancamento_id" uuid NOT NULL,
|
||||||
|
"anexo_id" uuid NOT NULL,
|
||||||
|
CONSTRAINT "lancamento_anexos_lancamento_id_anexo_id_pk" PRIMARY KEY("lancamento_id","anexo_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "anexos" ADD CONSTRAINT "anexos_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_lancamento_id_lancamentos_id_fk" FOREIGN KEY ("lancamento_id") REFERENCES "public"."lancamentos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_anexo_id_anexos_id_fk" FOREIGN KEY ("anexo_id") REFERENCES "public"."anexos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamento_anexos_anexo_id_idx" ON "lancamento_anexos" USING btree ("anexo_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";
|
||||||
2704
drizzle/meta/0023_snapshot.json
Normal file
2704
drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,13 @@
|
|||||||
"when": 1748000000000,
|
"when": 1748000000000,
|
||||||
"tag": "0022_import-category-mappings",
|
"tag": "0022_import-category-mappings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774529878374,
|
||||||
|
"tag": "0023_sturdy_wolfpack",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.0.3",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -29,10 +29,12 @@
|
|||||||
"backup": "bash scripts/backup.sh"
|
"backup": "bash scripts/backup.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.63",
|
"@ai-sdk/anthropic": "^3.0.64",
|
||||||
"@ai-sdk/google": "^3.0.52",
|
"@ai-sdk/google": "^3.0.53",
|
||||||
"@ai-sdk/openai": "^3.0.47",
|
"@ai-sdk/openai": "^3.0.48",
|
||||||
"@better-auth/passkey": "^1.5.5",
|
"@aws-sdk/client-s3": "^3.1019.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1019.0",
|
||||||
|
"@better-auth/passkey": "^1.5.6",
|
||||||
"@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",
|
||||||
@@ -61,14 +63,14 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"@vercel/speed-insights": "^2.0.0",
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
"ai": "^6.0.134",
|
"ai": "^6.0.141",
|
||||||
"better-auth": "1.5.5",
|
"better-auth": "1.5.6",
|
||||||
"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",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
@@ -78,7 +80,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.1",
|
||||||
"resend": "^6.9.4",
|
"resend": "^6.9.4",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
@@ -87,7 +89,7 @@
|
|||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.8",
|
"@biomejs/biome": "2.4.9",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.2.2",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "25.5.0",
|
"@types/node": "25.5.0",
|
||||||
@@ -98,6 +100,6 @@
|
|||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.2.2",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.9.3"
|
"typescript": "6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1579
pnpm-lock.yaml
generated
1579
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="pt-BR"
|
lang="pt-BR"
|
||||||
className={`${america.variable} ${america.className}`}
|
className={`${america.variable} ${america.className} `}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
113
src/db/schema.ts
113
src/db/schema.ts
@@ -847,32 +847,36 @@ export const inboxItemsRelations = relations(inboxItems, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const transactionsRelations = relations(transactions, ({ one }) => ({
|
export const transactionsRelations = relations(
|
||||||
user: one(user, {
|
transactions,
|
||||||
fields: [transactions.userId],
|
({ one, many }) => ({
|
||||||
references: [user.id],
|
user: one(user, {
|
||||||
|
fields: [transactions.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
card: one(cards, {
|
||||||
|
fields: [transactions.cardId],
|
||||||
|
references: [cards.id],
|
||||||
|
}),
|
||||||
|
financialAccount: one(financialAccounts, {
|
||||||
|
fields: [transactions.accountId],
|
||||||
|
references: [financialAccounts.id],
|
||||||
|
}),
|
||||||
|
category: one(categories, {
|
||||||
|
fields: [transactions.categoryId],
|
||||||
|
references: [categories.id],
|
||||||
|
}),
|
||||||
|
payer: one(payers, {
|
||||||
|
fields: [transactions.payerId],
|
||||||
|
references: [payers.id],
|
||||||
|
}),
|
||||||
|
anticipation: one(installmentAnticipations, {
|
||||||
|
fields: [transactions.anticipationId],
|
||||||
|
references: [installmentAnticipations.id],
|
||||||
|
}),
|
||||||
|
transactionAttachments: many(transactionAttachments),
|
||||||
}),
|
}),
|
||||||
card: one(cards, {
|
);
|
||||||
fields: [transactions.cardId],
|
|
||||||
references: [cards.id],
|
|
||||||
}),
|
|
||||||
financialAccount: one(financialAccounts, {
|
|
||||||
fields: [transactions.accountId],
|
|
||||||
references: [financialAccounts.id],
|
|
||||||
}),
|
|
||||||
category: one(categories, {
|
|
||||||
fields: [transactions.categoryId],
|
|
||||||
references: [categories.id],
|
|
||||||
}),
|
|
||||||
payer: one(payers, {
|
|
||||||
fields: [transactions.payerId],
|
|
||||||
references: [payers.id],
|
|
||||||
}),
|
|
||||||
anticipation: one(installmentAnticipations, {
|
|
||||||
fields: [transactions.anticipationId],
|
|
||||||
references: [installmentAnticipations.id],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const installmentAnticipationsRelations = relations(
|
export const installmentAnticipationsRelations = relations(
|
||||||
installmentAnticipations,
|
installmentAnticipations,
|
||||||
@@ -896,6 +900,40 @@ export const installmentAnticipationsRelations = relations(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ===================== ATTACHMENTS =====================
|
||||||
|
|
||||||
|
export const attachments = pgTable("anexos", {
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
fileKey: text("chave_arquivo").notNull().unique(),
|
||||||
|
fileName: text("nome_arquivo").notNull(),
|
||||||
|
fileSize: integer("tamanho_bytes").notNull(),
|
||||||
|
mimeType: text("mime_type").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const transactionAttachments = pgTable(
|
||||||
|
"lancamento_anexos",
|
||||||
|
{
|
||||||
|
transactionId: uuid("lancamento_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => transactions.id, { onDelete: "cascade" }),
|
||||||
|
attachmentId: uuid("anexo_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => attachments.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.transactionId, table.attachmentId] }),
|
||||||
|
attachmentIdIdx: index("lancamento_anexos_anexo_id_idx").on(
|
||||||
|
table.attachmentId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const importCategoryMappings = pgTable(
|
export const importCategoryMappings = pgTable(
|
||||||
"import_category_mappings",
|
"import_category_mappings",
|
||||||
{
|
{
|
||||||
@@ -939,3 +977,28 @@ export type NewApiToken = typeof apiTokens.$inferInsert;
|
|||||||
export type InboxItem = typeof inboxItems.$inferSelect;
|
export type InboxItem = typeof inboxItems.$inferSelect;
|
||||||
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
||||||
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;
|
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;
|
||||||
|
|
||||||
|
export const attachmentsRelations = relations(attachments, ({ one, many }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [attachments.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
transactionAttachments: many(transactionAttachments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const transactionAttachmentsRelations = relations(
|
||||||
|
transactionAttachments,
|
||||||
|
({ one }) => ({
|
||||||
|
transaction: one(transactions, {
|
||||||
|
fields: [transactionAttachments.transactionId],
|
||||||
|
references: [transactions.id],
|
||||||
|
}),
|
||||||
|
attachment: one(attachments, {
|
||||||
|
fields: [transactionAttachments.attachmentId],
|
||||||
|
references: [attachments.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Attachment = typeof attachments.$inferSelect;
|
||||||
|
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
|
||||||
|
|||||||
435
src/features/transactions/actions/attachments.ts
Normal file
435
src/features/transactions/actions/attachments.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import crypto, { randomUUID } from "node:crypto";
|
||||||
|
import { and, count, eq, inArray } from "drizzle-orm";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
} from "@/features/transactions/attachments-config";
|
||||||
|
import {
|
||||||
|
handleActionError,
|
||||||
|
revalidateForEntity,
|
||||||
|
} from "@/shared/lib/actions/helpers";
|
||||||
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import {
|
||||||
|
createPresignedGetUrl,
|
||||||
|
createPresignedPutUrl,
|
||||||
|
deleteS3Object,
|
||||||
|
headS3Object,
|
||||||
|
} from "@/shared/lib/storage/presign";
|
||||||
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
|
|
||||||
|
const UPLOAD_TOKEN_EXPIRY_SECONDS = 10 * 60;
|
||||||
|
|
||||||
|
const presignSchema = z.object({
|
||||||
|
fileName: z.string().min(1),
|
||||||
|
mimeType: z.enum(ALLOWED_MIME_TYPES),
|
||||||
|
fileSize: z.number().max(MAX_FILE_SIZE, "Arquivo deve ter no máximo 50MB."),
|
||||||
|
transactionId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmSchema = z.object({
|
||||||
|
uploadToken: z.string().min(1),
|
||||||
|
applyToSeries: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const detachSchema = z.object({
|
||||||
|
attachmentId: z.string().uuid(),
|
||||||
|
transactionId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type PresignResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
presignedUrl: string;
|
||||||
|
fileKey: string;
|
||||||
|
uploadToken: string;
|
||||||
|
}
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
type UploadTokenPayload = {
|
||||||
|
userId: string;
|
||||||
|
transactionId: string;
|
||||||
|
fileKey: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: (typeof ALLOWED_MIME_TYPES)[number];
|
||||||
|
fileSize: number;
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getUploadTokenSecret(): string {
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error(
|
||||||
|
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(value: string): string {
|
||||||
|
return Buffer.from(value)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlDecode(value: string): string {
|
||||||
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const pad = normalized.length % 4;
|
||||||
|
const padded = pad ? normalized + "=".repeat(4 - pad) : normalized;
|
||||||
|
return Buffer.from(padded, "base64").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function signUploadToken(payload: UploadTokenPayload): string {
|
||||||
|
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac("sha256", getUploadTokenSecret())
|
||||||
|
.update(encodedPayload)
|
||||||
|
.digest("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
|
||||||
|
return `${encodedPayload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyUploadToken(token: string): UploadTokenPayload | null {
|
||||||
|
try {
|
||||||
|
const [encodedPayload, signature] = token.split(".");
|
||||||
|
if (!encodedPayload || !signature) return null;
|
||||||
|
|
||||||
|
const expectedSignature = crypto
|
||||||
|
.createHmac("sha256", getUploadTokenSecret())
|
||||||
|
.update(encodedPayload)
|
||||||
|
.digest("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
|
||||||
|
if (
|
||||||
|
!crypto.timingSafeEqual(
|
||||||
|
Buffer.from(signature),
|
||||||
|
Buffer.from(expectedSignature),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(
|
||||||
|
base64UrlDecode(encodedPayload),
|
||||||
|
) as UploadTokenPayload;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
if (!payload.fileKey.startsWith(`${payload.userId}/`)) return null;
|
||||||
|
if (!ALLOWED_MIME_TYPES.includes(payload.mimeType)) return null;
|
||||||
|
if (payload.fileSize <= 0 || payload.fileSize > MAX_FILE_SIZE) return null;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPresignedUploadUrlAction(input: {
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileSize: number;
|
||||||
|
transactionId: string;
|
||||||
|
}): Promise<PresignResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = presignSchema.parse(input);
|
||||||
|
|
||||||
|
const [transaction] = await db
|
||||||
|
.select({ id: transactions.id })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.id, data.transactionId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = data.fileName.split(".").pop()?.toLowerCase() ?? "bin";
|
||||||
|
const fileKey = `${user.id}/${randomUUID()}.${ext}`;
|
||||||
|
const presignedUrl = await createPresignedPutUrl(fileKey, data.mimeType);
|
||||||
|
const uploadToken = signUploadToken({
|
||||||
|
userId: user.id,
|
||||||
|
transactionId: data.transactionId,
|
||||||
|
fileKey,
|
||||||
|
fileName: data.fileName,
|
||||||
|
mimeType: data.mimeType,
|
||||||
|
fileSize: data.fileSize,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + UPLOAD_TOKEN_EXPIRY_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, presignedUrl, fileKey, uploadToken };
|
||||||
|
} catch (error) {
|
||||||
|
const result = handleActionError(error);
|
||||||
|
if (!result.success) return { success: false, error: result.error };
|
||||||
|
return { success: false, error: "Erro inesperado." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmAttachmentUploadAction(input: {
|
||||||
|
uploadToken: string;
|
||||||
|
applyToSeries?: boolean;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = confirmSchema.parse(input);
|
||||||
|
const uploadPayload = verifyUploadToken(data.uploadToken);
|
||||||
|
|
||||||
|
if (!uploadPayload || uploadPayload.userId !== user.id) {
|
||||||
|
return { success: false, error: "Upload de anexo inválido ou expirado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [transaction] = await db
|
||||||
|
.select({ id: transactions.id, seriesId: transactions.seriesId })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.id, uploadPayload.transactionId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectMetadata = await headS3Object(uploadPayload.fileKey);
|
||||||
|
|
||||||
|
if (!objectMetadata.contentLength || objectMetadata.contentLength <= 0) {
|
||||||
|
return { success: false, error: "Arquivo enviado não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectMetadata.contentLength > MAX_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "O arquivo enviado excede o limite permitido de 50MB.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectMetadata.contentLength !== uploadPayload.fileSize) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"O tamanho do arquivo enviado não confere com o upload autorizado.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectMetadata.contentType !== uploadPayload.mimeType) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "O tipo do arquivo enviado não confere com o upload autorizado.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [attachment] = await db
|
||||||
|
.insert(attachments)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
fileKey: uploadPayload.fileKey,
|
||||||
|
fileName: uploadPayload.fileName,
|
||||||
|
fileSize: uploadPayload.fileSize,
|
||||||
|
mimeType: uploadPayload.mimeType,
|
||||||
|
})
|
||||||
|
.returning({ id: attachments.id });
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return { success: false, error: "Erro ao salvar o anexo." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let transactionIds: string[] = [uploadPayload.transactionId];
|
||||||
|
|
||||||
|
if (data.applyToSeries && transaction.seriesId) {
|
||||||
|
const seriesRows = await db
|
||||||
|
.select({ id: transactions.id })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.seriesId, transaction.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
transactionIds = seriesRows.map((t) => t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(transactionAttachments).values(
|
||||||
|
transactionIds.map((tid) => ({
|
||||||
|
transactionId: tid,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidateForEntity("transactions", user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Anexo salvo com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detachTransactionAttachmentAction(input: {
|
||||||
|
attachmentId: string;
|
||||||
|
transactionId: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = detachSchema.parse(input);
|
||||||
|
|
||||||
|
const [transaction] = await db
|
||||||
|
.select({ id: transactions.id })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.id, data.transactionId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [attachment] = await db
|
||||||
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
|
.from(attachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(attachments.id, data.attachmentId),
|
||||||
|
eq(attachments.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return { success: false, error: "Anexo não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(transactionAttachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.transactionId, data.transactionId),
|
||||||
|
eq(transactionAttachments.attachmentId, data.attachmentId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [remaining] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.where(eq(transactionAttachments.attachmentId, data.attachmentId));
|
||||||
|
|
||||||
|
if (!remaining || remaining.total === 0) {
|
||||||
|
await deleteS3Object(attachment.fileKey);
|
||||||
|
await db.delete(attachments).where(eq(attachments.id, data.attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateForEntity("transactions", user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Anexo removido com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */
|
||||||
|
export async function cleanupAttachmentsAfterTransactionDelete(
|
||||||
|
attachmentData: Array<{ id: string; fileKey: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (attachmentData.length === 0) return;
|
||||||
|
|
||||||
|
const uniqueIds = [...new Set(attachmentData.map((a) => a.id))];
|
||||||
|
|
||||||
|
const remaining = await db
|
||||||
|
.select({
|
||||||
|
attachmentId: transactionAttachments.attachmentId,
|
||||||
|
total: count(),
|
||||||
|
})
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.where(inArray(transactionAttachments.attachmentId, uniqueIds))
|
||||||
|
.groupBy(transactionAttachments.attachmentId);
|
||||||
|
|
||||||
|
const remainingMap = new Map(remaining.map((r) => [r.attachmentId, r.total]));
|
||||||
|
|
||||||
|
for (const att of attachmentData) {
|
||||||
|
if (!remainingMap.has(att.id) || (remainingMap.get(att.id) ?? 0) === 0) {
|
||||||
|
await deleteS3Object(att.fileKey);
|
||||||
|
await db.delete(attachments).where(eq(attachments.id, att.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { financialAccounts, transactions } from "@/db/schema";
|
import {
|
||||||
|
attachments,
|
||||||
|
financialAccounts,
|
||||||
|
invoices,
|
||||||
|
transactionAttachments,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
import { handleActionError } from "@/shared/lib/actions/helpers";
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
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 { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
import {
|
import {
|
||||||
buildEntriesByPayer,
|
buildEntriesByPayer,
|
||||||
sendPayerAutoEmails,
|
sendPayerAutoEmails,
|
||||||
@@ -16,6 +23,8 @@ import {
|
|||||||
getBusinessTodayDate,
|
getBusinessTodayDate,
|
||||||
parseLocalDateString,
|
parseLocalDateString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
|
import { MONTH_NAMES } from "@/shared/utils/period";
|
||||||
|
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||||
import {
|
import {
|
||||||
buildLancamentoRecords,
|
buildLancamentoRecords,
|
||||||
buildShares,
|
buildShares,
|
||||||
@@ -37,7 +46,7 @@ import {
|
|||||||
|
|
||||||
export async function createTransactionAction(
|
export async function createTransactionAction(
|
||||||
input: CreateInput,
|
input: CreateInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult<{ ids: string[] }>> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const data = createSchema.parse(input);
|
const data = createSchema.parse(input);
|
||||||
@@ -102,7 +111,42 @@ export async function createTransactionAction(
|
|||||||
throw new Error("Não foi possível criar os lançamentos solicitados.");
|
throw new Error("Não foi possível criar os lançamentos solicitados.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(transactions).values(records);
|
if (data.cardId) {
|
||||||
|
const uniquePeriods = [
|
||||||
|
...new Set(
|
||||||
|
records.map((r) => r.period).filter((p): p is string => Boolean(p)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const paidInvoices = await db.query.invoices.findMany({
|
||||||
|
columns: { period: true },
|
||||||
|
where: and(
|
||||||
|
eq(invoices.userId, user.id),
|
||||||
|
eq(invoices.cardId, data.cardId),
|
||||||
|
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
|
||||||
|
inArray(invoices.period, uniquePeriods),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paidInvoices.length > 0) {
|
||||||
|
const labels = paidInvoices
|
||||||
|
.map((inv) => {
|
||||||
|
const [year, month] = (inv.period ?? "").split("-");
|
||||||
|
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
|
||||||
|
return `${monthName}/${year}`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `As faturas dos meses ${labels} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
|
||||||
|
} as ActionResult<{ ids: string[] }>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await db
|
||||||
|
.insert(transactions)
|
||||||
|
.values(records)
|
||||||
|
.returning({ id: transactions.id });
|
||||||
|
|
||||||
const notificationEntries = buildEntriesByPayer(
|
const notificationEntries = buildEntriesByPayer(
|
||||||
records.map((record) => ({
|
records.map((record) => ({
|
||||||
@@ -128,9 +172,13 @@ export async function createTransactionAction(
|
|||||||
|
|
||||||
revalidate(user.id);
|
revalidate(user.id);
|
||||||
|
|
||||||
return { success: true, message: "Lançamento criado com sucesso." };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Lançamento criado com sucesso.",
|
||||||
|
data: { ids: inserted.map((r) => r.id) },
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error);
|
return handleActionError(error) as ActionResult<{ ids: string[] }>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,12 +377,23 @@ export async function deleteTransactionAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkedAttachments = await db
|
||||||
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.innerJoin(
|
||||||
|
attachments,
|
||||||
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
|
)
|
||||||
|
.where(eq(transactionAttachments.transactionId, data.id));
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(transactions)
|
.delete(transactions)
|
||||||
.where(
|
.where(
|
||||||
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
|
||||||
|
|
||||||
if (existing.payerId) {
|
if (existing.payerId) {
|
||||||
const notificationEntries = buildEntriesByPayer([
|
const notificationEntries = buildEntriesByPayer([
|
||||||
{
|
{
|
||||||
|
|||||||
8
src/features/transactions/attachments-config.ts
Normal file
8
src/features/transactions/attachments-config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const ALLOWED_MIME_TYPES = [
|
||||||
|
"application/pdf",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
} from "@/features/transactions/attachments-config";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
|
interface AttachmentFilePickerProps {
|
||||||
|
file: File | null;
|
||||||
|
onChange: (file: File | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentFilePicker({
|
||||||
|
file,
|
||||||
|
onChange,
|
||||||
|
}: AttachmentFilePickerProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const selected = e.target.files?.[0];
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!ALLOWED_MIME_TYPES.includes(
|
||||||
|
selected.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.size > MAX_FILE_SIZE) {
|
||||||
|
toast.error("O arquivo deve ter no máximo 50MB.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs">Anexo</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept={ALLOWED_MIME_TYPES.join(",")}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{file ? (
|
||||||
|
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
|
||||||
|
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={file.name}>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6 shrink-0"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
>
|
||||||
|
<RiCloseLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<RiAttachment2 className="size-4" />
|
||||||
|
Adicionar anexo
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px]">
|
||||||
|
PDF, JPEG, PNG ou WebP · máx. 50 MB
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiDeleteBinLine,
|
||||||
|
RiDownloadLine,
|
||||||
|
RiExternalLinkLine,
|
||||||
|
RiFileImageLine,
|
||||||
|
RiFileLine,
|
||||||
|
RiFilePdfLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { detachTransactionAttachmentAction } from "@/features/transactions/actions/attachments";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentIcon({ mimeType }: { mimeType: string }) {
|
||||||
|
if (mimeType === "application/pdf")
|
||||||
|
return <RiFilePdfLine className="size-4 text-red-500 shrink-0" />;
|
||||||
|
if (mimeType.startsWith("image/"))
|
||||||
|
return <RiFileImageLine className="size-4 text-blue-500 shrink-0" />;
|
||||||
|
return <RiFileLine className="size-4 text-muted-foreground shrink-0" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentPreview({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
fileName,
|
||||||
|
mimeType,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
url: string;
|
||||||
|
}) {
|
||||||
|
const isPdf = mimeType === "application/pdf";
|
||||||
|
const isImage = mimeType.startsWith("image/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="flex h-[92vh] w-[min(96vw,1400px)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:p-0"
|
||||||
|
>
|
||||||
|
<DialogHeader className="flex-row items-center justify-between gap-3 border-b px-4 py-3 sm:px-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<DialogTitle
|
||||||
|
className="truncate text-sm font-medium"
|
||||||
|
title={fileName}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button type="button" variant="ghost" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={fileName}
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" asChild>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="ghost" size="sm">
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
|
{isPdf && (
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
className="h-full w-full border-0 bg-background"
|
||||||
|
title={fileName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImage && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-black/85 p-4 sm:p-6">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={fileName}
|
||||||
|
className="max-h-full max-w-full rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentItemProps {
|
||||||
|
attachmentId: string;
|
||||||
|
transactionId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
url: string;
|
||||||
|
onDeleted: () => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentItem({
|
||||||
|
attachmentId,
|
||||||
|
transactionId,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
mimeType,
|
||||||
|
url,
|
||||||
|
onDeleted,
|
||||||
|
readonly = false,
|
||||||
|
}: AttachmentItemProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
|
const canPreview =
|
||||||
|
mimeType === "application/pdf" || mimeType.startsWith("image/");
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await detachTransactionAttachmentAction({
|
||||||
|
attachmentId,
|
||||||
|
transactionId,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
onDeleted();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setConfirmOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
|
||||||
|
<AttachmentIcon mimeType={mimeType} />
|
||||||
|
{canPreview ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 text-left"
|
||||||
|
onClick={() => setPreviewOpen(true)}
|
||||||
|
title={fileName}
|
||||||
|
>
|
||||||
|
<p className="truncate font-medium hover:underline">{fileName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(fileSize)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate font-medium">{fileName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(fileSize)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer" download={fileName}>
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canPreview && (
|
||||||
|
<AttachmentPreview
|
||||||
|
open={previewOpen}
|
||||||
|
onOpenChange={setPreviewOpen}
|
||||||
|
fileName={fileName}
|
||||||
|
mimeType={mimeType}
|
||||||
|
url={url}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remover anexo</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Tem certeza que deseja remover{" "}
|
||||||
|
<span className="break-all font-medium text-foreground">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={isPending}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? "Removendo..." : "Remover"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
|
||||||
|
import { AttachmentItem } from "./attachment-item";
|
||||||
|
import { AttachmentUpload } from "./attachment-upload";
|
||||||
|
|
||||||
|
type AttachmentRow = {
|
||||||
|
attachmentId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
createdAt: Date;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AttachmentSectionProps {
|
||||||
|
transactionId: string;
|
||||||
|
seriesId: string | null;
|
||||||
|
readonly?: boolean;
|
||||||
|
onLoaded?: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentSection({
|
||||||
|
transactionId,
|
||||||
|
seriesId,
|
||||||
|
readonly = false,
|
||||||
|
onLoaded,
|
||||||
|
}: AttachmentSectionProps) {
|
||||||
|
const [items, setItems] = useState<AttachmentRow[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchTransactionAttachmentsAction(transactionId);
|
||||||
|
setItems(data);
|
||||||
|
onLoaded?.(data.length);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [transactionId, onLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-2 overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Carregando...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="min-w-0 space-y-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<AttachmentItem
|
||||||
|
key={item.attachmentId}
|
||||||
|
attachmentId={item.attachmentId}
|
||||||
|
transactionId={transactionId}
|
||||||
|
fileName={item.fileName}
|
||||||
|
fileSize={item.fileSize}
|
||||||
|
mimeType={item.mimeType}
|
||||||
|
url={item.url}
|
||||||
|
onDeleted={load}
|
||||||
|
readonly={readonly}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
readonly && (
|
||||||
|
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readonly && (
|
||||||
|
<AttachmentUpload
|
||||||
|
transactionId={transactionId}
|
||||||
|
seriesId={seriesId}
|
||||||
|
onUploaded={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiAttachment2 } from "@remixicon/react";
|
||||||
|
import { useRef, useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
confirmAttachmentUploadAction,
|
||||||
|
getPresignedUploadUrlAction,
|
||||||
|
} from "@/features/transactions/actions/attachments";
|
||||||
|
import {
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
} from "@/features/transactions/attachments-config";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
|
||||||
|
interface AttachmentUploadProps {
|
||||||
|
transactionId: string;
|
||||||
|
seriesId: string | null;
|
||||||
|
onUploaded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentUpload({
|
||||||
|
transactionId,
|
||||||
|
seriesId,
|
||||||
|
onUploaded,
|
||||||
|
}: AttachmentUploadProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [applyToSeries, setApplyToSeries] = useState(false);
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!inputRef.current) return;
|
||||||
|
inputRef.current.value = "";
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!ALLOWED_MIME_TYPES.includes(
|
||||||
|
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
toast.error("O arquivo deve ter no máximo 50MB.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesId) {
|
||||||
|
setPendingFile(file);
|
||||||
|
} else {
|
||||||
|
uploadFile(file, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(file: File, toSeries: boolean) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const presignResult = await getPresignedUploadUrlAction({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
transactionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!presignResult.success) {
|
||||||
|
toast.error(presignResult.error ?? "Erro ao iniciar upload.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(presignResult.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
toast.error("Erro ao enviar o arquivo. Tente novamente.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmResult = await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presignResult.uploadToken,
|
||||||
|
applyToSeries: toSeries,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmResult.success) {
|
||||||
|
toast.success(confirmResult.message);
|
||||||
|
setPendingFile(null);
|
||||||
|
setApplyToSeries(false);
|
||||||
|
onUploaded();
|
||||||
|
} else {
|
||||||
|
toast.error(confirmResult.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmPending() {
|
||||||
|
if (pendingFile) uploadFile(pendingFile, applyToSeries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelPending() {
|
||||||
|
setPendingFile(null);
|
||||||
|
setApplyToSeries(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingFile) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-2 rounded-md border border-dashed p-3 text-sm">
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<p className="truncate font-medium" title={pendingFile.name}>
|
||||||
|
{pendingFile.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="apply-series"
|
||||||
|
checked={applyToSeries}
|
||||||
|
onCheckedChange={(v) => setApplyToSeries(Boolean(v))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="apply-series" className="cursor-pointer text-xs">
|
||||||
|
Aplicar a todas as parcelas da série
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConfirmPending}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? "Enviando..." : "Confirmar"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancelPending}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept={ALLOWED_MIME_TYPES.join(",")}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<RiAttachment2 className="size-4" />
|
||||||
|
{isPending ? "Enviando..." : "Adicionar anexo"}
|
||||||
|
</span>
|
||||||
|
{!isPending && (
|
||||||
|
<span className="text-xs">PDF, JPEG, PNG ou WebP · máx. 50 MB</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
currencyFormatter,
|
currencyFormatter,
|
||||||
formatCondition,
|
formatCondition,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
import { Separator } from "@/shared/components/ui/separator";
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import { parseLocalDateString } from "@/shared/utils/date";
|
import { parseLocalDateString } from "@/shared/utils/date";
|
||||||
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||||
|
import { AttachmentSection } from "../attachments/attachment-section";
|
||||||
import { InstallmentTimeline } from "../shared/installment-timeline";
|
import { InstallmentTimeline } from "../shared/installment-timeline";
|
||||||
import type { TransactionItem } from "../types";
|
import type { TransactionItem } from "../types";
|
||||||
|
|
||||||
@@ -37,6 +39,12 @@ export function TransactionDetailsDialog({
|
|||||||
transaction,
|
transaction,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: TransactionDetailsDialogProps) {
|
}: TransactionDetailsDialogProps) {
|
||||||
|
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAttachmentCount(null);
|
||||||
|
}, [transaction?.id]);
|
||||||
|
|
||||||
if (!transaction) return null;
|
if (!transaction) return null;
|
||||||
|
|
||||||
const isInstallment =
|
const isInstallment =
|
||||||
@@ -63,7 +71,7 @@ export function TransactionDetailsDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{transaction.name}</DialogTitle>
|
<DialogTitle>{transaction.name}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -71,57 +79,18 @@ export function TransactionDetailsDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto text-sm">
|
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||||
<div className="grid gap-3">
|
<div className="min-w-0 space-y-4">
|
||||||
<ul className="grid gap-3">
|
<section className="rounded-lg border bg-muted/20 p-3">
|
||||||
<DetailRow
|
<div className="flex items-start justify-between gap-3">
|
||||||
label="Período"
|
<div className="min-w-0">
|
||||||
value={formatPeriod(transaction.period)}
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
/>
|
Resumo
|
||||||
|
</p>
|
||||||
<li className="flex items-center justify-between">
|
<p className="mt-1 text-2xl font-semibold">
|
||||||
<span className="text-muted-foreground">
|
{currencyFormatter.format(valorTotal)}
|
||||||
Forma de Pagamento
|
</p>
|
||||||
</span>
|
</div>
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
{getPaymentMethodIcon(transaction.paymentMethod)}
|
|
||||||
<span>{transaction.paymentMethod}</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<DetailRow
|
|
||||||
label={transaction.cartaoName ? "Cartão" : "Conta"}
|
|
||||||
value={transaction.cartaoName ?? transaction.contaName ?? "—"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailRow
|
|
||||||
label="Categoria"
|
|
||||||
value={transaction.categoriaName ?? "—"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<li className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Tipo de Transação</span>
|
|
||||||
<TransactionTypeBadge
|
|
||||||
kind={
|
|
||||||
transaction.categoriaName === "Saldo inicial"
|
|
||||||
? "Saldo inicial"
|
|
||||||
: transaction.transactionType
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<DetailRow
|
|
||||||
label="Condição"
|
|
||||||
value={formatCondition(transaction.condition)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<li className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Responsável</span>
|
|
||||||
<span>{transaction.pagadorName}</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Status</span>
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={
|
className={
|
||||||
@@ -132,75 +101,140 @@ export function TransactionDetailsDialog({
|
|||||||
>
|
>
|
||||||
{transaction.isSettled ? "Pago" : "Pendente"}
|
{transaction.isSettled ? "Pago" : "Pendente"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</li>
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
{isBoleto && transaction.dueDate && (
|
<TransactionTypeBadge
|
||||||
<DetailRow
|
kind={
|
||||||
label="Vencimento"
|
transaction.categoriaName === "Saldo inicial"
|
||||||
value={formatDate(transaction.dueDate)}
|
? "Saldo inicial"
|
||||||
|
: transaction.transactionType
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{formatCondition(transaction.condition)}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Detalhes
|
||||||
|
</h3>
|
||||||
|
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
|
||||||
|
<DetailRow
|
||||||
|
label="Período"
|
||||||
|
value={formatPeriod(transaction.period)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{transaction.isDivided && (
|
|
||||||
<li className="flex items-center justify-between">
|
<li className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground">Divisão</span>
|
<span className="text-muted-foreground">
|
||||||
<Badge variant="outline">Dividido</Badge>
|
Forma de Pagamento
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{getPaymentMethodIcon(transaction.paymentMethod)}
|
||||||
|
<span>{transaction.paymentMethod}</span>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
)}
|
|
||||||
|
|
||||||
{transaction.note && (
|
<DetailRow
|
||||||
<li className="flex flex-col gap-1">
|
label={transaction.cartaoName ? "Cartão" : "Conta"}
|
||||||
<span className="text-muted-foreground">Notas</span>
|
value={transaction.cartaoName ?? transaction.contaName ?? "—"}
|
||||||
<span className="text-foreground">{transaction.note}</span>
|
/>
|
||||||
|
|
||||||
|
<DetailRow
|
||||||
|
label="Categoria"
|
||||||
|
value={transaction.categoriaName ?? "—"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<li className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Responsável</span>
|
||||||
|
<span>{transaction.pagadorName}</span>
|
||||||
</li>
|
</li>
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<ul className="mb-2 grid gap-3">
|
{isBoleto && transaction.dueDate && (
|
||||||
{isInstallment && (
|
<DetailRow
|
||||||
<li className="mt-4">
|
label="Vencimento"
|
||||||
<InstallmentTimeline
|
value={formatDate(transaction.dueDate)}
|
||||||
purchaseDate={parseLocalDateString(
|
|
||||||
transaction.purchaseDate,
|
|
||||||
)}
|
|
||||||
currentInstallment={parcelaAtual}
|
|
||||||
totalInstallments={totalParcelas}
|
|
||||||
period={transaction.period}
|
|
||||||
/>
|
/>
|
||||||
</li>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<DetailRow
|
{transaction.isDivided && (
|
||||||
label={isInstallment ? "Valor da Parcela" : "Valor"}
|
<li className="flex items-center justify-between">
|
||||||
value={currencyFormatter.format(valorParcela)}
|
<span className="text-muted-foreground">Divisão</span>
|
||||||
/>
|
<Badge variant="outline">Dividido</Badge>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Valores
|
||||||
|
</h3>
|
||||||
|
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
|
||||||
|
{isInstallment && (
|
||||||
|
<li className="mb-1">
|
||||||
|
<InstallmentTimeline
|
||||||
|
purchaseDate={parseLocalDateString(
|
||||||
|
transaction.purchaseDate,
|
||||||
|
)}
|
||||||
|
currentInstallment={parcelaAtual}
|
||||||
|
totalInstallments={totalParcelas}
|
||||||
|
period={transaction.period}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
{isInstallment && (
|
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Valor Restante"
|
label={isInstallment ? "Valor da Parcela" : "Valor"}
|
||||||
value={currencyFormatter.format(valorRestante)}
|
value={currencyFormatter.format(valorParcela)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{transaction.recurrenceCount && (
|
{isInstallment && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Quantidade de Recorrências"
|
label="Valor Restante"
|
||||||
value={`${transaction.recurrenceCount} meses`}
|
value={currencyFormatter.format(valorRestante)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isInstallment && <Separator className="my-2" />}
|
{transaction.recurrenceCount && (
|
||||||
|
<DetailRow
|
||||||
|
label="Quantidade de Recorrências"
|
||||||
|
value={`${transaction.recurrenceCount} meses`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<li className="flex items-center justify-between font-semibold">
|
{transaction.note ? (
|
||||||
<span className="text-muted-foreground">Total da Compra</span>
|
<section className="space-y-2">
|
||||||
<span className="text-lg">
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{currencyFormatter.format(valorTotal)}
|
Notas
|
||||||
</span>
|
</h3>
|
||||||
</li>
|
<div className="rounded-lg border p-3 text-foreground">
|
||||||
</ul>
|
{transaction.note}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{attachmentCount !== 0 && (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Anexos
|
||||||
|
</h3>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<AttachmentSection
|
||||||
|
transactionId={transaction.id}
|
||||||
|
seriesId={transaction.seriesId}
|
||||||
|
readonly
|
||||||
|
onLoaded={setAttachmentCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{onEdit && !transaction.readonly && (
|
{onEdit && !transaction.readonly && (
|
||||||
<Button variant="outline" onClick={handleEdit}>
|
<Button variant="outline" onClick={handleEdit}>
|
||||||
@@ -223,9 +257,9 @@ interface DetailRowProps {
|
|||||||
|
|
||||||
function DetailRow({ label, value }: DetailRowProps) {
|
function DetailRow({ label, value }: DetailRowProps) {
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center justify-between">
|
<li className="min-w-0 flex items-center justify-between gap-3">
|
||||||
<span className="text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground">{label}</span>
|
||||||
<span>{value}</span>
|
<span className="min-w-0 truncate">{value}</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
createTransactionAction,
|
createTransactionAction,
|
||||||
updateTransactionAction,
|
updateTransactionAction,
|
||||||
} from "@/features/transactions/actions";
|
} from "@/features/transactions/actions";
|
||||||
|
import {
|
||||||
|
confirmAttachmentUploadAction,
|
||||||
|
getPresignedUploadUrlAction,
|
||||||
|
} from "@/features/transactions/actions/attachments";
|
||||||
import {
|
import {
|
||||||
filterSecondaryPayerOptions,
|
filterSecondaryPayerOptions,
|
||||||
groupAndSortCategories,
|
groupAndSortCategories,
|
||||||
@@ -30,7 +34,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||||
|
import { AttachmentFilePicker } from "../../attachments/attachment-file-picker";
|
||||||
|
import { AttachmentSection } from "../../attachments/attachment-section";
|
||||||
import { BasicFieldsSection } from "./basic-fields-section";
|
import { BasicFieldsSection } from "./basic-fields-section";
|
||||||
import { BoletoFieldsSection } from "./boleto-fields-section";
|
import { BoletoFieldsSection } from "./boleto-fields-section";
|
||||||
import { CategorySection } from "./category-section";
|
import { CategorySection } from "./category-section";
|
||||||
@@ -90,6 +97,7 @@ export function TransactionDialog({
|
|||||||
);
|
);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
@@ -126,6 +134,7 @@ export function TransactionDialog({
|
|||||||
|
|
||||||
setFormState(initial);
|
setFormState(initial);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
setPendingFile(null);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
dialogOpen,
|
dialogOpen,
|
||||||
@@ -313,6 +322,29 @@ export function TransactionDialog({
|
|||||||
const result = await createTransactionAction(payload);
|
const result = await createTransactionAction(payload);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
if (pendingFile && result.data?.ids?.length) {
|
||||||
|
const firstId = result.data.ids[0];
|
||||||
|
const isNewSeries =
|
||||||
|
formState.condition === "Parcelado" ||
|
||||||
|
formState.condition === "Recorrente";
|
||||||
|
const presign = await getPresignedUploadUrlAction({
|
||||||
|
fileName: pendingFile.name,
|
||||||
|
mimeType: pendingFile.type,
|
||||||
|
fileSize: pendingFile.size,
|
||||||
|
transactionId: firstId,
|
||||||
|
});
|
||||||
|
if (presign.success) {
|
||||||
|
await fetch(presign.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: pendingFile,
|
||||||
|
headers: { "Content-Type": pendingFile.type },
|
||||||
|
});
|
||||||
|
await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presign.uploadToken,
|
||||||
|
applyToSeries: isNewSeries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -415,18 +447,18 @@ export function TransactionDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||||
<DialogContent>
|
<DialogContent className="min-w-0 overflow-x-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-0"
|
className="flex min-w-0 flex-col gap-0"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<div className="space-y-3 -mx-6 max-h-[70vh] overflow-y-auto px-6 pb-1">
|
<div className="min-w-0 space-y-3 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
||||||
<BasicFieldsSection
|
<BasicFieldsSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
@@ -477,17 +509,31 @@ export function TransactionDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isUpdateMode ? (
|
{isUpdateMode ? (
|
||||||
<NoteSection
|
<>
|
||||||
formState={formState}
|
<NoteSection
|
||||||
onFieldChange={handleFieldChange}
|
formState={formState}
|
||||||
/>
|
onFieldChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium leading-none">
|
||||||
|
Anexos
|
||||||
|
</Label>
|
||||||
|
<AttachmentSection
|
||||||
|
transactionId={transaction?.id ?? ""}
|
||||||
|
seriesId={transaction?.seriesId ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Collapsible defaultOpen={formState.condition !== "À vista"}>
|
<Collapsible
|
||||||
|
defaultOpen={formState.condition !== "À vista"}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
||||||
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
|
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
|
||||||
Condições e anotações
|
Condições, anotações e anexos
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="space-y-3 pt-3">
|
<CollapsibleContent className="min-w-0 overflow-hidden space-y-3 pt-3">
|
||||||
<ConditionSection
|
<ConditionSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
@@ -498,6 +544,10 @@ export function TransactionDialog({
|
|||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
/>
|
/>
|
||||||
|
<AttachmentFilePicker
|
||||||
|
file={pendingFile}
|
||||||
|
onChange={setPendingFile}
|
||||||
|
/>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RiArrowLeftSLine,
|
RiArrowLeftSLine,
|
||||||
RiArrowRightDoubleLine,
|
RiArrowRightDoubleLine,
|
||||||
RiArrowRightSLine,
|
RiArrowRightSLine,
|
||||||
|
RiAttachment2,
|
||||||
RiBankCard2Line,
|
RiBankCard2Line,
|
||||||
RiChat1Line,
|
RiChat1Line,
|
||||||
RiCheckboxBlankCircleLine,
|
RiCheckboxBlankCircleLine,
|
||||||
@@ -115,6 +116,14 @@ type BuildColumnsArgs = {
|
|||||||
showActions: boolean;
|
showActions: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getPaymentMethodTableLabel(method: string) {
|
||||||
|
if (method === "Transferência bancária") {
|
||||||
|
return "Transf. bancária";
|
||||||
|
}
|
||||||
|
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
const buildColumns = ({
|
const buildColumns = ({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
noteAsColumn,
|
noteAsColumn,
|
||||||
@@ -182,6 +191,7 @@ const buildColumns = ({
|
|||||||
note,
|
note,
|
||||||
isDivided,
|
isDivided,
|
||||||
isAnticipated,
|
isAnticipated,
|
||||||
|
hasAttachments,
|
||||||
} = row.original;
|
} = row.original;
|
||||||
|
|
||||||
const installmentBadge =
|
const installmentBadge =
|
||||||
@@ -191,7 +201,7 @@ const buildColumns = ({
|
|||||||
|
|
||||||
const isBoleto = paymentMethod === "Boleto" && dueDate;
|
const isBoleto = paymentMethod === "Boleto" && dueDate;
|
||||||
const dueDateLabel =
|
const dueDateLabel =
|
||||||
isBoleto && dueDate ? `Venc. ${formatDate(dueDate)}` : null;
|
isBoleto && dueDate ? `venc. ${formatDate(dueDate)}` : null;
|
||||||
const hasNote = Boolean(note?.trim().length);
|
const hasNote = Boolean(note?.trim().length);
|
||||||
const isLastInstallment =
|
const isLastInstallment =
|
||||||
currentInstallment === installmentCount &&
|
currentInstallment === installmentCount &&
|
||||||
@@ -201,13 +211,18 @@ const buildColumns = ({
|
|||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<EstablishmentLogo name={name} size={28} />
|
<EstablishmentLogo name={name} size={28} />
|
||||||
<span className="flex flex-col">
|
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="flex flex-col py-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
{formatDate(purchaseDate)}
|
{formatDate(purchaseDate)}
|
||||||
|
|
||||||
|
{dueDateLabel ? (
|
||||||
|
<span className="text-primary">{dueDateLabel}</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="line-clamp-2 max-w-[160px] font-semibold truncate">
|
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -259,12 +274,6 @@ const buildColumns = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{dueDateLabel ? (
|
|
||||||
<Badge variant="outline" className="px-2 text-xs">
|
|
||||||
{dueDateLabel}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isAnticipated && (
|
{isAnticipated && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -301,6 +310,21 @@ const buildColumns = ({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{hasAttachments ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex rounded-full p-1">
|
||||||
|
<RiAttachment2
|
||||||
|
className="h-4 w-4 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Possui anexos</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Possui anexos</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -366,7 +390,7 @@ const buildColumns = ({
|
|||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
<span>{method}</span>
|
<span>{getPaymentMethodTableLabel(method)}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type TransactionItem = {
|
|||||||
isAnticipated: boolean;
|
isAnticipated: boolean;
|
||||||
anticipationId: string | null;
|
anticipationId: string | null;
|
||||||
seriesId: string | null;
|
seriesId: string | null;
|
||||||
|
hasAttachments: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ type TransactionRowWithRelations = Partial<typeof transactions.$inferSelect> & {
|
|||||||
financialAccount?: AccountRow | null;
|
financialAccount?: AccountRow | null;
|
||||||
card?: CardRow | null;
|
card?: CardRow | null;
|
||||||
category?: CategoryRow | null;
|
category?: CategoryRow | null;
|
||||||
|
hasAttachments?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
|
export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
|
||||||
@@ -442,6 +443,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,
|
||||||
|
hasAttachments: item.hasAttachments ?? false,
|
||||||
readonly:
|
readonly:
|
||||||
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||||
item.category?.name === "Saldo inicial" ||
|
item.category?.name === "Saldo inicial" ||
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
categories,
|
categories,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
payers,
|
payers,
|
||||||
|
transactionAttachments,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
||||||
@@ -74,6 +75,7 @@ const mapTransactionRows = (
|
|||||||
financialAccount: typeof financialAccounts.$inferSelect | null;
|
financialAccount: typeof financialAccounts.$inferSelect | null;
|
||||||
card: typeof cards.$inferSelect | null;
|
card: typeof cards.$inferSelect | null;
|
||||||
category: typeof categories.$inferSelect | null;
|
category: typeof categories.$inferSelect | null;
|
||||||
|
hasAttachments: boolean;
|
||||||
}[],
|
}[],
|
||||||
) =>
|
) =>
|
||||||
transactionRows.map((row) => ({
|
transactionRows.map((row) => ({
|
||||||
@@ -82,6 +84,7 @@ const mapTransactionRows = (
|
|||||||
financialAccount: row.financialAccount,
|
financialAccount: row.financialAccount,
|
||||||
card: row.card,
|
card: row.card,
|
||||||
category: row.category,
|
category: row.category,
|
||||||
|
hasAttachments: row.hasAttachments,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
async function selectTransactionsWithRelations({
|
async function selectTransactionsWithRelations({
|
||||||
@@ -98,6 +101,10 @@ async function selectTransactionsWithRelations({
|
|||||||
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))
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-10 sm:max-w-xl",
|
"bg-background fixed top-[50%] left-[50%] z-50 grid w-full min-w-0 max-w-[calc(100%-2rem)] max-h-[90vh] overflow-x-hidden overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-10 sm:max-w-xl",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export const CATEGORY_ICON_OPTIONS: CategoryIconOption[] = [
|
|||||||
{ label: "Chave", value: "RiKeyLine" },
|
{ label: "Chave", value: "RiKeyLine" },
|
||||||
{ label: "Configurações", value: "RiSettings3Line" },
|
{ label: "Configurações", value: "RiSettings3Line" },
|
||||||
{ label: "Link", value: "RiLinkLine" },
|
{ label: "Link", value: "RiLinkLine" },
|
||||||
{ label: "Anexo", value: "RiAttachmentLine" },
|
{ label: "Anexo", value: "RiAttachment2" },
|
||||||
{ label: "Download", value: "RiDownloadLine" },
|
{ label: "Download", value: "RiDownloadLine" },
|
||||||
{ label: "Upload", value: "RiUploadLine" },
|
{ label: "Upload", value: "RiUploadLine" },
|
||||||
{ label: "Nuvem Download", value: "RiCloudDownloadLine" },
|
{ label: "Nuvem Download", value: "RiCloudDownloadLine" },
|
||||||
@@ -327,7 +327,7 @@ export const CATEGORY_ICON_GROUPS: CategoryIconGroup[] = [
|
|||||||
{ label: "Chave", value: "RiKeyLine" },
|
{ label: "Chave", value: "RiKeyLine" },
|
||||||
{ label: "Configurações", value: "RiSettings3Line" },
|
{ label: "Configurações", value: "RiSettings3Line" },
|
||||||
{ label: "Link", value: "RiLinkLine" },
|
{ label: "Link", value: "RiLinkLine" },
|
||||||
{ label: "Anexo", value: "RiAttachmentLine" },
|
{ label: "Anexo", value: "RiAttachment2" },
|
||||||
{ label: "Download", value: "RiDownloadLine" },
|
{ label: "Download", value: "RiDownloadLine" },
|
||||||
{ label: "Upload", value: "RiUploadLine" },
|
{ label: "Upload", value: "RiUploadLine" },
|
||||||
{ label: "Nuvem Download", value: "RiCloudDownloadLine" },
|
{ label: "Nuvem Download", value: "RiCloudDownloadLine" },
|
||||||
|
|||||||
52
src/shared/lib/storage/presign.ts
Normal file
52
src/shared/lib/storage/presign.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
import { S3_BUCKET, s3 } from "./s3-client";
|
||||||
|
|
||||||
|
export async function createPresignedPutUrl(
|
||||||
|
fileKey: string,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Key: fileKey,
|
||||||
|
ContentType: mimeType,
|
||||||
|
});
|
||||||
|
return getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutos
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPresignedGetUrl(fileKey: string): Promise<string> {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Key: fileKey,
|
||||||
|
});
|
||||||
|
return getSignedUrl(s3, command, { expiresIn: 3600 }); // 1 hora
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function headS3Object(fileKey: string): Promise<{
|
||||||
|
contentLength: number | null;
|
||||||
|
contentType: string | null;
|
||||||
|
}> {
|
||||||
|
const command = new HeadObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Key: fileKey,
|
||||||
|
});
|
||||||
|
const result = await s3.send(command);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentLength: result.ContentLength ?? null,
|
||||||
|
contentType: result.ContentType ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteS3Object(fileKey: string): Promise<void> {
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Key: fileKey,
|
||||||
|
});
|
||||||
|
await s3.send(command);
|
||||||
|
}
|
||||||
13
src/shared/lib/storage/s3-client.ts
Normal file
13
src/shared/lib/storage/s3-client.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { S3Client } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
|
export const s3 = new S3Client({
|
||||||
|
endpoint: process.env.S3_ENDPOINT ?? "",
|
||||||
|
region: process.env.S3_REGION ?? "auto",
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
|
||||||
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const S3_BUCKET = process.env.S3_BUCKET ?? "attachments";
|
||||||
Reference in New Issue
Block a user