8 Commits

Author SHA1 Message Date
Felipe Coutinho
20d0c3e0a7 chore(docs): atualizar regra de versionamento no CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:56 +00:00
Felipe Coutinho
71b5a004e3 chore: ajustes de formatação e configuração
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:27 +00:00
Felipe Coutinho
65b1506d75 chore(release): publicar versão 2.1.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:23 +00:00
Felipe Coutinho
2a458d5a3c chore(configurações): redesign visual da página de configurações
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:19 +00:00
Felipe Coutinho
f418987f47 feat(lançamentos): escopo "period" na ação em lote e correção do fluxo de anexos em séries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:33 +00:00
Felipe Coutinho
59b4dea071 feat(preferências): configuração de tamanho máximo de anexo por arquivo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:28 +00:00
Felipe Coutinho
6ce132fe0c feat(db): adicionar coluna attachmentMaxSizeMb em userPreferences
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:45:41 +00:00
Felipe Coutinho
49731238e4 Update version badge from 2.1.0 to 2.1.1 2026-03-29 11:14:23 -03:00
39 changed files with 3830 additions and 516 deletions

View File

@@ -7,6 +7,22 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [Unreleased]
## [2.1.2] - 2026-03-30
### Adicionado
- Preferências: nova configuração de tamanho máximo por arquivo de anexo (5, 10, 25, 50 ou 100 MB), persistida no banco e respeitada em todos os pontos de upload
- Lançamentos: novo escopo `"period"` na ação em lote, que aplica a alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um
### Corrigido
- Lançamentos: ao editar um lançamento de série, uploads e remoções de anexo agora aguardam a escolha de escopo da ação em lote antes de serem executados, evitando que o anexo fosse aplicado no lançamento errado
- Lançamentos: ação em lote com escopo `"period"` não sobrescreve mais o `payerId` individual de cada lançamento ao alterar o pagador
### Alterado
- Configurações: redesign visual da página com separadores entre seções e títulos maiores
- Configurações: seção "Extrato e lançamentos" renomeada para "Lançamentos"
## [2.1.1] - 2026-03-29 ## [2.1.1] - 2026-03-29
### Adicionado ### Adicionado

View File

@@ -16,7 +16,7 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`. 3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`. 4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
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, também altere o `package.json`.
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. 8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1,174 +1,181 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1762993507299, "when": 1762993507299,
"tag": "0000_flashy_manta", "tag": "0000_flashy_manta",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1765199006435, "when": 1765199006435,
"tag": "0001_young_mister_fear", "tag": "0001_young_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1765200545692, "when": 1765200545692,
"tag": "0002_slimy_flatman", "tag": "0002_slimy_flatman",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1767102605526, "when": 1767102605526,
"tag": "0003_green_korg", "tag": "0003_green_korg",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "7", "version": "7",
"when": 1767104066872, "when": 1767104066872,
"tag": "0004_acoustic_mach_iv", "tag": "0004_acoustic_mach_iv",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "7", "version": "7",
"when": 1767106121811, "when": 1767106121811,
"tag": "0005_adorable_bruce_banner", "tag": "0005_adorable_bruce_banner",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "7", "version": "7",
"when": 1767107487318, "when": 1767107487318,
"tag": "0006_youthful_mister_fear", "tag": "0006_youthful_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1767118780033, "when": 1767118780033,
"tag": "0007_sturdy_kate_bishop", "tag": "0007_sturdy_kate_bishop",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1767125796314, "when": 1767125796314,
"tag": "0008_fat_stick", "tag": "0008_fat_stick",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1768925100873, "when": 1768925100873,
"tag": "0009_add_dashboard_widgets", "tag": "0009_add_dashboard_widgets",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 10, "idx": 10,
"version": "7", "version": "7",
"when": 1769369834242, "when": 1769369834242,
"tag": "0010_lame_psynapse", "tag": "0010_lame_psynapse",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 11, "idx": 11,
"version": "7", "version": "7",
"when": 1769447087678, "when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns", "tag": "0011_remove_unused_inbox_columns",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 12, "idx": 12,
"version": "7", "version": "7",
"when": 1769533200000, "when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese", "tag": "0012_rename_tables_to_portuguese",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 13, "idx": 13,
"version": "7", "version": "7",
"when": 1769523352777, "when": 1769523352777,
"tag": "0013_fancy_rick_jones", "tag": "0013_fancy_rick_jones",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 14, "idx": 14,
"version": "7", "version": "7",
"when": 1769619226903, "when": 1769619226903,
"tag": "0014_yielding_jack_flag", "tag": "0014_yielding_jack_flag",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 15, "idx": 15,
"version": "7", "version": "7",
"when": 1770332054481, "when": 1770332054481,
"tag": "0015_concerned_kat_farrell", "tag": "0015_concerned_kat_farrell",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 16, "idx": 16,
"version": "7", "version": "7",
"when": 1771166328908, "when": 1771166328908,
"tag": "0016_complete_randall", "tag": "0016_complete_randall",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 17, "idx": 17,
"version": "7", "version": "7",
"when": 1772400510326, "when": 1772400510326,
"tag": "0017_previous_warstar", "tag": "0017_previous_warstar",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 18, "idx": 18,
"version": "7", "version": "7",
"when": 1773020417482, "when": 1773020417482,
"tag": "0018_rainy_epoch", "tag": "0018_rainy_epoch",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 19, "idx": 19,
"version": "7", "version": "7",
"when": 1773699152928, "when": 1773699152928,
"tag": "0019_ordinary_wild_pack", "tag": "0019_ordinary_wild_pack",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 20, "idx": 20,
"version": "7", "version": "7",
"when": 1773841892114, "when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints", "tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 21, "idx": 21,
"version": "7", "version": "7",
"when": 1774033320053, "when": 1774033320053,
"tag": "0021_careful_malcolm_colcord", "tag": "0021_careful_malcolm_colcord",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 22, "idx": 22,
"version": "7", "version": "7",
"when": 1748000000000, "when": 1748000000000,
"tag": "0022_import-category-mappings", "tag": "0022_import-category-mappings",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 23, "idx": 23,
"version": "7", "version": "7",
"when": 1774529878374, "when": 1774529878374,
"tag": "0023_sturdy_wolfpack", "tag": "0023_sturdy_wolfpack",
"breakpoints": true "breakpoints": true
} },
] {
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
}
]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.1.1", "version": "2.1.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",

View File

@@ -190,6 +190,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={false} allowCreate={false}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</section> </section>
</main> </main>

View File

@@ -202,6 +202,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate allowCreate
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
defaultCardId={card.id} defaultCardId={card.id}
defaultPaymentMethod="Cartão de crédito" defaultPaymentMethod="Cartão de crédito"
lockCardSelection lockCardSelection

View File

@@ -99,6 +99,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={true} allowCreate={true}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

View File

@@ -390,6 +390,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={canEdit} allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions} importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions} importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId} importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}

