mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d0c3e0a7 | ||
|
|
71b5a004e3 | ||
|
|
65b1506d75 | ||
|
|
2a458d5a3c | ||
|
|
f418987f47 | ||
|
|
59b4dea071 | ||
|
|
6ce132fe0c | ||
|
|
49731238e4 | ||
|
|
c5df97f7aa | ||
|
|
3476fda4db | ||
|
|
519b673ae5 | ||
|
|
303b8bedd4 | ||
|
|
f2b9b16896 | ||
|
|
6eba35542b | ||
|
|
f5e95ffba6 | ||
|
|
a75bb86eec | ||
|
|
a3b858621f | ||
|
|
fee2a2c9f5 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -12,7 +12,6 @@
|
|||||||
"**/.next": true,
|
"**/.next": true,
|
||||||
".next": true
|
".next": true
|
||||||
},
|
},
|
||||||
"explorerExclude.backup": {},
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -25,9 +24,7 @@
|
|||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"eslint.enable": false,
|
|
||||||
"prettier.enable": false,
|
"prettier.enable": false,
|
||||||
"typescript.preferences.organizeImportsCollation": "ordinal",
|
|
||||||
"editor.fontSize": 15,
|
"editor.fontSize": 15,
|
||||||
"[jsonc]": {
|
"[jsonc]": {
|
||||||
"editor.defaultFormatter": "vscode.json-language-features"
|
"editor.defaultFormatter": "vscode.json-language-features"
|
||||||
|
|||||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -7,6 +7,43 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page
|
||||||
|
- UI: nova variante `navbar` no componente `Button`, centralizando os estilos de botões usados dentro da navbar
|
||||||
|
- Analytics: integração com Umami self-hosted via script tag no layout raiz
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Navbar: `AnimatedThemeToggler` e `RefreshPageButton` passam a aceitar prop `variant` para adaptar estilos ao contexto (navbar ou sidebar)
|
||||||
|
- Navbar: estilos inline duplicados de `nav-styles.ts` migrados para a variante `navbar` do Button
|
||||||
|
- Logo: prop `showVersion` removida; prop `colorIcon` passa a aplicar filtro de cor também no variant `compact`
|
||||||
|
- Scripts: `mockup` renomeado para `db:seed`; `db:enableExtensions` renomeado para `db:extensions`; script `dev-env` removido
|
||||||
|
- Landing: `MobileNav` simplificado com a remoção da prop `triggerClassName`
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- Navbar: arquivo `nav-styles.ts` removido após migração dos estilos para a variante `navbar`
|
||||||
|
- Dependências: `@vercel/analytics` e `@vercel/speed-insights` removidos (substituídos pelo Umami self-hosted)
|
||||||
|
|
||||||
## [2.1.0] - 2026-03-28
|
## [2.1.0] - 2026-03-28
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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/)
|
||||||
@@ -156,7 +156,7 @@ O script irá:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up db -d
|
docker compose up db -d
|
||||||
pnpm db:enableExtensions
|
pnpm db:extensions
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Execute as migrations e inicie**
|
4. **Execute as migrations e inicie**
|
||||||
|
|||||||
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;
|
||||||
2873
drizzle/meta/0024_snapshot.json
Normal file
2873
drizzle/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,13 @@
|
|||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.1.0",
|
"version": "2.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"dev-env": "tsx scripts/dev.ts",
|
"db:seed": "tsx scripts/mock-data.ts",
|
||||||
"mockup": "tsx scripts/mock-data.ts",
|
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
@@ -14,7 +13,7 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:enableExtensions": "tsx scripts/postgres/enable-extensions.ts",
|
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"docker:up": "docker compose up --build",
|
"docker:up": "docker compose up --build",
|
||||||
"docker:up:db": "docker compose up -d db",
|
"docker:up:db": "docker compose up -d db",
|
||||||
@@ -61,8 +60,6 @@
|
|||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
"@vercel/analytics": "^2.0.1",
|
|
||||||
"@vercel/speed-insights": "^2.0.0",
|
|
||||||
"ai": "^6.0.141",
|
"ai": "^6.0.141",
|
||||||
"better-auth": "1.5.6",
|
"better-auth": "1.5.6",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
|
|||||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
@@ -104,12 +104,6 @@ importers:
|
|||||||
'@tanstack/react-virtual':
|
'@tanstack/react-virtual':
|
||||||
specifier: ^3.13.23
|
specifier: ^3.13.23
|
||||||
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@vercel/analytics':
|
|
||||||
specifier: ^2.0.1
|
|
||||||
version: 2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
|
||||||
'@vercel/speed-insights':
|
|
||||||
specifier: ^2.0.0
|
|
||||||
version: 2.0.0(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
|
||||||
ai:
|
ai:
|
||||||
specifier: ^6.0.141
|
specifier: ^6.0.141
|
||||||
version: 6.0.141(zod@4.3.6)
|
version: 6.0.141(zod@4.3.6)
|
||||||
@@ -2666,65 +2660,10 @@ packages:
|
|||||||
'@types/whatwg-url@13.0.0':
|
'@types/whatwg-url@13.0.0':
|
||||||
resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==}
|
resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==}
|
||||||
|
|
||||||
'@vercel/analytics@2.0.1':
|
|
||||||
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
|
|
||||||
peerDependencies:
|
|
||||||
'@remix-run/react': ^2
|
|
||||||
'@sveltejs/kit': ^1 || ^2
|
|
||||||
next: '>= 13'
|
|
||||||
nuxt: '>= 3'
|
|
||||||
react: ^18 || ^19 || ^19.0.0-rc
|
|
||||||
svelte: '>= 4'
|
|
||||||
vue: ^3
|
|
||||||
vue-router: ^4
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@remix-run/react':
|
|
||||||
optional: true
|
|
||||||
'@sveltejs/kit':
|
|
||||||
optional: true
|
|
||||||
next:
|
|
||||||
optional: true
|
|
||||||
nuxt:
|
|
||||||
optional: true
|
|
||||||
react:
|
|
||||||
optional: true
|
|
||||||
svelte:
|
|
||||||
optional: true
|
|
||||||
vue:
|
|
||||||
optional: true
|
|
||||||
vue-router:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@vercel/oidc@3.1.0':
|
'@vercel/oidc@3.1.0':
|
||||||
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
|
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
|
|
||||||
'@vercel/speed-insights@2.0.0':
|
|
||||||
resolution: {integrity: sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==}
|
|
||||||
peerDependencies:
|
|
||||||
'@sveltejs/kit': ^1 || ^2
|
|
||||||
next: '>= 13'
|
|
||||||
nuxt: '>= 3'
|
|
||||||
react: ^18 || ^19 || ^19.0.0-rc
|
|
||||||
svelte: '>= 4'
|
|
||||||
vue: ^3
|
|
||||||
vue-router: ^4
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@sveltejs/kit':
|
|
||||||
optional: true
|
|
||||||
next:
|
|
||||||
optional: true
|
|
||||||
nuxt:
|
|
||||||
optional: true
|
|
||||||
react:
|
|
||||||
optional: true
|
|
||||||
svelte:
|
|
||||||
optional: true
|
|
||||||
vue:
|
|
||||||
optional: true
|
|
||||||
vue-router:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
adler-32@1.3.1:
|
adler-32@1.3.1:
|
||||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -6557,18 +6496,8 @@ snapshots:
|
|||||||
'@types/webidl-conversions': 7.0.3
|
'@types/webidl-conversions': 7.0.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vercel/analytics@2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
|
|
||||||
optionalDependencies:
|
|
||||||
next: 16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
|
||||||
react: 19.2.4
|
|
||||||
|
|
||||||
'@vercel/oidc@3.1.0': {}
|
'@vercel/oidc@3.1.0': {}
|
||||||
|
|
||||||
'@vercel/speed-insights@2.0.0(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
|
|
||||||
optionalDependencies:
|
|
||||||
next: 16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
|
||||||
react: 19.2.4
|
|
||||||
|
|
||||||
adler-32@1.3.1: {}
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
ai@6.0.141(zod@4.3.6):
|
ai@6.0.141(zod@4.3.6):
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.0 KiB |
39
setup.mjs
39
setup.mjs
@@ -21,6 +21,7 @@ const c = {
|
|||||||
red: "\x1b[31m",
|
red: "\x1b[31m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
cyan: "\x1b[36m",
|
cyan: "\x1b[36m",
|
||||||
|
orange: "\x1b[38;5;214m",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sym = {
|
const sym = {
|
||||||
@@ -81,10 +82,38 @@ function abort(msg) {
|
|||||||
|
|
||||||
// ─── Header ──────────────────────────────────────────────────────────────────
|
// ─── Header ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
console.log(`
|
const logoLines = [
|
||||||
${c.bold}${c.cyan} OpenMonetis — Setup${c.reset}
|
".............................+@@@@@@@@@@=.............................",
|
||||||
${c.dim}Gestão financeira self-hosted${c.reset}
|
".............................@@@@@@@@@@@:.............................",
|
||||||
`);
|
"...................+@@@@@@*-:@@@@@@@@@@%...=@@@@@@-...................",
|
||||||
|
"..................@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%..................",
|
||||||
|
"................=@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+................",
|
||||||
|
"...................-=+%@@@@@@@@@@@@@@@@@@@@@*:........................",
|
||||||
|
".......................#@@@@@@@@@@@@@@@@@@@@@@@+......................",
|
||||||
|
"....................%@@@@@@@@@@@@@%#@@@@@@@@@@@@*.....................",
|
||||||
|
"....................+@@@@@@@@@@@......*@@@@@@#........................",
|
||||||
|
".........................:#@@=...........+#...........................",
|
||||||
|
];
|
||||||
|
|
||||||
|
const nameLines = [
|
||||||
|
" ___ __ __ _ _ ",
|
||||||
|
" / _ \\ _ __ ___ _ __ | \\/ | ___ _ __ ___| |_(_)___ ",
|
||||||
|
" | | | | '_ \\ / _ \\ '_ \\| |\\/| |/ _ \\| '_ \\ / _ \\ __| / __|",
|
||||||
|
" | |_| | |_) | __/ | | | | | | (_) | | | | __/ |_| \\__ \\",
|
||||||
|
" \\___/| .__/ \\___|_| |_|_| |_|\\___/|_| |_|\\___|\\__|_|___/",
|
||||||
|
" |_| ",
|
||||||
|
];
|
||||||
|
|
||||||
|
const nameStart = Math.floor((logoLines.length - nameLines.length) / 2);
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
for (let i = 0; i < logoLines.length; i++) {
|
||||||
|
const logoCol = c.orange + logoLines[i].replaceAll(".", " ").substring(14, 56).padEnd(42) + c.reset;
|
||||||
|
const nameIdx = i - nameStart;
|
||||||
|
const nameCol = nameIdx >= 0 && nameIdx < nameLines.length ? nameLines[nameIdx] : "";
|
||||||
|
console.log(logoCol + " " + nameCol);
|
||||||
|
}
|
||||||
|
console.log(`\n${" ".repeat(46)}${c.dim}Gestão financeira · self-hosted${c.reset}\n`);
|
||||||
|
|
||||||
// ─── ETAPA 1: Verificações do sistema ────────────────────────────────────────
|
// ─── ETAPA 1: Verificações do sistema ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -329,7 +358,7 @@ if (useLocalDocker) {
|
|||||||
// Extensões
|
// Extensões
|
||||||
s = spinner("Habilitando extensões do banco...");
|
s = spinner("Habilitando extensões do banco...");
|
||||||
try {
|
try {
|
||||||
run("pnpm db:enableExtensions", { cwd: targetDir });
|
run("pnpm db:extensions", { cwd: targetDir });
|
||||||
s.stop("Extensões habilitadas");
|
s.stop("Extensões habilitadas");
|
||||||
} catch {
|
} catch {
|
||||||
s.fail("Falha ao habilitar extensões");
|
s.fail("Falha ao habilitar extensões");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<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} />
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
extraFeatures,
|
extraFeatures,
|
||||||
getMetricsItems,
|
getMetricsItems,
|
||||||
mainFeatures,
|
mainFeatures,
|
||||||
navbarActionClassName,
|
|
||||||
navLinks,
|
navLinks,
|
||||||
pwaHighlights,
|
pwaHighlights,
|
||||||
stackItems,
|
stackItems,
|
||||||
@@ -27,6 +26,7 @@ import { landingImages } from "@/features/landing/images";
|
|||||||
import { fetchGitHubStats } from "@/features/landing/queries";
|
import { fetchGitHubStats } from "@/features/landing/queries";
|
||||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||||
import { Logo } from "@/shared/components/logo";
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
@@ -50,10 +50,7 @@ export default async function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center bg-primary">
|
<NavbarShell>
|
||||||
<div className="relative z-10 max-w-8xl mx-auto px-4 w-full flex h-full items-center justify-between">
|
|
||||||
<Logo variant="compact" invertTextOnDark={false} />
|
|
||||||
|
|
||||||
{/* Center Navigation Links */}
|
{/* Center Navigation Links */}
|
||||||
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
||||||
{navLinks.map(({ href, label }) => (
|
{navLinks.map(({ href, label }) => (
|
||||||
@@ -67,8 +64,8 @@ export default async function Page() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav className="flex items-center gap-2 md:gap-3">
|
<nav className="ml-auto flex items-center gap-2 md:gap-3">
|
||||||
<AnimatedThemeToggler className={navbarActionClassName} />
|
<AnimatedThemeToggler variant="navbar" />
|
||||||
{!isPublicDomain &&
|
{!isPublicDomain &&
|
||||||
(session?.user ? (
|
(session?.user ? (
|
||||||
<Link prefetch href="/dashboard" className="hidden md:block">
|
<Link prefetch href="/dashboard" className="hidden md:block">
|
||||||
@@ -104,11 +101,9 @@ export default async function Page() {
|
|||||||
<MobileNav
|
<MobileNav
|
||||||
isPublicDomain={isPublicDomain}
|
isPublicDomain={isPublicDomain}
|
||||||
isLoggedIn={!!session?.user}
|
isLoggedIn={!!session?.user}
|
||||||
triggerClassName="border border-black/10 text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
|
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</NavbarShell>
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Analytics } from "@vercel/analytics/next";
|
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
||||||
import { Toaster } from "@/shared/components/ui/sonner";
|
import { Toaster } from "@/shared/components/ui/sonner";
|
||||||
@@ -28,14 +26,18 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://umami.felipecoutinho.com/script.js"
|
||||||
|
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
|
||||||
|
data-domains="openmonetis.com"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="subpixel-antialiased" suppressHydrationWarning>
|
<body className="subpixel-antialiased" suppressHydrationWarning>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light">
|
<ThemeProvider attribute="class" defaultTheme="light">
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<Analytics />
|
|
||||||
<SpeedInsights />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ export function LoginForm({ className, ...props }: DivProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (passkeyError) {
|
if (passkeyError) {
|
||||||
setError(passkeyError.message || "Erro ao entrar com passkey.");
|
setError(
|
||||||
|
(passkeyError.message as string) || "Erro ao entrar com passkey.",
|
||||||
|
);
|
||||||
setLoadingPasskey(false);
|
setLoadingPasskey(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,24 +24,18 @@ const navLinks = [
|
|||||||
interface MobileNavProps {
|
interface MobileNavProps {
|
||||||
isPublicDomain: boolean;
|
isPublicDomain: boolean;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
triggerClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileNav({
|
export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
|
||||||
isPublicDomain,
|
|
||||||
isLoggedIn,
|
|
||||||
triggerClassName,
|
|
||||||
}: MobileNavProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="navbar"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
aria-label="Abrir menu"
|
aria-label="Abrir menu"
|
||||||
className={triggerClassName}
|
|
||||||
>
|
>
|
||||||
<RiMenuLine className="size-5" />
|
<RiMenuLine className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ export type FeatureItem = {
|
|||||||
colorVar: string;
|
colorVar: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const navbarActionClassName =
|
|
||||||
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
|
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{ href: "#telas", label: "conheça as telas" },
|
{ href: "#telas", label: "conheça as telas" },
|
||||||
{ href: "#funcionalidades", label: "funcionalidades" },
|
{ href: "#funcionalidades", label: "funcionalidades" },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,24 +67,7 @@ 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">
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h2 className="text-lg 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>
|
|
||||||
|
|
||||||
{/* Steps */}
|
{/* Steps */}
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
@@ -106,12 +87,8 @@ export function CompanionTab({ tokens }: CompanionTabProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t" />
|
|
||||||
|
|
||||||
{/* Devices */}
|
{/* Devices */}
|
||||||
<ApiTokensForm tokens={tokens} />
|
<ApiTokensForm tokens={tokens} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || "Erro ao carregar passkeys.");
|
setError(
|
||||||
|
(fetchError.message as string) || "Erro ao carregar passkeys.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPasskeys(
|
setPasskeys(
|
||||||
@@ -111,7 +113,7 @@ export function PasskeysForm() {
|
|||||||
name: addName.trim() || undefined,
|
name: addName.trim() || undefined,
|
||||||
});
|
});
|
||||||
if (addError) {
|
if (addError) {
|
||||||
setError(addError.message || "Erro ao registrar passkey.");
|
setError((addError.message as string) || "Erro ao registrar passkey.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAddName("");
|
setAddName("");
|
||||||
@@ -134,7 +136,9 @@ export function PasskeysForm() {
|
|||||||
name: editName.trim(),
|
name: editName.trim(),
|
||||||
});
|
});
|
||||||
if (renameError) {
|
if (renameError) {
|
||||||
setError(renameError.message || "Erro ao renomear passkey.");
|
setError(
|
||||||
|
(renameError.message as string) || "Erro ao renomear passkey.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@@ -156,7 +160,7 @@ export function PasskeysForm() {
|
|||||||
id: deleteId,
|
id: deleteId,
|
||||||
});
|
});
|
||||||
if (deleteError) {
|
if (deleteError) {
|
||||||
setError(deleteError.message || "Erro ao remover passkey.");
|
setError((deleteError.message as string) || "Erro ao remover passkey.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDeleteId(null);
|
setDeleteId(null);
|
||||||
|
|||||||
@@ -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,8 +194,44 @@ export function PreferencesForm({
|
|||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isPending} className="w-fit">
|
<Button type="submit" disabled={isPending} className="w-fit">
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 }>,
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,6 +204,7 @@ export function AttachmentItem({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isPendingDelete && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -195,7 +216,19 @@ export function AttachmentItem({
|
|||||||
<RiDownloadLine className="size-4" />
|
<RiDownloadLine className="size-4" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
{!readonly && (
|
)}
|
||||||
|
{!readonly &&
|
||||||
|
(isPendingDelete ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 text-xs h-7 px-2"
|
||||||
|
onClick={() => onUndoPendingDelete?.(attachmentId)}
|
||||||
|
>
|
||||||
|
Desfazer
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -206,7 +239,7 @@ export function AttachmentItem({
|
|||||||
>
|
>
|
||||||
<RiDeleteBinLine className="size-4" />
|
<RiDeleteBinLine className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canPreview && (
|
{canPreview && (
|
||||||
|
|||||||
@@ -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,13 +59,19 @@ 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 ? (
|
{(items.length > 0 || hasPendingUploads) && (
|
||||||
<div className="min-w-0 space-y-1.5">
|
<div className="min-w-0 space-y-1.5">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<AttachmentItem
|
<AttachmentItem
|
||||||
@@ -64,24 +84,46 @@ export function AttachmentSection({
|
|||||||
url={item.url}
|
url={item.url}
|
||||||
onDeleted={load}
|
onDeleted={load}
|
||||||
readonly={readonly}
|
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>
|
</div>
|
||||||
) : (
|
|
||||||
readonly && (
|
|
||||||
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<AttachmentUpload
|
<AttachmentUpload
|
||||||
transactionId={transactionId}
|
transactionId={transactionId}
|
||||||
seriesId={seriesId}
|
|
||||||
onUploaded={load}
|
onUploaded={load}
|
||||||
|
onPendingUpload={onPendingUpload}
|
||||||
|
maxSizeMb={maxSizeMb}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -116,8 +120,9 @@ export function TransactionDialog({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derive credit card period on open when cardId is pre-filled
|
// Derive credit card period on open when cardId is pre-filled (create only)
|
||||||
if (
|
if (
|
||||||
|
mode !== "update" &&
|
||||||
initial.paymentMethod === "Cartão de crédito" &&
|
initial.paymentMethod === "Cartão de crédito" &&
|
||||||
initial.cardId &&
|
initial.cardId &&
|
||||||
initial.purchaseDate
|
initial.purchaseDate
|
||||||
@@ -135,6 +140,8 @@ export function TransactionDialog({
|
|||||||
setFormState(initial);
|
setFormState(initial);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setPendingFile(null);
|
setPendingFile(null);
|
||||||
|
setPendingDetachIds([]);
|
||||||
|
setPendingUploadFiles([]);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
dialogOpen,
|
dialogOpen,
|
||||||
@@ -341,7 +348,7 @@ export function TransactionDialog({
|
|||||||
});
|
});
|
||||||
await confirmAttachmentUploadAction({
|
await confirmAttachmentUploadAction({
|
||||||
uploadToken: presign.uploadToken,
|
uploadToken: presign.uploadToken,
|
||||||
applyToSeries: isNewSeries,
|
scope: isNewSeries ? "all" : "current",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,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(),
|
||||||
@@ -382,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,
|
||||||
@@ -395,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);
|
||||||
@@ -520,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>
|
||||||
</>
|
</>
|
||||||
@@ -547,6 +614,7 @@ export function TransactionDialog({
|
|||||||
<AttachmentFilePicker
|
<AttachmentFilePicker
|
||||||
file={pendingFile}
|
file={pendingFile}
|
||||||
onChange={setPendingFile}
|
onChange={setPendingFile}
|
||||||
|
maxSizeMb={maxSizeMb}
|
||||||
/>
|
/>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ const buildColumns = ({
|
|||||||
<span className="text-primary">{dueDateLabel}</span>
|
<span className="text-primary">{dueDateLabel}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
|
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
|
||||||
@@ -230,7 +231,6 @@ const buildColumns = ({
|
|||||||
{name}
|
{name}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
|
||||||
|
|
||||||
{isDivided && (
|
{isDivided && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -241,7 +241,9 @@ const buildColumns = ({
|
|||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">Dividido entre pagadores</span>
|
<span className="sr-only">
|
||||||
|
Dividido entre pagadores
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
@@ -286,7 +288,9 @@ const buildColumns = ({
|
|||||||
<span className="sr-only">Parcela antecipada</span>
|
<span className="sr-only">Parcela antecipada</span>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">Parcela antecipada</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
Parcela antecipada
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -326,6 +330,8 @@ const buildColumns = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
1
src/global.d.ts
vendored
Normal file
1
src/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
|
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
|
||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { buttonVariants } from "@/shared/components/ui/button";
|
import { buttonVariants } from "@/shared/components/ui/button";
|
||||||
@@ -13,11 +14,13 @@ import { cn } from "@/shared/utils/ui";
|
|||||||
interface AnimatedThemeTogglerProps
|
interface AnimatedThemeTogglerProps
|
||||||
extends React.ComponentPropsWithoutRef<"button"> {
|
extends React.ComponentPropsWithoutRef<"button"> {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
variant?: VariantProps<typeof buttonVariants>["variant"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnimatedThemeToggler = ({
|
export const AnimatedThemeToggler = ({
|
||||||
className,
|
className,
|
||||||
duration = 400,
|
duration = 400,
|
||||||
|
variant = "ghost",
|
||||||
...props
|
...props
|
||||||
}: AnimatedThemeTogglerProps) => {
|
}: AnimatedThemeTogglerProps) => {
|
||||||
const [isDark, setIsDark] = useState(false);
|
const [isDark, setIsDark] = useState(false);
|
||||||
@@ -84,10 +87,10 @@ export const AnimatedThemeToggler = ({
|
|||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
data-state={isDark ? "dark" : "light"}
|
data-state={isDark ? "dark" : "light"}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
buttonVariants({ variant, size: "icon-sm" }),
|
||||||
"group relative text-muted-foreground transition-all duration-200",
|
"group relative transition-all duration-200",
|
||||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
|
variant === "ghost" &&
|
||||||
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
|
"text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { version } from "@/package.json";
|
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
variant?: "full" | "small" | "compact";
|
variant?: "full" | "small" | "compact";
|
||||||
className?: string;
|
className?: string;
|
||||||
showVersion?: boolean;
|
/** Apenas nos variants "full" e "compact" */
|
||||||
invertTextOnDark?: boolean;
|
invertTextOnDark?: boolean;
|
||||||
/** Exibe o ícone na cor original, sem filtro preto */
|
/** Exibe o ícone na cor original, sem filtro preto. Apenas nos variants "full" e "compact" */
|
||||||
colorIcon?: boolean;
|
colorIcon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconFilterClass = "brightness-0 saturate-0";
|
||||||
|
|
||||||
export function Logo({
|
export function Logo({
|
||||||
variant = "full",
|
variant = "full",
|
||||||
className,
|
className,
|
||||||
showVersion = false,
|
|
||||||
invertTextOnDark = true,
|
invertTextOnDark = true,
|
||||||
colorIcon = false,
|
colorIcon = false,
|
||||||
}: LogoProps) {
|
}: LogoProps) {
|
||||||
@@ -26,10 +26,7 @@ export function Logo({
|
|||||||
alt="OpenMonetis"
|
alt="OpenMonetis"
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
className={cn(
|
className={cn("object-contain", !colorIcon && iconFilterClass)}
|
||||||
"object-contain",
|
|
||||||
!colorIcon && "brightness-0 saturate-0",
|
|
||||||
)}
|
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
@@ -67,7 +64,7 @@ export function Logo({
|
|||||||
alt="OpenMonetis"
|
alt="OpenMonetis"
|
||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
className="object-contain"
|
className={cn("object-contain", !colorIcon && iconFilterClass)}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
@@ -78,11 +75,6 @@ export function Logo({
|
|||||||
className={cn("object-contain", invertTextOnDark && "dark:invert")}
|
className={cn("object-contain", invertTextOnDark && "dark:invert")}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
{showVersion && (
|
|
||||||
<span className="text-[9px] font-medium text-muted-foreground">
|
|
||||||
{version}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||||
import { Logo } from "@/shared/components/logo";
|
|
||||||
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
|
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
|
||||||
import { RefreshPageButton } from "@/shared/components/refresh-page-button";
|
import { RefreshPageButton } from "@/shared/components/refresh-page-button";
|
||||||
import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications";
|
import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications";
|
||||||
import { NavMenu } from "./nav-menu";
|
import { NavMenu } from "./nav-menu";
|
||||||
|
import { NavbarShell } from "./navbar-shell";
|
||||||
import { NavbarUser } from "./navbar-user";
|
import { NavbarUser } from "./navbar-user";
|
||||||
|
|
||||||
type AppNavbarProps = {
|
type AppNavbarProps = {
|
||||||
@@ -19,9 +18,6 @@ type AppNavbarProps = {
|
|||||||
notificationsSnapshot: DashboardNotificationsSnapshot;
|
notificationsSnapshot: DashboardNotificationsSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navbarActionClassName =
|
|
||||||
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
|
|
||||||
|
|
||||||
export function AppNavbar({
|
export function AppNavbar({
|
||||||
user,
|
user,
|
||||||
pagadorAvatarUrl,
|
pagadorAvatarUrl,
|
||||||
@@ -29,14 +25,8 @@ export function AppNavbar({
|
|||||||
notificationsSnapshot,
|
notificationsSnapshot,
|
||||||
}: AppNavbarProps) {
|
}: AppNavbarProps) {
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 flex h-16 shrink-0 items-center bg-primary">
|
<NavbarShell logoHref="/dashboard" fixed>
|
||||||
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
|
|
||||||
<Link href="/dashboard" className="shrink-0 mr-1">
|
|
||||||
<Logo variant="compact" invertTextOnDark={false} />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<NavMenu />
|
<NavMenu />
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<NotificationBell
|
<NotificationBell
|
||||||
notifications={notificationsSnapshot.notifications}
|
notifications={notificationsSnapshot.notifications}
|
||||||
@@ -45,12 +35,10 @@ export function AppNavbar({
|
|||||||
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
||||||
preLancamentosCount={preLancamentosCount}
|
preLancamentosCount={preLancamentosCount}
|
||||||
/>
|
/>
|
||||||
<RefreshPageButton className={navbarActionClassName} />
|
<RefreshPageButton variant="navbar" />
|
||||||
<AnimatedThemeToggler className={navbarActionClassName} />
|
<AnimatedThemeToggler variant="navbar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
|
<NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
|
||||||
</div>
|
</NavbarShell>
|
||||||
</header>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,18 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/shared/components/ui/sheet";
|
} from "@/shared/components/ui/sheet";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { MobileLink, MobileSectionLabel } from "./mobile-link";
|
import { MobileLink, MobileSectionLabel } from "./mobile-link";
|
||||||
import { NavDropdown } from "./nav-dropdown";
|
import { NavDropdown } from "./nav-dropdown";
|
||||||
import { NAV_SECTIONS } from "./nav-items";
|
import { NAV_SECTIONS } from "./nav-items";
|
||||||
import { NavPill } from "./nav-pill";
|
import { NavPill } from "./nav-pill";
|
||||||
import { triggerActiveClass, triggerClass } from "./nav-styles";
|
|
||||||
import { MobileTools, NavToolsDropdown } from "./nav-tools";
|
import { MobileTools, NavToolsDropdown } from "./nav-tools";
|
||||||
|
|
||||||
|
const triggerClass =
|
||||||
|
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! lowercase! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10!";
|
||||||
|
|
||||||
|
const triggerActiveClass = "bg-black/15! text-black!";
|
||||||
|
|
||||||
export function NavMenu() {
|
export function NavMenu() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
@@ -55,7 +60,10 @@ export function NavMenu() {
|
|||||||
return (
|
return (
|
||||||
<NavigationMenuItem key={section.label}>
|
<NavigationMenuItem key={section.label}>
|
||||||
<NavigationMenuTrigger
|
<NavigationMenuTrigger
|
||||||
className={`${triggerClass} ${isSectionActive ? triggerActiveClass : ""}`}
|
className={cn(
|
||||||
|
triggerClass,
|
||||||
|
isSectionActive && triggerActiveClass,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{section.label}
|
{section.label}
|
||||||
</NavigationMenuTrigger>
|
</NavigationMenuTrigger>
|
||||||
@@ -82,9 +90,9 @@ export function NavMenu() {
|
|||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="navbar"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
className="-order-1 border border-black/10 text-black/75 shadow-none md:hidden hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
|
className="-order-1 md:hidden"
|
||||||
>
|
>
|
||||||
<RiMenuLine className="size-5" />
|
<RiMenuLine className="size-5" />
|
||||||
<span className="sr-only">Abrir menu</span>
|
<span className="sr-only">Abrir menu</span>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { buttonVariants } from "@/shared/components/ui/button";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { NavLink } from "./nav-link";
|
import { NavLink } from "./nav-link";
|
||||||
import { linkActive, linkBase, linkIdle } from "./nav-styles";
|
|
||||||
|
|
||||||
type NavPillProps = {
|
type NavPillProps = {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -23,7 +23,11 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
|
|||||||
<NavLink
|
<NavLink
|
||||||
href={href}
|
href={href}
|
||||||
preservePeriod={preservePeriod}
|
preservePeriod={preservePeriod}
|
||||||
className={cn(linkBase, isActive ? linkActive : linkIdle)}
|
className={cn(
|
||||||
|
buttonVariants({ variant: "navbar", size: "sm" }),
|
||||||
|
"lowercase",
|
||||||
|
isActive && "bg-black/15 text-black",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
export const linkBase =
|
|
||||||
"inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all lowercase";
|
|
||||||
|
|
||||||
export const linkIdle = "text-black/75 hover:bg-black/10 hover:text-black";
|
|
||||||
|
|
||||||
export const linkActive = "bg-black/15 text-black";
|
|
||||||
|
|
||||||
export const triggerActiveClass = ["bg-black/15!", "text-black!"].join(" ");
|
|
||||||
|
|
||||||
export const triggerClass = [
|
|
||||||
"h-8!",
|
|
||||||
"rounded-md!",
|
|
||||||
"px-2!",
|
|
||||||
"py-0!",
|
|
||||||
"text-sm!",
|
|
||||||
"font-medium!",
|
|
||||||
"bg-transparent!",
|
|
||||||
"text-black/75!",
|
|
||||||
"hover:text-black!",
|
|
||||||
"hover:bg-black/10!",
|
|
||||||
"focus:text-black!",
|
|
||||||
"focus:bg-black/10!",
|
|
||||||
"focus-visible:ring-black/20!",
|
|
||||||
"data-[state=open]:text-black!",
|
|
||||||
"data-[state=open]:bg-black/10!",
|
|
||||||
"shadow-none!",
|
|
||||||
"[&_svg]:text-current!",
|
|
||||||
"lowercase!",
|
|
||||||
].join(" ");
|
|
||||||
33
src/shared/components/navigation/navbar/navbar-shell.tsx
Normal file
33
src/shared/components/navigation/navbar/navbar-shell.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
|
||||||
|
type NavbarShellProps = {
|
||||||
|
logoHref?: string;
|
||||||
|
fixed?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NavbarShell({
|
||||||
|
logoHref,
|
||||||
|
fixed = false,
|
||||||
|
children,
|
||||||
|
}: NavbarShellProps) {
|
||||||
|
const positionClass = fixed ? "fixed top-0 left-0 right-0" : "sticky top-0";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary`}
|
||||||
|
>
|
||||||
|
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
|
||||||
|
{logoHref ? (
|
||||||
|
<Link href={logoHref} className="shrink-0">
|
||||||
|
<Logo variant="compact" invertTextOnDark={false} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Logo variant="compact" invertTextOnDark={false} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"} showVersion={!isCollapsed} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiRefreshLine } from "@remixicon/react";
|
import { RiRefreshLine } from "@remixicon/react";
|
||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { buttonVariants } from "@/shared/components/ui/button";
|
import { buttonVariants } from "@/shared/components/ui/button";
|
||||||
@@ -11,10 +12,12 @@ import {
|
|||||||
} from "@/shared/components/ui/tooltip";
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
type RefreshPageButtonProps = React.ComponentPropsWithoutRef<"button">;
|
type RefreshPageButtonProps = React.ComponentPropsWithoutRef<"button"> &
|
||||||
|
Pick<VariantProps<typeof buttonVariants>, "variant">;
|
||||||
|
|
||||||
export function RefreshPageButton({
|
export function RefreshPageButton({
|
||||||
className,
|
className,
|
||||||
|
variant = "ghost",
|
||||||
...props
|
...props
|
||||||
}: RefreshPageButtonProps) {
|
}: RefreshPageButtonProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -36,10 +39,10 @@ export function RefreshPageButton({
|
|||||||
aria-label="Atualizar página"
|
aria-label="Atualizar página"
|
||||||
title="Atualizar página"
|
title="Atualizar página"
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
buttonVariants({ variant, size: "icon-sm" }),
|
||||||
"size-8 text-muted-foreground transition-all duration-200",
|
"transition-all duration-200",
|
||||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
|
variant === "ghost" &&
|
||||||
"disabled:pointer-events-none disabled:opacity-50",
|
"text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const buttonVariants = cva(
|
|||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
navbar:
|
||||||
|
"bg-transparent text-black/75 shadow-none hover:bg-black/10 hover:text-black focus-visible:ring-black/20",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
|||||||
Reference in New Issue
Block a user