View File

@@ -1,4 +1,4 @@
import { RiArrowRightSLine } from "@remixicon/react"; import { RiAndroidLine, RiArrowRightSLine } from "@remixicon/react";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -11,6 +11,7 @@ import { UpdateNameForm } from "@/features/settings/components/update-name-form"
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form"; import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
import { fetchSettingsPageData } from "@/features/settings/queries"; import { fetchSettingsPageData } from "@/features/settings/queries";
import { Card } from "@/shared/components/ui/card"; import { Card } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@@ -64,12 +65,13 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Preferências</h2> <h2 className="text-xl font-bold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Personalize sua experiência no OpenMonetis ajustando as Personalize sua experiência no OpenMonetis ajustando as
configurações de acordo com suas necessidades. configurações de acordo com suas necessidades.
</p> </p>
</div> </div>
<Separator />
<PreferencesForm <PreferencesForm
statementNoteAsColumn={ statementNoteAsColumn={
userPreferences?.statementNoteAsColumn ?? false userPreferences?.statementNoteAsColumn ?? false
@@ -77,25 +79,46 @@ export default async function Page() {
transactionsColumnOrder={ transactionsColumnOrder={
userPreferences?.transactionsColumnOrder ?? null userPreferences?.transactionsColumnOrder ?? null
} }
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</div> </div>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="companion" className="mt-4"> <TabsContent value="companion" className="mt-4">
<CompanionTab tokens={userApiTokens} /> <Card className="p-6">
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-bold">OpenMonetis Companion</h2>
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
<RiAndroidLine className="h-3 w-3" />
Android
</span>
</div>
<p className="text-sm text-muted-foreground">
Capture notificações de transações dos seus apps de banco
(Nubank, Itaú, Bradesco, Inter, C6 e outros) e envie para sua
caixa de entrada.
</p>
</div>
<Separator />
<CompanionTab tokens={userApiTokens} />
</div>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="nome" className="mt-4"> <TabsContent value="nome" className="mt-4">
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Alterar nome</h2> <h2 className="text-xl font-bold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Atualize como seu nome aparece no OpenMonetis. Esse nome pode Atualize como seu nome aparece no OpenMonetis. Esse nome pode
ser exibido em diferentes seções do app e em comunicações. ser exibido em diferentes seções do app e em comunicações.
</p> </p>
</div> </div>
<Separator />
<UpdateNameForm currentName={userName} /> <UpdateNameForm currentName={userName} />
</div> </div>
</Card> </Card>
@@ -105,12 +128,13 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Alterar senha</h2> <h2 className="text-xl font-bold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Defina uma nova senha para sua conta. Guarde-a em local Defina uma nova senha para sua conta. Guarde-a em local
seguro. seguro.
</p> </p>
</div> </div>
<Separator />
<UpdatePasswordForm authProvider={authProvider} /> <UpdatePasswordForm authProvider={authProvider} />
</div> </div>
</Card> </Card>
@@ -120,12 +144,13 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Passkeys</h2> <h2 className="text-xl font-bold mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Passkeys permitem login sem senha, usando biometria (Face ID, Passkeys permitem login sem senha, usando biometria (Face ID,
Touch ID, Windows Hello) ou chaves de segurança. Touch ID, Windows Hello) ou chaves de segurança.
</p> </p>
</div> </div>
<Separator />
<PasskeysForm /> <PasskeysForm />
</div> </div>
</Card> </Card>
@@ -135,13 +160,14 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2> <h2 className="text-xl font-bold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Atualize o e-mail associado à sua conta. Você precisará Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail confirmar os links enviados para o novo e também para o e-mail
atual (quando aplicável) para concluir a alteração. atual (quando aplicável) para concluir a alteração.
</p> </p>
</div> </div>
<Separator />
<UpdateEmailForm <UpdateEmailForm
currentEmail={userEmail} currentEmail={userEmail}
authProvider={authProvider} authProvider={authProvider}
@@ -154,14 +180,15 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1 text-destructive"> <h2 className="text-xl font-bold mb-1 text-destructive">
Ações perigosas Ações perigosas
</h2> </h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Você pode zerar os dados do OpenMonetis e manter seu acesso, Você pode zerar os dados do OpenMonetis e manter seu acesso,
ou excluir sua conta inteira de forma irreversível. ou excluir sua conta inteira de forma irreversível.
</p> </p>
</div> </div>
<Separator />
<DeleteAccountForm /> <DeleteAccountForm />
</div> </div>
</Card> </Card>

View File

@@ -102,6 +102,7 @@ export default async function Page({ searchParams }: PageProps) {
}} }}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

View File

@@ -135,6 +135,7 @@ export const userPreferences = pgTable("preferencias_usuario", {
transactionsColumnOrder: jsonb("lancamentos_column_order").$type< transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
string[] | null string[] | null
>(), >(),
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
dashboardWidgets: jsonb("dashboard_widgets").$type<{ dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[]; order: string[];
hidden: string[]; hidden: string[];

View File

@@ -125,7 +125,9 @@ export function LoginForm({ className, ...props }: DivProps) {
}); });
if (passkeyError) { if (passkeyError) {
setError((passkeyError.message as string) || "Erro ao entrar com passkey."); setError(
(passkeyError.message as string) || "Erro ao entrar com passkey.",
);
setLoadingPasskey(false); setLoadingPasskey(false);
} }
} }

View File

@@ -41,7 +41,7 @@ const baseSchema = z.object({
.string() .string()
.trim() .trim()
.email("Informe um e-mail válido.") .email("Informe um e-mail válido.")
.optional() .nullish()
.transform((value) => normalizeOptionalString(value)), .transform((value) => normalizeOptionalString(value)),
status: statusEnum, status: statusEnum,
note: noteSchema, note: noteSchema,

View File

@@ -65,6 +65,7 @@ const resetAccountSchema = z.object({
const updatePreferencesSchema = z.object({ const updatePreferencesSchema = z.object({
statementNoteAsColumn: z.boolean(), statementNoteAsColumn: z.boolean(),
transactionsColumnOrder: z.array(z.string()).nullable(), transactionsColumnOrder: z.array(z.string()).nullable(),
attachmentMaxSizeMb: z.number().int().min(1).max(100),
}); });
type ResettableUser = { type ResettableUser = {
@@ -561,6 +562,7 @@ export async function updatePreferencesAction(
.set({ .set({
statementNoteAsColumn: validated.statementNoteAsColumn, statementNoteAsColumn: validated.statementNoteAsColumn,
transactionsColumnOrder: validated.transactionsColumnOrder, transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(schema.userPreferences.userId, session.user.id)); .where(eq(schema.userPreferences.userId, session.user.id));
@@ -570,6 +572,7 @@ export async function updatePreferencesAction(
userId: session.user.id, userId: session.user.id,
statementNoteAsColumn: validated.statementNoteAsColumn, statementNoteAsColumn: validated.statementNoteAsColumn,
transactionsColumnOrder: validated.transactionsColumnOrder, transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
}); });
} }

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
RiAndroidLine,
RiDownload2Line, RiDownload2Line,
RiExternalLinkLine, RiExternalLinkLine,
RiNotification3Line, RiNotification3Line,
@@ -9,7 +8,6 @@ import {
RiShieldCheckLine, RiShieldCheckLine,
} from "@remixicon/react"; } from "@remixicon/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Card } from "@/shared/components/ui/card";
import { ApiTokensForm } from "./api-tokens-form"; import { ApiTokensForm } from "./api-tokens-form";
interface ApiToken { interface ApiToken {
@@ -69,49 +67,28 @@ const steps: {
export function CompanionTab({ tokens }: CompanionTabProps) { export function CompanionTab({ tokens }: CompanionTabProps) {
return ( return (
<Card className="p-6"> <div className="space-y-6">
<div className="space-y-6"> {/* Steps */}
{/* Header */} <div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
<div> {steps.map((step, index) => (
<div className="flex items-center gap-2 mb-1"> <div key={step.title} className="flex items-start gap-2">
<h2 className="text-lg font-bold">OpenMonetis Companion</h2> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10"> <step.icon className="h-4 w-4" />
<RiAndroidLine className="h-3 w-3" />
Android
</span>
</div>
<p className="text-sm text-muted-foreground">
Capture notificações de transações dos seus apps de banco (Nubank,
Itaú, Bradesco, Inter, C6 e outros) e envie para sua caixa de
entrada.
</p>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
{steps.map((step, index) => (
<div key={step.title} className="flex items-start gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<step.icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">
{index + 1}. {step.title}
</p>
<p className="text-xs text-muted-foreground">
{step.description}
</p>
</div>
</div> </div>
))} <div className="min-w-0">
</div> <p className="text-sm font-medium leading-tight">
{index + 1}. {step.title}
{/* Divider */} </p>
<div className="border-t" /> <p className="text-xs text-muted-foreground">
{step.description}
{/* Devices */} </p>
<ApiTokensForm tokens={tokens} /> </div>
</div>
))}
</div> </div>
</Card>
{/* Devices */}
<ApiTokensForm tokens={tokens} />
</div>
); );
} }

View File

@@ -73,7 +73,9 @@ export function PasskeysForm() {
const { data, error: fetchError } = const { data, error: fetchError } =
await authClient.passkey.listUserPasskeys(); await authClient.passkey.listUserPasskeys();
if (fetchError) { if (fetchError) {
setError((fetchError.message as string) || "Erro ao carregar passkeys."); setError(
(fetchError.message as string) || "Erro ao carregar passkeys.",
);
return; return;
} }
setPasskeys( setPasskeys(
@@ -134,7 +136,9 @@ export function PasskeysForm() {
name: editName.trim(), name: editName.trim(),
}); });
if (renameError) { if (renameError) {
setError((renameError.message as string) || "Erro ao renomear passkey."); setError(
(renameError.message as string) || "Erro ao renomear passkey.",
);
return; return;
} }
setEditingId(null); setEditingId(null);

View File

@@ -21,17 +21,27 @@ import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { updatePreferencesAction } from "@/features/settings/actions"; import { updatePreferencesAction } from "@/features/settings/actions";
import {
ATTACHMENT_SIZE_OPTIONS,
type AttachmentSizeOption,
} from "@/features/transactions/attachments-config";
import { import {
DEFAULT_LANCAMENTOS_COLUMN_ORDER, DEFAULT_LANCAMENTOS_COLUMN_ORDER,
LANCAMENTOS_COLUMN_LABELS, LANCAMENTOS_COLUMN_LABELS,
} from "@/features/transactions/column-order"; } from "@/features/transactions/column-order";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { Separator } from "@/shared/components/ui/separator";
import { Switch } from "@/shared/components/ui/switch"; import { Switch } from "@/shared/components/ui/switch";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
interface PreferencesFormProps { interface PreferencesFormProps {
statementNoteAsColumn: boolean; statementNoteAsColumn: boolean;
transactionsColumnOrder: string[] | null; transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
} }
function SortableColumnItem({ id }: { id: string }) { function SortableColumnItem({ id }: { id: string }) {
@@ -74,6 +84,7 @@ function SortableColumnItem({ id }: { id: string }) {
export function PreferencesForm({ export function PreferencesForm({
statementNoteAsColumn: initialExtratoNoteAsColumn, statementNoteAsColumn: initialExtratoNoteAsColumn,
transactionsColumnOrder: initialColumnOrder, transactionsColumnOrder: initialColumnOrder,
attachmentMaxSizeMb: initialAttachmentMaxSizeMb,
}: PreferencesFormProps) { }: PreferencesFormProps) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -85,6 +96,14 @@ export function PreferencesForm({
? initialColumnOrder ? initialColumnOrder
: DEFAULT_LANCAMENTOS_COLUMN_ORDER, : DEFAULT_LANCAMENTOS_COLUMN_ORDER,
); );
const [attachmentMaxSizeMb, setAttachmentMaxSizeMb] =
useState<AttachmentSizeOption>(
(ATTACHMENT_SIZE_OPTIONS.includes(
initialAttachmentMaxSizeMb as AttachmentSizeOption,
)
? initialAttachmentMaxSizeMb
: 50) as AttachmentSizeOption,
);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -109,6 +128,7 @@ export function PreferencesForm({
const result = await updatePreferencesAction({ const result = await updatePreferencesAction({
statementNoteAsColumn, statementNoteAsColumn,
transactionsColumnOrder: columnOrder, transactionsColumnOrder: columnOrder,
attachmentMaxSizeMb,
}); });
if (result.success) { if (result.success) {
@@ -122,19 +142,18 @@ export function PreferencesForm({
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-8"> <form onSubmit={handleSubmit} className="flex flex-col gap-8">
{/* Seção: Extrato / Lançamentos */} {/* Seção: Lançamentos */}
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<h3 className="text-base font-semibold">Extrato e lançamentos</h3> <h3 className="text-base font-semibold">Lançamentos</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Como exibir anotações e a ordem das colunas na tabela de Configurações de exibição da tabela de movimentações.
movimentações.
</p> </p>
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md"> <section className="flex items-center justify-between max-w-md">
<div className="space-y-0.5"> <div className="space-y-2">
<Label htmlFor="extrato-note-column" className="text-base"> <Label htmlFor="extrato-note-column" className="text-sm">
Anotações em coluna Anotações em coluna
</Label> </Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -149,10 +168,12 @@ export function PreferencesForm({
onCheckedChange={setExtratoNoteAsColumn} onCheckedChange={setExtratoNoteAsColumn}
disabled={isPending} disabled={isPending}
/> />
</div> </section>
<div className="space-y-2 max-w-md"> <Separator />
<Label className="text-base">Ordem das colunas</Label>
<section className="space-y-2 max-w-md">
<Label className="text-sm">Ordem das colunas</Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Arraste os itens para definir a ordem em que as colunas aparecem na Arraste os itens para definir a ordem em que as colunas aparecem na
tabela do extrato e dos lançamentos. tabela do extrato e dos lançamentos.
@@ -173,7 +194,43 @@ export function PreferencesForm({
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</div> </section>
<Separator />
<section className="space-y-2">
<Label className="text-sm">Anexos</Label>
<p className="text-sm text-muted-foreground">
Configurações de upload de arquivos nos lançamentos.
</p>
<div className="space-y-2 max-w-md mt-4">
<Label>Tamanho máximo por arquivo</Label>
<p className="text-sm text-muted-foreground">
Limite aplicado ao upload de PDFs e imagens.
</p>
<ToggleGroup
type="single"
value={String(attachmentMaxSizeMb)}
onValueChange={(val) => {
if (val)
setAttachmentMaxSizeMb(Number(val) as AttachmentSizeOption);
}}
className="flex flex-wrap gap-2 justify-start"
>
{ATTACHMENT_SIZE_OPTIONS.map((size) => (
<ToggleGroupItem
key={size}
value={String(size)}
aria-label={`${size} MB`}
className="min-w-14"
>
{size} MB
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</section>
</section> </section>
<div className="flex justify-end"> <div className="flex justify-end">

View File

@@ -5,6 +5,7 @@ import { db, schema } from "@/shared/lib/db";
export interface UserPreferences { export interface UserPreferences {
statementNoteAsColumn: boolean; statementNoteAsColumn: boolean;
transactionsColumnOrder: string[] | null; transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
} }
export interface ApiToken { export interface ApiToken {
@@ -32,6 +33,7 @@ export async function fetchUserPreferences(
.select({ .select({
statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn, statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn,
transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder, transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder,
attachmentMaxSizeMb: schema.userPreferences.attachmentMaxSizeMb,
}) })
.from(schema.userPreferences) .from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId)) .where(eq(schema.userPreferences.userId, userId))

View File

@@ -33,7 +33,7 @@ const presignSchema = z.object({
const confirmSchema = z.object({ const confirmSchema = z.object({
uploadToken: z.string().min(1), uploadToken: z.string().min(1),
applyToSeries: z.boolean().default(false), scope: z.enum(["current", "period", "future", "all"]).default("current"),
}); });
const detachSchema = z.object({ const detachSchema = z.object({
@@ -183,7 +183,7 @@ export async function getPresignedUploadUrlAction(input: {
export async function confirmAttachmentUploadAction(input: { export async function confirmAttachmentUploadAction(input: {
uploadToken: string; uploadToken: string;
applyToSeries?: boolean; scope?: "current" | "period" | "future" | "all";
}): Promise<ActionResult> { }): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -195,7 +195,11 @@ export async function confirmAttachmentUploadAction(input: {
} }
const [transaction] = await db const [transaction] = await db
.select({ id: transactions.id, seriesId: transactions.seriesId }) .select({
id: transactions.id,
seriesId: transactions.seriesId,
period: transactions.period,
})
.from(transactions) .from(transactions)
.where( .where(
and( and(
@@ -253,9 +257,9 @@ export async function confirmAttachmentUploadAction(input: {
let transactionIds: string[] = [uploadPayload.transactionId]; let transactionIds: string[] = [uploadPayload.transactionId];
if (data.applyToSeries && transaction.seriesId) { if (data.scope !== "current" && transaction.seriesId) {
const seriesRows = await db const seriesRows = await db
.select({ id: transactions.id }) .select({ id: transactions.id, period: transactions.period })
.from(transactions) .from(transactions)
.where( .where(
and( and(
@@ -263,7 +267,18 @@ export async function confirmAttachmentUploadAction(input: {
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
), ),
); );
transactionIds = seriesRows.map((t) => t.id);
if (data.scope === "period") {
transactionIds = seriesRows
.filter((r) => r.period === transaction.period)
.map((r) => r.id);
} else if (data.scope === "future") {
transactionIds = seriesRows
.filter((r) => (r.period ?? "") >= (transaction.period ?? ""))
.map((r) => r.id);
} else {
transactionIds = seriesRows.map((r) => r.id);
}
} }
await db.insert(transactionAttachments).values( await db.insert(transactionAttachments).values(
@@ -407,6 +422,110 @@ export async function fetchTransactionAttachmentsAction(
); );
} }
const detachBulkSchema = z.object({
attachmentId: z.string().uuid(),
transactionId: z.string().uuid(),
scope: z.enum(["current", "period", "future", "all"]),
});
export async function detachAttachmentBulkAction(input: {
attachmentId: string;
transactionId: string;
scope: "current" | "period" | "future" | "all";
}): Promise<ActionResult> {
try {
const user = await getUser();
const data = detachBulkSchema.parse(input);
const [baseTransaction] = await db
.select({
id: transactions.id,
seriesId: transactions.seriesId,
period: transactions.period,
})
.from(transactions)
.where(
and(
eq(transactions.id, data.transactionId),
eq(transactions.userId, user.id),
),
);
if (!baseTransaction) {
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." };
}
let targetTransactionIds: string[];
if (data.scope === "current" || !baseTransaction.seriesId) {
targetTransactionIds = [data.transactionId];
} else {
const seriesRows = await db
.select({ id: transactions.id, period: transactions.period })
.from(transactions)
.where(
and(
eq(transactions.seriesId, baseTransaction.seriesId),
eq(transactions.userId, user.id),
),
);
if (data.scope === "period") {
targetTransactionIds = seriesRows
.filter((r) => r.period === baseTransaction.period)
.map((r) => r.id);
} else if (data.scope === "future") {
targetTransactionIds = seriesRows
.filter((r) => (r.period ?? "") >= (baseTransaction.period ?? ""))
.map((r) => r.id);
} else {
targetTransactionIds = seriesRows.map((r) => r.id);
}
}
if (targetTransactionIds.length > 0) {
await db
.delete(transactionAttachments)
.where(
and(
inArray(transactionAttachments.transactionId, targetTransactionIds),
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);
}
}
/** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */ /** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */
export async function cleanupAttachmentsAfterTransactionDelete( export async function cleanupAttachmentsAfterTransactionDelete(
attachmentData: Array<{ id: string; fileKey: string }>, attachmentData: Array<{ id: string; fileKey: string }>,

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
import { transactions } from "@/db/schema"; import { transactions } from "@/db/schema";
import { import {
PAYMENT_METHODS, PAYMENT_METHODS,
@@ -80,6 +80,24 @@ export async function deleteTransactionBulkAction(
return { success: true, message: "Lançamento removido com sucesso." }; return { success: true, message: "Lançamento removido com sucesso." };
} }
if (data.scope === "period") {
await db
.delete(transactions)
.where(
and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
eq(transactions.period, existing.period ?? ""),
),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos do período foram removidos.",
};
}
if (data.scope === "future") { if (data.scope === "future") {
await db await db
.delete(transactions) .delete(transactions)
@@ -147,6 +165,7 @@ export async function updateTransactionBulkAction(
condition: true, condition: true,
transactionType: true, transactionType: true,
purchaseDate: true, purchaseDate: true,
payerId: true,
}, },
where: and( where: and(
eq(transactions.id, data.id), eq(transactions.id, data.id),
@@ -169,7 +188,8 @@ export async function updateTransactionBulkAction(
name: data.name, name: data.name,
categoryId: data.categoryId ?? null, categoryId: data.categoryId ?? null,
note: data.note ?? null, note: data.note ?? null,
payerId: data.payerId ?? null, // "period" atualiza todos os pagadores do mês — preserva o payerId de cada linha
...(data.scope !== "period" && { payerId: data.payerId ?? null }),
accountId: data.accountId ?? null, accountId: data.accountId ?? null,
cardId: data.cardId ?? null, cardId: data.cardId ?? null,
...(data.isSettled !== undefined && { isSettled: data.isSettled }), ...(data.isSettled !== undefined && { isSettled: data.isSettled }),
@@ -309,6 +329,42 @@ export async function updateTransactionBulkAction(
return { success: true, message: "Lançamento atualizado com sucesso." }; return { success: true, message: "Lançamento atualizado com sucesso." };
} }
if (data.scope === "period") {
if (!existing.period) {
return {
success: false,
error: "Período do lançamento não encontrado.",
};
}
const periodLancamentos = await db.query.transactions.findMany({
columns: { id: true, purchaseDate: true },
where: and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
eq(transactions.period, existing.period),
),
orderBy: asc(transactions.purchaseDate),
});
await applyUpdates(
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
})),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos do período foram atualizados.",
};
}
const payerIdFilter = existing.payerId
? eq(transactions.payerId, existing.payerId)
: isNull(transactions.payerId);
if (data.scope === "future") { if (data.scope === "future") {
const futureLancamentos = await db.query.transactions.findMany({ const futureLancamentos = await db.query.transactions.findMany({
columns: { columns: {
@@ -319,6 +375,7 @@ export async function updateTransactionBulkAction(
eq(transactions.seriesId, existing.seriesId), eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
sql`${transactions.period} >= ${existing.period}`, sql`${transactions.period} >= ${existing.period}`,
payerIdFilter,
), ),
orderBy: asc(transactions.purchaseDate), orderBy: asc(transactions.purchaseDate),
}); });
@@ -346,6 +403,7 @@ export async function updateTransactionBulkAction(
where: and( where: and(
eq(transactions.seriesId, existing.seriesId), eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
payerIdFilter,
), ),
orderBy: asc(transactions.purchaseDate), orderBy: asc(transactions.purchaseDate),
}); });

View File

@@ -664,7 +664,7 @@ export const buildLancamentoRecords = ({
export const deleteBulkSchema = z.object({ export const deleteBulkSchema = z.object({
id: uuidSchema("Lançamento"), id: uuidSchema("Lançamento"),
scope: z.enum(["current", "future", "all"], { scope: z.enum(["current", "period", "future", "all"], {
message: "Escopo de ação inválido.", message: "Escopo de ação inválido.",
}), }),
}); });
@@ -673,7 +673,7 @@ export type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
export const updateBulkSchema = z.object({ export const updateBulkSchema = z.object({
id: uuidSchema("Lançamento"), id: uuidSchema("Lançamento"),
scope: z.enum(["current", "future", "all"], { scope: z.enum(["current", "period", "future", "all"], {
message: "Escopo de ação inválido.", message: "Escopo de ação inválido.",
}), }),
name: z name: z

View File

@@ -5,4 +5,9 @@ export const ALLOWED_MIME_TYPES = [
"image/webp", "image/webp",
] as const; ] as const;
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB export const DEFAULT_MAX_FILE_SIZE_MB = 50;
export const MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; // 50MB (fallback)
export const ATTACHMENT_SIZE_OPTIONS = [5, 10, 25, 50, 100] as const;
export type AttachmentSizeOption = (typeof ATTACHMENT_SIZE_OPTIONS)[number];

View File

@@ -5,19 +5,22 @@ import { useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
ALLOWED_MIME_TYPES, ALLOWED_MIME_TYPES,
MAX_FILE_SIZE, DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/attachments-config"; } from "@/features/transactions/attachments-config";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
interface AttachmentFilePickerProps { interface AttachmentFilePickerProps {
file: File | null; file: File | null;
onChange: (file: File | null) => void; onChange: (file: File | null) => void;
maxSizeMb?: number;
} }
export function AttachmentFilePicker({ export function AttachmentFilePicker({
file, file,
onChange, onChange,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentFilePickerProps) { }: AttachmentFilePickerProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -37,8 +40,8 @@ export function AttachmentFilePicker({
return; return;
} }
if (selected.size > MAX_FILE_SIZE) { if (selected.size > maxFileSizeBytes) {
toast.error("O arquivo deve ter no máximo 50MB."); toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
return; return;
} }
@@ -83,7 +86,7 @@ export function AttachmentFilePicker({
Adicionar anexo Adicionar anexo
</span> </span>
<span className="text-[11px]"> <span className="text-[11px]">
PDF, JPEG, PNG ou WebP · máx. 50 MB PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span> </span>
</button> </button>
)} )}

View File

@@ -125,6 +125,9 @@ interface AttachmentItemProps {
url: string; url: string;
onDeleted: () => void; onDeleted: () => void;
readonly?: boolean; readonly?: boolean;
isPendingDelete?: boolean;
onPendingDelete?: (attachmentId: string) => void;
onUndoPendingDelete?: (attachmentId: string) => void;
} }
export function AttachmentItem({ export function AttachmentItem({
@@ -136,6 +139,9 @@ export function AttachmentItem({
url, url,
onDeleted, onDeleted,
readonly = false, readonly = false,
isPendingDelete = false,
onPendingDelete,
onUndoPendingDelete,
}: AttachmentItemProps) { }: AttachmentItemProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
@@ -145,6 +151,11 @@ export function AttachmentItem({
mimeType === "application/pdf" || mimeType.startsWith("image/"); mimeType === "application/pdf" || mimeType.startsWith("image/");
function handleDelete() { function handleDelete() {
if (onPendingDelete) {
onPendingDelete(attachmentId);
setConfirmOpen(false);
return;
}
startTransition(async () => { startTransition(async () => {
const result = await detachTransactionAttachmentAction({ const result = await detachTransactionAttachmentAction({
attachmentId, attachmentId,
@@ -162,9 +173,18 @@ export function AttachmentItem({
return ( return (
<> <>
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm"> <div
className={`flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm transition-opacity ${isPendingDelete ? "opacity-50 border-dashed" : ""}`}
>
<AttachmentIcon mimeType={mimeType} /> <AttachmentIcon mimeType={mimeType} />
{canPreview ? ( {isPendingDelete ? (
<div className="flex-1 min-w-0">
<p className="truncate font-medium line-through">{fileName}</p>
<p className="text-xs text-muted-foreground">
Será removido ao salvar
</p>
</div>
) : canPreview ? (
<button <button
type="button" type="button"
className="min-w-0 flex-1 text-left" className="min-w-0 flex-1 text-left"
@@ -184,29 +204,42 @@ export function AttachmentItem({
</p> </p>
</div> </div>
)} )}
<Button {!isPendingDelete && (
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 <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7 shrink-0 text-destructive hover:text-destructive" className="size-7 shrink-0"
onClick={() => setConfirmOpen(true)} asChild
disabled={isPending}
> >
<RiDeleteBinLine className="size-4" /> <a href={url} target="_blank" rel="noreferrer" download={fileName}>
<RiDownloadLine className="size-4" />
</a>
</Button> </Button>
)} )}
{!readonly &&
(isPendingDelete ? (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-xs h-7 px-2"
onClick={() => onUndoPendingDelete?.(attachmentId)}
>
Desfazer
</Button>
) : (
<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> </div>
{canPreview && ( {canPreview && (

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { RiFileAddLine } from "@remixicon/react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments"; import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
import { Button } from "@/shared/components/ui/button";
import { AttachmentItem } from "./attachment-item"; import { AttachmentItem } from "./attachment-item";
import { AttachmentUpload } from "./attachment-upload"; import { AttachmentUpload } from "./attachment-upload";
@@ -16,16 +18,28 @@ type AttachmentRow = {
interface AttachmentSectionProps { interface AttachmentSectionProps {
transactionId: string; transactionId: string;
seriesId: string | null;
readonly?: boolean; readonly?: boolean;
onLoaded?: (count: number) => void; onLoaded?: (count: number) => void;
pendingDetachIds?: string[];
onPendingDetach?: (attachmentId: string) => void;
onUndoPendingDetach?: (attachmentId: string) => void;
pendingUploadFiles?: File[];
onPendingUpload?: (file: File) => void;
onCancelPendingUpload?: (file: File) => void;
maxSizeMb?: number;
} }
export function AttachmentSection({ export function AttachmentSection({
transactionId, transactionId,
seriesId,
readonly = false, readonly = false,
onLoaded, onLoaded,
pendingDetachIds,
onPendingDetach,
onUndoPendingDetach,
pendingUploadFiles,
onPendingUpload,
onCancelPendingUpload,
maxSizeMb,
}: AttachmentSectionProps) { }: AttachmentSectionProps) {
const [items, setItems] = useState<AttachmentRow[]>([]); const [items, setItems] = useState<AttachmentRow[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -45,42 +59,70 @@ export function AttachmentSection({
load(); load();
}, [load]); }, [load]);
if (isLoading) {
return <p className="text-xs text-muted-foreground">Carregando...</p>;
}
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
return ( return (
<div className="min-w-0 space-y-2 overflow-hidden"> <div className="min-w-0 space-y-2 overflow-hidden">
{isLoading ? ( {items.length === 0 && !hasPendingUploads && readonly && (
<p className="text-xs text-muted-foreground">Carregando...</p> <p className="text-xs text-muted-foreground">Nenhum anexo.</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 && ( {(items.length > 0 || hasPendingUploads) && (
<AttachmentUpload <div className="min-w-0 space-y-1.5">
{items.map((item) => (
<AttachmentItem
key={item.attachmentId}
attachmentId={item.attachmentId}
transactionId={transactionId} transactionId={transactionId}
seriesId={seriesId} fileName={item.fileName}
onUploaded={load} fileSize={item.fileSize}
mimeType={item.mimeType}
url={item.url}
onDeleted={load}
readonly={readonly}
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
onPendingDelete={onPendingDetach}
onUndoPendingDelete={onUndoPendingDetach}
/> />
)} ))}
</>
{pendingUploadFiles?.map((file) => (
<div
key={`${file.name}-${file.size}`}
className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border border-dashed px-3 py-2 text-sm opacity-60"
>
<RiFileAddLine className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
Será adicionado ao salvar
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-xs h-7 px-2"
onClick={() => onCancelPendingUpload?.(file)}
>
Cancelar
</Button>
</div>
))}
</div>
)}
{!readonly && (
<AttachmentUpload
transactionId={transactionId}
onUploaded={load}
onPendingUpload={onPendingUpload}
maxSizeMb={maxSizeMb}
/>
)} )}
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { RiAttachment2 } from "@remixicon/react"; import { RiAttachment2 } from "@remixicon/react";
import { useRef, useState, useTransition } from "react"; import { useRef, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
confirmAttachmentUploadAction, confirmAttachmentUploadAction,
@@ -9,27 +9,25 @@ import {
} from "@/features/transactions/actions/attachments"; } from "@/features/transactions/actions/attachments";
import { import {
ALLOWED_MIME_TYPES, ALLOWED_MIME_TYPES,
MAX_FILE_SIZE, DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/attachments-config"; } 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 { interface AttachmentUploadProps {
transactionId: string; transactionId: string;
seriesId: string | null;
onUploaded: () => void; onUploaded: () => void;
onPendingUpload?: (file: File) => void;
maxSizeMb?: number;
} }
export function AttachmentUpload({ export function AttachmentUpload({
transactionId, transactionId,
seriesId,
onUploaded, onUploaded,
onPendingUpload,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentUploadProps) { }: AttachmentUploadProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [applyToSeries, setApplyToSeries] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -49,19 +47,16 @@ export function AttachmentUpload({
return; return;
} }
if (file.size > MAX_FILE_SIZE) { if (file.size > maxFileSizeBytes) {
toast.error("O arquivo deve ter no máximo 50MB."); toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
return; return;
} }
if (seriesId) { if (onPendingUpload) {
setPendingFile(file); onPendingUpload(file);
} else { return;
uploadFile(file, false);
} }
}
function uploadFile(file: File, toSeries: boolean) {
startTransition(async () => { startTransition(async () => {
const presignResult = await getPresignedUploadUrlAction({ const presignResult = await getPresignedUploadUrlAction({
fileName: file.name, fileName: file.name,
@@ -88,13 +83,10 @@ export function AttachmentUpload({
const confirmResult = await confirmAttachmentUploadAction({ const confirmResult = await confirmAttachmentUploadAction({
uploadToken: presignResult.uploadToken, uploadToken: presignResult.uploadToken,
applyToSeries: toSeries,
}); });
if (confirmResult.success) { if (confirmResult.success) {
toast.success(confirmResult.message); toast.success(confirmResult.message);
setPendingFile(null);
setApplyToSeries(false);
onUploaded(); onUploaded();
} else { } else {
toast.error(confirmResult.error); toast.error(confirmResult.error);
@@ -102,56 +94,6 @@ export function AttachmentUpload({
}); });
} }
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 ( return (
<> <>
<input <input
@@ -172,7 +114,9 @@ export function AttachmentUpload({
{isPending ? "Enviando..." : "Adicionar anexo"} {isPending ? "Enviando..." : "Adicionar anexo"}
</span> </span>
{!isPending && ( {!isPending && (
<span className="text-xs">PDF, JPEG, PNG ou WebP · máx. 50 MB</span> <span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span>
)} )}
</button> </button>
</> </>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { RiErrorWarningLine } from "@remixicon/react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { import {
@@ -13,7 +14,7 @@ import {
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
export type BulkActionScope = "current" | "future" | "all"; export type BulkActionScope = "current" | "period" | "future" | "all";
type BulkActionDialogProps = { type BulkActionDialogProps = {
open: boolean; open: boolean;
@@ -108,6 +109,30 @@ export function BulkActionDialog({
</div> </div>
</div> </div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="period" id="period" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="period"
className="text-sm cursor-pointer font-medium"
>
Todos os pagadores deste período
</Label>
<p className="text-xs text-muted-foreground">
Aplica a todos os lançamentos deste mesmo mês na série
</p>
{scope === "period" && actionType === "edit" && (
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
<RiErrorWarningLine className="mt-0.5 size-3.5 shrink-0" />
<p className="text-xs">
Atenção: os valores individuais de cada pagador serão
substituídos pelos valores deste lançamento.
</p>
</div>
)}
</div>
</div>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<RadioGroupItem value="future" id="future" className="mt-0.5" /> <RadioGroupItem value="future" id="future" className="mt-0.5" />
<div className="flex-1"> <div className="flex-1">

View File

@@ -223,7 +223,6 @@ export function TransactionDetailsDialog({
<div className="min-w-0"> <div className="min-w-0">
<AttachmentSection <AttachmentSection
transactionId={transaction.id} transactionId={transaction.id}
seriesId={transaction.seriesId}
readonly readonly
onLoaded={setAttachmentCount} onLoaded={setAttachmentCount}
/> />

View File

@@ -27,7 +27,7 @@ export function BoletoFieldsSection({
/> />
</div> </div>
{showPaymentDate ? ( {showPaymentDate ? (
<div className="space-y-2 w-full md:w-1/2"> <div className="space-y-1 w-full md:w-1/2">
<Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label> <Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label>
<DatePicker <DatePicker
id="boletoPaymentDate" id="boletoPaymentDate"

View File

@@ -30,6 +30,8 @@ export interface TransactionDialogProps {
forceShowTransactionType?: boolean; forceShowTransactionType?: boolean;
/** Called after successful create/update. Receives the action result. */ /** Called after successful create/update. Receives the action result. */
onSuccess?: () => void; onSuccess?: () => void;
/** Max attachment file size in MB for this user */
maxSizeMb?: number;
onBulkEditRequest?: (data: { onBulkEditRequest?: (data: {
id: string; id: string;
name: string; name: string;
@@ -42,6 +44,8 @@ export interface TransactionDialogProps {
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null; isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => void; }) => void;
} }

View File

@@ -8,6 +8,7 @@ import {
} from "@/features/transactions/actions"; } from "@/features/transactions/actions";
import { import {
confirmAttachmentUploadAction, confirmAttachmentUploadAction,
detachTransactionAttachmentAction,
getPresignedUploadUrlAction, getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments"; } from "@/features/transactions/actions/attachments";
import { import {
@@ -72,10 +73,11 @@ export function TransactionDialog({
defaultAmount, defaultAmount,
lockCardSelection, lockCardSelection,
lockPaymentMethod, lockPaymentMethod,
isImporting = false, isImporting,
defaultTransactionType, defaultTransactionType,
forceShowTransactionType = false, forceShowTransactionType,
onSuccess, onSuccess,
maxSizeMb,
onBulkEditRequest, onBulkEditRequest,
}: TransactionDialogProps) { }: TransactionDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState( const [dialogOpen, setDialogOpen] = useControlledState(
@@ -98,6 +100,8 @@ 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); const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
useEffect(() => { useEffect(() => {
if (dialogOpen) { if (dialogOpen) {
@@ -136,6 +140,8 @@ export function TransactionDialog({
setFormState(initial); setFormState(initial);
setErrorMessage(null); setErrorMessage(null);
setPendingFile(null); setPendingFile(null);
setPendingDetachIds([]);
setPendingUploadFiles([]);
} }
}, [ }, [
dialogOpen, dialogOpen,
@@ -342,7 +348,7 @@ export function TransactionDialog({
}); });
await confirmAttachmentUploadAction({ await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken, uploadToken: presign.uploadToken,
applyToSeries: isNewSeries, scope: isNewSeries ? "all" : "current",
}); });
} }
} }
@@ -357,11 +363,11 @@ export function TransactionDialog({
return; return;
} }
// Update mode
const hasSeriesId = Boolean(transaction?.seriesId); const hasSeriesId = Boolean(transaction?.seriesId);
if (hasSeriesId && onBulkEditRequest) { if (hasSeriesId && onBulkEditRequest) {
// Para lançamentos em série, abre o diálogo de bulk action // Para lançamentos em série, passa os arquivos para a página confirmar
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
onBulkEditRequest({ onBulkEditRequest({
id: transaction?.id ?? "", id: transaction?.id ?? "",
name: formState.name.trim(), name: formState.name.trim(),
@@ -383,11 +389,13 @@ export function TransactionDialog({
formState.paymentMethod === "Cartão de crédito" formState.paymentMethod === "Cartão de crédito"
? null ? null
: Boolean(formState.isSettled), : Boolean(formState.isSettled),
pendingDetachIds,
pendingUploadFiles,
}); });
return; return;
} }
// Atualização normal para lançamentos únicos ou todos os campos // Atualização normal para lançamentos únicos
const updatePayload: UpdateTransactionInput = { const updatePayload: UpdateTransactionInput = {
id: transaction?.id ?? "", id: transaction?.id ?? "",
...payload, ...payload,
@@ -396,6 +404,31 @@ export function TransactionDialog({
const result = await updateTransactionAction(updatePayload); const result = await updateTransactionAction(updatePayload);
if (result.success) { if (result.success) {
for (const attachmentId of pendingDetachIds) {
await detachTransactionAttachmentAction({
attachmentId,
transactionId: transaction?.id ?? "",
});
}
for (const file of pendingUploadFiles) {
const presign = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId: transaction?.id ?? "",
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope: "current",
});
}
}
toast.success(result.message); toast.success(result.message);
onSuccess?.(); onSuccess?.();
setDialogOpen(false); setDialogOpen(false);
@@ -521,7 +554,40 @@ export function TransactionDialog({
</Label> </Label>
<AttachmentSection <AttachmentSection
transactionId={transaction?.id ?? ""} transactionId={transaction?.id ?? ""}
seriesId={transaction?.seriesId ?? null} maxSizeMb={maxSizeMb}
pendingDetachIds={
transaction?.seriesId ? pendingDetachIds : undefined
}
onPendingDetach={
transaction?.seriesId
? (id) => setPendingDetachIds((prev) => [...prev, id])
: undefined
}
onUndoPendingDetach={
transaction?.seriesId
? (id) =>
setPendingDetachIds((prev) =>
prev.filter((x) => x !== id),
)
: undefined
}
pendingUploadFiles={
transaction?.seriesId ? pendingUploadFiles : undefined
}
onPendingUpload={
transaction?.seriesId
? (file) =>
setPendingUploadFiles((prev) => [...prev, file])
: undefined
}
onCancelPendingUpload={
transaction?.seriesId
? (file) =>
setPendingUploadFiles((prev) =>
prev.filter((f) => f !== file),
)
: undefined
}
/> />
</div> </div>
</> </>
@@ -548,6 +614,7 @@ export function TransactionDialog({
<AttachmentFilePicker <AttachmentFilePicker
file={pendingFile} file={pendingFile}
onChange={setPendingFile} onChange={setPendingFile}
maxSizeMb={maxSizeMb}
/> />
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>

View File

@@ -10,6 +10,11 @@ import {
toggleTransactionSettlementAction, toggleTransactionSettlementAction,
updateTransactionBulkAction, updateTransactionBulkAction,
} from "@/features/transactions/actions"; } from "@/features/transactions/actions";
import {
confirmAttachmentUploadAction,
detachAttachmentBulkAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import type { import type {
TransactionsExportContext, TransactionsExportContext,
@@ -59,6 +64,7 @@ interface TransactionsPageProps {
lockPaymentMethod?: boolean; lockPaymentMethod?: boolean;
pagination?: TransactionsPaginationState; pagination?: TransactionsPaginationState;
exportContext?: TransactionsExportContext; exportContext?: TransactionsExportContext;
attachmentMaxSizeMb?: number;
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário) // Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
importPayerOptions?: SelectOption[]; importPayerOptions?: SelectOption[];
importSplitPayerOptions?: SelectOption[]; importSplitPayerOptions?: SelectOption[];
@@ -91,6 +97,7 @@ export function TransactionsPage({
lockPaymentMethod, lockPaymentMethod,
pagination, pagination,
exportContext, exportContext,
attachmentMaxSizeMb,
importPayerOptions, importPayerOptions,
importSplitPayerOptions, importSplitPayerOptions,
importDefaultPayerId, importDefaultPayerId,
@@ -130,6 +137,8 @@ export function TransactionsPage({
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null; isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
transaction: TransactionItem; transaction: TransactionItem;
} | null>(null); } | null>(null);
const [pendingDeleteData, setPendingDeleteData] = const [pendingDeleteData, setPendingDeleteData] =
@@ -246,6 +255,8 @@ export function TransactionsPage({
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null; isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => { }) => {
if (!selectedTransaction) { if (!selectedTransaction) {
return; return;
@@ -284,6 +295,36 @@ export function TransactionsPage({
throw new Error(result.error); throw new Error(result.error);
} }
// Propaga remoções de anexo pendentes com o mesmo escopo
for (const attachmentId of pendingEditData.pendingDetachIds) {
await detachAttachmentBulkAction({
attachmentId,
transactionId: pendingEditData.id,
scope,
});
}
// Faz upload dos arquivos pendentes e confirma com o escopo escolhido
for (const file of pendingEditData.pendingUploadFiles) {
const presign = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId: pendingEditData.id,
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope,
});
}
}
toast.success(result.message); toast.success(result.message);
setBulkEditOpen(false); setBulkEditOpen(false);
setPendingEditData(null); setPendingEditData(null);
@@ -438,6 +479,7 @@ export function TransactionsPage({
lockCardSelection={lockCardSelection} lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod} lockPaymentMethod={lockPaymentMethod}
defaultTransactionType={transactionTypeForCreate ?? undefined} defaultTransactionType={transactionTypeForCreate ?? undefined}
maxSizeMb={attachmentMaxSizeMb}
/> />
) : null} ) : null}
@@ -459,6 +501,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={transactionToCopy ?? undefined} transaction={transactionToCopy ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
maxSizeMb={attachmentMaxSizeMb}
/> />
<TransactionDialog <TransactionDialog
@@ -480,6 +523,7 @@ export function TransactionsPage({
transaction={transactionToImport ?? undefined} transaction={transactionToImport ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
isImporting={true} isImporting={true}
maxSizeMb={attachmentMaxSizeMb}
/> />
<BulkImportDialog <BulkImportDialog
@@ -507,6 +551,7 @@ export function TransactionsPage({
transaction={selectedTransaction ?? undefined} transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
onBulkEditRequest={handleBulkEditRequest} onBulkEditRequest={handleBulkEditRequest}
maxSizeMb={attachmentMaxSizeMb}
/> />
<TransactionDetailsDialog <TransactionDetailsDialog

View File

@@ -220,111 +220,117 @@ const buildColumns = ({
<span className="text-primary">{dueDateLabel}</span> <span className="text-primary">{dueDateLabel}</span>
) : null} ) : null}
</span> </span>
<Tooltip> <span className="flex items-center gap-1">
<TooltipTrigger asChild> <Tooltip>
<span className="line-clamp-2 max-w-[180px] font-bold truncate"> <TooltipTrigger asChild>
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
{name}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{name} {name}
</span> </TooltipContent>
</TooltipTrigger> </Tooltip>
<TooltipContent side="top" className="max-w-xs">
{name} {isDivided && (
</TooltipContent> <Tooltip>
</Tooltip> <TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">
Dividido entre pagadores
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icons/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Parcela antecipada
</TooltipContent>
</Tooltip>
)}
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{note}
</TooltipContent>
</Tooltip>
) : 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> </span>
{isDivided && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Dividido entre pagadores</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icons/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Parcela antecipada</TooltipContent>
</Tooltip>
)}
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{note}
</TooltipContent>
</Tooltip>
) : 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>
); );
}, },

View File

@@ -77,7 +77,5 @@ function LogoContent() {
const { state } = useSidebar(); const { state } = useSidebar();
const isCollapsed = state === "collapsed"; const isCollapsed = state === "collapsed";
return ( return <Logo variant={isCollapsed ? "small" : "full"} />;
<Logo variant={isCollapsed ? "small" : "full"} />
);
} }

View File

@@ -3,11 +3,7 @@
"ignoreDeprecations": "6.0", "ignoreDeprecations": "6.0",
"baseUrl": ".", "baseUrl": ".",
"target": "ES2017", "target": "ES2017",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -25,10 +21,7 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./src/*", "./*"]
"./src/*",
"./*"
]
} }
}, },
"include": [ "include": [
@@ -42,7 +35,5 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": ["node_modules"]
"node_modules"
]
} }