mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(anexos): página de galeria de comprovantes e documentos
Adiciona rota `/attachments` com visualização de todos os anexos do usuário em grade, visualização inline de imagem e PDF, navegação entre arquivos do mesmo lançamento e download direto. Inclui também: - API REST em `/api/attachments` para servir os arquivos - Actions `fetch-by-id` e `fetch-dialog-options` em transactions - Item "Anexos" adicionado à navbar - `formatBytes` extraído para `src/shared/utils/number.ts` - Migrations de banco atualizadas - Fix: uploads e remoções de anexo agora funcionam para todos os lançamentos, não apenas os pertencentes a séries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -93,12 +93,8 @@
|
|||||||
"name": "account_userId_user_id_fk",
|
"name": "account_userId_user_id_fk",
|
||||||
"tableFrom": "account",
|
"tableFrom": "account",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -213,12 +209,8 @@
|
|||||||
"name": "tokens_api_user_id_user_id_fk",
|
"name": "tokens_api_user_id_user_id_fk",
|
||||||
"tableFrom": "tokens_api",
|
"tableFrom": "tokens_api",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -284,12 +276,8 @@
|
|||||||
"name": "anexos_user_id_user_id_fk",
|
"name": "anexos_user_id_user_id_fk",
|
||||||
"tableFrom": "anexos",
|
"tableFrom": "anexos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -299,9 +287,7 @@
|
|||||||
"anexos_chave_arquivo_unique": {
|
"anexos_chave_arquivo_unique": {
|
||||||
"name": "anexos_chave_arquivo_unique",
|
"name": "anexos_chave_arquivo_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["chave_arquivo"]
|
||||||
"chave_arquivo"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
@@ -406,12 +392,8 @@
|
|||||||
"name": "orcamentos_user_id_user_id_fk",
|
"name": "orcamentos_user_id_user_id_fk",
|
||||||
"tableFrom": "orcamentos",
|
"tableFrom": "orcamentos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -419,12 +401,8 @@
|
|||||||
"name": "orcamentos_categoria_id_categorias_id_fk",
|
"name": "orcamentos_categoria_id_categorias_id_fk",
|
||||||
"tableFrom": "orcamentos",
|
"tableFrom": "orcamentos",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["categoria_id"],
|
||||||
"categoria_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -542,12 +520,8 @@
|
|||||||
"name": "cartoes_user_id_user_id_fk",
|
"name": "cartoes_user_id_user_id_fk",
|
||||||
"tableFrom": "cartoes",
|
"tableFrom": "cartoes",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -555,12 +529,8 @@
|
|||||||
"name": "cartoes_conta_id_contas_id_fk",
|
"name": "cartoes_conta_id_contas_id_fk",
|
||||||
"tableFrom": "cartoes",
|
"tableFrom": "cartoes",
|
||||||
"tableTo": "contas",
|
"tableTo": "contas",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["conta_id"],
|
||||||
"conta_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -642,12 +612,8 @@
|
|||||||
"name": "categorias_user_id_user_id_fk",
|
"name": "categorias_user_id_user_id_fk",
|
||||||
"tableFrom": "categorias",
|
"tableFrom": "categorias",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -763,12 +729,8 @@
|
|||||||
"name": "dashboard_notification_states_user_id_user_id_fk",
|
"name": "dashboard_notification_states_user_id_user_id_fk",
|
||||||
"tableFrom": "dashboard_notification_states",
|
"tableFrom": "dashboard_notification_states",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -883,12 +845,8 @@
|
|||||||
"name": "contas_user_id_user_id_fk",
|
"name": "contas_user_id_user_id_fk",
|
||||||
"tableFrom": "contas",
|
"tableFrom": "contas",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -935,12 +893,8 @@
|
|||||||
"name": "import_category_mappings_user_id_user_id_fk",
|
"name": "import_category_mappings_user_id_user_id_fk",
|
||||||
"tableFrom": "import_category_mappings",
|
"tableFrom": "import_category_mappings",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -948,12 +902,8 @@
|
|||||||
"name": "import_category_mappings_category_id_categorias_id_fk",
|
"name": "import_category_mappings_category_id_categorias_id_fk",
|
||||||
"tableFrom": "import_category_mappings",
|
"tableFrom": "import_category_mappings",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["category_id"],
|
||||||
"category_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -961,10 +911,7 @@
|
|||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"import_category_mappings_user_id_description_key_pk": {
|
"import_category_mappings_user_id_description_key_pk": {
|
||||||
"name": "import_category_mappings_user_id_description_key_pk",
|
"name": "import_category_mappings_user_id_description_key_pk",
|
||||||
"columns": [
|
"columns": ["user_id", "description_key"]
|
||||||
"user_id",
|
|
||||||
"description_key"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
@@ -1120,12 +1067,8 @@
|
|||||||
"name": "pre_lancamentos_user_id_user_id_fk",
|
"name": "pre_lancamentos_user_id_user_id_fk",
|
||||||
"tableFrom": "pre_lancamentos",
|
"tableFrom": "pre_lancamentos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1133,12 +1076,8 @@
|
|||||||
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
|
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
|
||||||
"tableFrom": "pre_lancamentos",
|
"tableFrom": "pre_lancamentos",
|
||||||
"tableTo": "lancamentos",
|
"tableTo": "lancamentos",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["lancamento_id"],
|
||||||
"lancamento_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "set null",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1278,12 +1217,8 @@
|
|||||||
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
|
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "lancamentos",
|
"tableTo": "lancamentos",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["lancamento_id"],
|
||||||
"lancamento_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1291,12 +1226,8 @@
|
|||||||
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
|
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "pagadores",
|
"tableTo": "pagadores",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["pagador_id"],
|
||||||
"pagador_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1304,12 +1235,8 @@
|
|||||||
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
|
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["categoria_id"],
|
||||||
"categoria_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1317,12 +1244,8 @@
|
|||||||
"name": "antecipacoes_parcelas_user_id_user_id_fk",
|
"name": "antecipacoes_parcelas_user_id_user_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1452,12 +1375,8 @@
|
|||||||
"name": "faturas_user_id_user_id_fk",
|
"name": "faturas_user_id_user_id_fk",
|
||||||
"tableFrom": "faturas",
|
"tableFrom": "faturas",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1465,12 +1384,8 @@
|
|||||||
"name": "faturas_cartao_id_cartoes_id_fk",
|
"name": "faturas_cartao_id_cartoes_id_fk",
|
||||||
"tableFrom": "faturas",
|
"tableFrom": "faturas",
|
||||||
"tableTo": "cartoes",
|
"tableTo": "cartoes",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["cartao_id"],
|
||||||
"cartao_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -1544,12 +1459,8 @@
|
|||||||
"name": "anotacoes_user_id_user_id_fk",
|
"name": "anotacoes_user_id_user_id_fk",
|
||||||
"tableFrom": "anotacoes",
|
"tableFrom": "anotacoes",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1637,12 +1548,8 @@
|
|||||||
"name": "passkey_userId_user_id_fk",
|
"name": "passkey_userId_user_id_fk",
|
||||||
"tableFrom": "passkey",
|
"tableFrom": "passkey",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1725,12 +1632,8 @@
|
|||||||
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
|
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
|
||||||
"tableFrom": "compartilhamentos_pagador",
|
"tableFrom": "compartilhamentos_pagador",
|
||||||
"tableTo": "pagadores",
|
"tableTo": "pagadores",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["pagador_id"],
|
||||||
"pagador_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1738,12 +1641,8 @@
|
|||||||
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
|
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
|
||||||
"tableFrom": "compartilhamentos_pagador",
|
"tableFrom": "compartilhamentos_pagador",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["shared_with_user_id"],
|
||||||
"shared_with_user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1751,12 +1650,8 @@
|
|||||||
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
|
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
|
||||||
"tableFrom": "compartilhamentos_pagador",
|
"tableFrom": "compartilhamentos_pagador",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["created_by_user_id"],
|
||||||
"created_by_user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1912,12 +1807,8 @@
|
|||||||
"name": "pagadores_user_id_user_id_fk",
|
"name": "pagadores_user_id_user_id_fk",
|
||||||
"tableFrom": "pagadores",
|
"tableFrom": "pagadores",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -2006,12 +1897,8 @@
|
|||||||
"name": "insights_salvos_user_id_user_id_fk",
|
"name": "insights_salvos_user_id_user_id_fk",
|
||||||
"tableFrom": "insights_salvos",
|
"tableFrom": "insights_salvos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -2081,12 +1968,8 @@
|
|||||||
"name": "session_userId_user_id_fk",
|
"name": "session_userId_user_id_fk",
|
||||||
"tableFrom": "session",
|
"tableFrom": "session",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -2096,9 +1979,7 @@
|
|||||||
"session_token_unique": {
|
"session_token_unique": {
|
||||||
"name": "session_token_unique",
|
"name": "session_token_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["token"]
|
||||||
"token"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
@@ -2144,12 +2025,8 @@
|
|||||||
"name": "lancamento_anexos_lancamento_id_lancamentos_id_fk",
|
"name": "lancamento_anexos_lancamento_id_lancamentos_id_fk",
|
||||||
"tableFrom": "lancamento_anexos",
|
"tableFrom": "lancamento_anexos",
|
||||||
"tableTo": "lancamentos",
|
"tableTo": "lancamentos",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["lancamento_id"],
|
||||||
"lancamento_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -2157,12 +2034,8 @@
|
|||||||
"name": "lancamento_anexos_anexo_id_anexos_id_fk",
|
"name": "lancamento_anexos_anexo_id_anexos_id_fk",
|
||||||
"tableFrom": "lancamento_anexos",
|
"tableFrom": "lancamento_anexos",
|
||||||
"tableTo": "anexos",
|
"tableTo": "anexos",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["anexo_id"],
|
||||||
"anexo_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -2170,10 +2043,7 @@
|
|||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"lancamento_anexos_lancamento_id_anexo_id_pk": {
|
"lancamento_anexos_lancamento_id_anexo_id_pk": {
|
||||||
"name": "lancamento_anexos_lancamento_id_anexo_id_pk",
|
"name": "lancamento_anexos_lancamento_id_anexo_id_pk",
|
||||||
"columns": [
|
"columns": ["lancamento_id", "anexo_id"]
|
||||||
"lancamento_id",
|
|
||||||
"anexo_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
@@ -2577,12 +2447,8 @@
|
|||||||
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
|
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "antecipacoes_parcelas",
|
"tableTo": "antecipacoes_parcelas",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["antecipacao_id"],
|
||||||
"antecipacao_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "set null",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -2590,12 +2456,8 @@
|
|||||||
"name": "lancamentos_user_id_user_id_fk",
|
"name": "lancamentos_user_id_user_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -2603,12 +2465,8 @@
|
|||||||
"name": "lancamentos_cartao_id_cartoes_id_fk",
|
"name": "lancamentos_cartao_id_cartoes_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "cartoes",
|
"tableTo": "cartoes",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["cartao_id"],
|
||||||
"cartao_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
},
|
},
|
||||||
@@ -2616,12 +2474,8 @@
|
|||||||
"name": "lancamentos_conta_id_contas_id_fk",
|
"name": "lancamentos_conta_id_contas_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "contas",
|
"tableTo": "contas",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["conta_id"],
|
||||||
"conta_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
},
|
},
|
||||||
@@ -2629,12 +2483,8 @@
|
|||||||
"name": "lancamentos_categoria_id_categorias_id_fk",
|
"name": "lancamentos_categoria_id_categorias_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["categoria_id"],
|
||||||
"categoria_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
},
|
},
|
||||||
@@ -2642,12 +2492,8 @@
|
|||||||
"name": "lancamentos_pagador_id_pagadores_id_fk",
|
"name": "lancamentos_pagador_id_pagadores_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "pagadores",
|
"tableTo": "pagadores",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["pagador_id"],
|
||||||
"pagador_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -2712,9 +2558,7 @@
|
|||||||
"user_email_unique": {
|
"user_email_unique": {
|
||||||
"name": "user_email_unique",
|
"name": "user_email_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["email"]
|
||||||
"email"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
@@ -2785,12 +2629,8 @@
|
|||||||
"name": "preferencias_usuario_user_id_user_id_fk",
|
"name": "preferencias_usuario_user_id_user_id_fk",
|
||||||
"tableFrom": "preferencias_usuario",
|
"tableFrom": "preferencias_usuario",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -2800,9 +2640,7 @@
|
|||||||
"preferencias_usuario_user_id_unique": {
|
"preferencias_usuario_user_id_unique": {
|
||||||
"name": "preferencias_usuario_user_id_unique",
|
"name": "preferencias_usuario_user_id_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["user_id"]
|
||||||
"user_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|||||||
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function AnexosLoading() {
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-6">
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<Skeleton className="h-10 w-40 rounded-md bg-foreground/10" />
|
||||||
|
|
||||||
|
{/* Month navigation */}
|
||||||
|
<Skeleton className="h-10 w-64 rounded-md bg-foreground/10" />
|
||||||
|
|
||||||
|
{/* Count */}
|
||||||
|
<Skeleton className="h-4 w-20 rounded-md bg-foreground/10" />
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex flex-col overflow-hidden rounded-lg border"
|
||||||
|
>
|
||||||
|
<Skeleton className="aspect-square w-full bg-foreground/10" />
|
||||||
|
<div className="space-y-1.5 p-2.5">
|
||||||
|
<Skeleton className="h-3 w-3/4 rounded bg-foreground/10" />
|
||||||
|
<Skeleton className="h-3 w-full rounded bg-foreground/10" />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Skeleton className="h-3 w-16 rounded bg-foreground/10" />
|
||||||
|
<Skeleton className="h-3 w-12 rounded bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/app/(dashboard)/attachments/page.tsx
Normal file
36
src/app/(dashboard)/attachments/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
|
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
|
||||||
|
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { parsePeriodParam } from "@/shared/utils/period";
|
||||||
|
|
||||||
|
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams?: PageSearchParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSingleParam = (
|
||||||
|
params: Record<string, string | string[] | undefined> | undefined,
|
||||||
|
key: string,
|
||||||
|
) => {
|
||||||
|
const value = params?.[key];
|
||||||
|
if (!value) return null;
|
||||||
|
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
|
const userId = await getUserId();
|
||||||
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
const { period } = parsePeriodParam(periodoParam);
|
||||||
|
|
||||||
|
const attachments = await fetchAttachmentsForPeriod(userId, period);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-6">
|
||||||
|
<AttachmentsPage attachments={attachments} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
27
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { attachments } from "@/db/schema";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ attachmentId: string }> },
|
||||||
|
) {
|
||||||
|
const [userId, { attachmentId }] = await Promise.all([getUserId(), params]);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ fileKey: attachments.fileKey })
|
||||||
|
.from(attachments)
|
||||||
|
.where(
|
||||||
|
and(eq(attachments.id, attachmentId), eq(attachments.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await createPresignedGetUrl(row.fileKey);
|
||||||
|
return NextResponse.json({ url });
|
||||||
|
}
|
||||||
208
src/features/attachments/components/attachment-grid-item.tsx
Normal file
208
src/features/attachments/components/attachment-grid-item.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiFileLine, RiFilePdf2Line, RiImageLine } from "@remixicon/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
|
import { formatDate } from "@/shared/utils/date";
|
||||||
|
import { formatBytes } from "@/shared/utils/number";
|
||||||
|
|
||||||
|
interface PdfCanvasProps {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfCanvas({ url }: PdfCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [locked, setLocked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLocked(false);
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const pdfjsLib = await import("pdfjs-dist");
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
||||||
|
|
||||||
|
let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;
|
||||||
|
try {
|
||||||
|
pdf = await pdfjsLib.getDocument(url).promise;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { name?: string }).name === "PasswordException") {
|
||||||
|
if (!cancelled) setLocked(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await pdf.getPage(1);
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || cancelled) return;
|
||||||
|
|
||||||
|
const containerWidth = canvas.parentElement?.offsetWidth ?? 200;
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const scale = containerWidth / viewport.width;
|
||||||
|
const scaled = page.getViewport({ scale });
|
||||||
|
|
||||||
|
canvas.width = scaled.width;
|
||||||
|
canvas.height = scaled.height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
await page.render({ canvasContext: ctx, canvas, viewport: scaled })
|
||||||
|
.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
render().catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
|
||||||
|
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground/60">
|
||||||
|
PDF Protegido
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentGridItemProps {
|
||||||
|
attachment: AttachmentForPeriod;
|
||||||
|
url?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
onDetails: () => void;
|
||||||
|
isLoadingDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentGridItem({
|
||||||
|
attachment,
|
||||||
|
url,
|
||||||
|
onClick,
|
||||||
|
onDetails,
|
||||||
|
isLoadingDetails = false,
|
||||||
|
}: AttachmentGridItemProps) {
|
||||||
|
const isPdf = attachment.mimeType === "application/pdf";
|
||||||
|
const isImage = attachment.mimeType.startsWith("image/");
|
||||||
|
const amount = Number.parseFloat(attachment.transactionAmount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex flex-col overflow-hidden rounded-lg border bg-card transition-all duration-200 hover:border-primary">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="relative aspect-4/3 w-full border-b overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* Conteúdo do thumbnail */}
|
||||||
|
{isImage && url && (
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
alt={attachment.fileName}
|
||||||
|
fill
|
||||||
|
unoptimized
|
||||||
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImage && !url && (
|
||||||
|
<div className="h-full w-full animate-pulse bg-muted-foreground/10" />
|
||||||
|
)}
|
||||||
|
{isPdf && url && <PdfCanvas url={url} />}
|
||||||
|
{isPdf && !url && (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-red-50 dark:bg-red-950/20">
|
||||||
|
<RiFilePdf2Line className="size-14 text-red-400/60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isImage && !isPdf && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||||
|
<RiFileLine className="size-14 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay no hover */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/10" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Informações */}
|
||||||
|
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
|
||||||
|
{/* Nome do arquivo + tipo */}
|
||||||
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
<div className="shrink-0 gap-0.5 text-xs opacity-60">
|
||||||
|
{isPdf && <RiFilePdf2Line className="size-4 text-red-500" />}
|
||||||
|
{isImage && <RiImageLine className="size-4 text-blue-500" />}
|
||||||
|
{!isPdf && !isImage && <RiFileLine className="size-4" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="truncate text-sm font-medium leading-tight text-foreground">
|
||||||
|
{attachment.fileName}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
{attachment.fileName}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data */}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(attachment.purchaseDate)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Transação e Valor */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{attachment.transactionName}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{attachment.transactionName}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: Tamanho + Botão Detalhes */}
|
||||||
|
<div className="mt-auto flex items-center justify-between border-t pt-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground/70">
|
||||||
|
{formatBytes(attachment.fileSize)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDetails}
|
||||||
|
disabled={isLoadingDetails}
|
||||||
|
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoadingDetails ? "Carregando..." : "Detalhes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/features/attachments/components/attachment-preview.tsx
Normal file
201
src/features/attachments/components/attachment-preview.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiArrowLeftSLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiCloseLine,
|
||||||
|
RiDownloadLine,
|
||||||
|
RiExternalLinkLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
|
||||||
|
interface AttachmentPreviewProps {
|
||||||
|
attachments: AttachmentForPeriod[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentPreview({
|
||||||
|
attachments,
|
||||||
|
selectedIndex,
|
||||||
|
onClose,
|
||||||
|
}: AttachmentPreviewProps) {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const open = selectedIndex >= 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIndex >= 0) setCurrentIndex(selectedIndex);
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowLeft") setCurrentIndex((i) => Math.max(0, i - 1));
|
||||||
|
if (e.key === "ArrowRight")
|
||||||
|
setCurrentIndex((i) => Math.min(attachments.length - 1, i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [open, attachments.length]);
|
||||||
|
|
||||||
|
const attachment = attachments[currentIndex];
|
||||||
|
const attachmentId = attachment?.attachmentId;
|
||||||
|
|
||||||
|
// Busca URL fresca a cada troca de anexo
|
||||||
|
useEffect(() => {
|
||||||
|
if (!attachmentId) return;
|
||||||
|
setPreviewUrl(null);
|
||||||
|
|
||||||
|
fetch(`/api/attachments/${attachmentId}/presign`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { url: string }) => setPreviewUrl(data.url))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [attachmentId]);
|
||||||
|
|
||||||
|
if (!attachment) return null;
|
||||||
|
|
||||||
|
const isPdf = attachment.mimeType === "application/pdf";
|
||||||
|
const isImage = attachment.mimeType.startsWith("image/");
|
||||||
|
const hasPrev = currentIndex > 0;
|
||||||
|
const hasNext = currentIndex < attachments.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="flex h-[92vh] w-[min(96vw,1400px)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:p-0"
|
||||||
|
>
|
||||||
|
<DialogHeader className="flex-row items-start justify-between gap-3 border-b px-4 py-3 sm:px-5">
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<DialogTitle
|
||||||
|
className="truncate text-sm font-medium"
|
||||||
|
title={attachment.transactionName}
|
||||||
|
>
|
||||||
|
{attachment.transactionName}
|
||||||
|
</DialogTitle>
|
||||||
|
<p
|
||||||
|
className="truncate text-xs text-muted-foreground"
|
||||||
|
title={attachment.fileName}
|
||||||
|
>
|
||||||
|
{attachment.fileName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{attachments.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setCurrentIndex((i) => i - 1)}
|
||||||
|
title="Anterior (←)"
|
||||||
|
>
|
||||||
|
<RiArrowLeftSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="select-none text-xs text-muted-foreground tabular-nums">
|
||||||
|
{currentIndex + 1} / {attachments.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setCurrentIndex((i) => i + 1)}
|
||||||
|
title="Próximo (→)"
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!previewUrl}
|
||||||
|
asChild={!!previewUrl}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<a
|
||||||
|
href={previewUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={attachment.fileName}
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!previewUrl}
|
||||||
|
asChild={!!previewUrl}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<a href={previewUrl} target="_blank" rel="noreferrer">
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="ghost" size="icon">
|
||||||
|
<RiCloseLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
|
{!previewUrl && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPdf && previewUrl && (
|
||||||
|
<iframe
|
||||||
|
key={attachment.attachmentId}
|
||||||
|
src={previewUrl}
|
||||||
|
className="h-full w-full border-0 bg-background"
|
||||||
|
title={attachment.fileName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImage && previewUrl && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-black/85 p-4 sm:p-6">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
key={attachment.attachmentId}
|
||||||
|
src={previewUrl}
|
||||||
|
alt={attachment.fileName}
|
||||||
|
className="max-h-full max-w-full rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/features/attachments/components/attachments-page.tsx
Normal file
275
src/features/attachments/components/attachments-page.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiAttachmentLine,
|
||||||
|
RiFilePdf2Line,
|
||||||
|
RiImageLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type React from "react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { AttachmentGridItem } from "@/features/attachments/components/attachment-grid-item";
|
||||||
|
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
|
||||||
|
import { useAttachmentUrl } from "@/features/attachments/hooks/use-attachment-url";
|
||||||
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
|
import { fetchTransactionByIdAction } from "@/features/transactions/actions/fetch-by-id";
|
||||||
|
import type { TransactionDialogOptions } from "@/features/transactions/actions/fetch-dialog-options";
|
||||||
|
import { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
|
||||||
|
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
|
||||||
|
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||||
|
import type { TransactionItem } from "@/features/transactions/components/types";
|
||||||
|
import { EmptyState } from "@/shared/components/empty-state";
|
||||||
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
|
import PageDescription from "@/shared/components/page-description";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
|
type FilterType = "all" | "images" | "pdfs";
|
||||||
|
|
||||||
|
function AttachmentGridItemWithUrl({
|
||||||
|
attachment,
|
||||||
|
onClick,
|
||||||
|
onDetails,
|
||||||
|
isLoadingDetails,
|
||||||
|
}: {
|
||||||
|
attachment: AttachmentForPeriod;
|
||||||
|
onClick: () => void;
|
||||||
|
onDetails: () => void;
|
||||||
|
isLoadingDetails: boolean;
|
||||||
|
}) {
|
||||||
|
const { url, containerRef } = useAttachmentUrl(attachment.attachmentId);
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<AttachmentGridItem
|
||||||
|
attachment={attachment}
|
||||||
|
url={url ?? undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
onDetails={onDetails}
|
||||||
|
isLoadingDetails={isLoadingDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS: {
|
||||||
|
value: FilterType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "all",
|
||||||
|
label: "Todos",
|
||||||
|
icon: <RiAttachmentLine className="size-3.5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "images",
|
||||||
|
label: "Imagens",
|
||||||
|
icon: <RiImageLine className="size-3.5 text-blue-500" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "pdfs",
|
||||||
|
label: "PDFs",
|
||||||
|
icon: <RiFilePdf2Line className="size-3.5 text-red-500" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AttachmentsPageProps {
|
||||||
|
attachments: AttachmentForPeriod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [filter, setFilter] = useState<FilterType>("all");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [transactionDetails, setTransactionDetails] =
|
||||||
|
useState<TransactionItem | null>(null);
|
||||||
|
const [loadingTransactionId, setLoadingTransactionId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Edit dialog state
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [transactionToEdit, setTransactionToEdit] =
|
||||||
|
useState<TransactionItem | null>(null);
|
||||||
|
const [dialogOptions, setDialogOptions] =
|
||||||
|
useState<TransactionDialogOptions | null>(null);
|
||||||
|
|
||||||
|
const filteredAttachments = attachments.filter((a) => {
|
||||||
|
if (filter === "images") return a.mimeType.startsWith("image/");
|
||||||
|
if (filter === "pdfs") return a.mimeType === "application/pdf";
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageCount = attachments.filter((a) =>
|
||||||
|
a.mimeType.startsWith("image/"),
|
||||||
|
).length;
|
||||||
|
const pdfCount = attachments.filter(
|
||||||
|
(a) => a.mimeType === "application/pdf",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const counts: Record<FilterType, number> = {
|
||||||
|
all: attachments.length,
|
||||||
|
images: imageCount,
|
||||||
|
pdfs: pdfCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleSelect(attachment: AttachmentForPeriod) {
|
||||||
|
const idx = filteredAttachments.findIndex(
|
||||||
|
(a) =>
|
||||||
|
a.attachmentId === attachment.attachmentId &&
|
||||||
|
a.transactionId === attachment.transactionId,
|
||||||
|
);
|
||||||
|
setSelectedIndex(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDetails(transactionId: string) {
|
||||||
|
setLoadingTransactionId(transactionId);
|
||||||
|
startTransition(async () => {
|
||||||
|
const transaction = await fetchTransactionByIdAction(transactionId);
|
||||||
|
setLoadingTransactionId(null);
|
||||||
|
if (transaction) setTransactionDetails(transaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(transaction: TransactionItem) {
|
||||||
|
setTransactionToEdit(transaction);
|
||||||
|
startTransition(async () => {
|
||||||
|
const options = await fetchTransactionDialogOptionsAction();
|
||||||
|
setDialogOptions(options);
|
||||||
|
setEditOpen(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<PageDescription
|
||||||
|
icon={<RiAttachmentLine className="size-5" />}
|
||||||
|
title="Anexos"
|
||||||
|
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MonthNavigation />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{attachments.length === 0 ? (
|
||||||
|
<div className="flex w-full items-center justify-center py-12">
|
||||||
|
<EmptyState
|
||||||
|
media={<RiAttachmentLine className="size-6 text-primary" />}
|
||||||
|
title="Nenhum anexo neste mês"
|
||||||
|
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header: filtros + contagem */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{filteredAttachments.length}{" "}
|
||||||
|
{filteredAttachments.length === 1 ? "anexo" : "anexos"}
|
||||||
|
{filter !== "all" &&
|
||||||
|
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border p-1">
|
||||||
|
{FILTERS.map(({ value, label, icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilter(value);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
filter === value
|
||||||
|
? "bg-primary text-primary-foreground [&_svg]:opacity-100"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(filter !== value && "opacity-60")}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{label}{" "}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"tabular-nums",
|
||||||
|
filter === value ? "opacity-80" : "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
({counts[value]})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredAttachments.length === 0 ? (
|
||||||
|
<div className="flex w-full items-center justify-center py-12">
|
||||||
|
<EmptyState
|
||||||
|
media={<RiAttachmentLine className="size-6 text-primary" />}
|
||||||
|
title="Nenhum anexo encontrado"
|
||||||
|
description="Não há anexos do tipo selecionado neste mês."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{filteredAttachments.map((attachment) => (
|
||||||
|
<AttachmentGridItemWithUrl
|
||||||
|
key={`${attachment.attachmentId}-${attachment.transactionId}`}
|
||||||
|
attachment={attachment}
|
||||||
|
onClick={() => handleSelect(attachment)}
|
||||||
|
onDetails={() => handleDetails(attachment.transactionId)}
|
||||||
|
isLoadingDetails={
|
||||||
|
isPending &&
|
||||||
|
loadingTransactionId === attachment.transactionId
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AttachmentPreview
|
||||||
|
attachments={filteredAttachments}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onClose={() => setSelectedIndex(-1)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransactionDetailsDialog
|
||||||
|
open={!!transactionDetails}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setTransactionDetails(null);
|
||||||
|
}}
|
||||||
|
transaction={transactionDetails}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{dialogOptions && transactionToEdit && (
|
||||||
|
<TransactionDialog
|
||||||
|
mode="update"
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setEditOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setTransactionToEdit(null);
|
||||||
|
setDialogOptions(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
transaction={transactionToEdit}
|
||||||
|
payerOptions={dialogOptions.payerOptions}
|
||||||
|
splitPayerOptions={dialogOptions.splitPayerOptions}
|
||||||
|
defaultPayerId={dialogOptions.defaultPayerId}
|
||||||
|
accountOptions={dialogOptions.accountOptions}
|
||||||
|
cardOptions={dialogOptions.cardOptions}
|
||||||
|
categoryOptions={dialogOptions.categoryOptions}
|
||||||
|
estabelecimentos={dialogOptions.estabelecimentos}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/features/attachments/hooks/use-attachment-url.ts
Normal file
31
src/features/attachments/hooks/use-attachment-url.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useAttachmentUrl(attachmentId: string) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUrl(null);
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries[0].isIntersecting) return;
|
||||||
|
observer.disconnect();
|
||||||
|
fetch(`/api/attachments/${attachmentId}/presign`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { url: string }) => setUrl(data.url))
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
{ rootMargin: "150px" },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [attachmentId]);
|
||||||
|
|
||||||
|
return { url, containerRef };
|
||||||
|
}
|
||||||
70
src/features/attachments/queries.ts
Normal file
70
src/features/attachments/queries.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
|
import {
|
||||||
|
attachments,
|
||||||
|
categories,
|
||||||
|
transactionAttachments,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
|
||||||
|
export type AttachmentForPeriod = {
|
||||||
|
attachmentId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
transactionId: string;
|
||||||
|
transactionName: string;
|
||||||
|
transactionAmount: string;
|
||||||
|
transactionPeriod: string;
|
||||||
|
purchaseDate: Date;
|
||||||
|
categoryName: string | null;
|
||||||
|
categoryIcon: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAttachmentsForPeriod(
|
||||||
|
userId: string,
|
||||||
|
period: string,
|
||||||
|
): Promise<AttachmentForPeriod[]> {
|
||||||
|
"use cache";
|
||||||
|
cacheTag(`dashboard-${userId}`);
|
||||||
|
cacheLife({ revalidate: 3 });
|
||||||
|
|
||||||
|
const adminPayerId = await getAdminPayerId(userId);
|
||||||
|
if (!adminPayerId) return [];
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
attachmentId: attachments.id,
|
||||||
|
fileName: attachments.fileName,
|
||||||
|
fileSize: attachments.fileSize,
|
||||||
|
mimeType: attachments.mimeType,
|
||||||
|
transactionId: transactions.id,
|
||||||
|
transactionName: transactions.name,
|
||||||
|
transactionAmount: transactions.amount,
|
||||||
|
transactionPeriod: transactions.period,
|
||||||
|
purchaseDate: transactions.purchaseDate,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryIcon: categories.icon,
|
||||||
|
})
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.innerJoin(
|
||||||
|
attachments,
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
|
eq(attachments.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
transactions,
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.transactionId, transactions.id),
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
eq(transactions.payerId, adminPayerId),
|
||||||
|
eq(transactions.period, period),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.leftJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.orderBy(desc(transactions.purchaseDate), desc(attachments.id));
|
||||||
|
}
|
||||||
23
src/features/transactions/actions/fetch-by-id.ts
Normal file
23
src/features/transactions/actions/fetch-by-id.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { transactions } from "@/db/schema";
|
||||||
|
import { mapTransactionsData } from "@/features/transactions/page-helpers";
|
||||||
|
import { fetchTransactionsWithRelations } from "@/features/transactions/queries";
|
||||||
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import type { TransactionItem } from "../components/types";
|
||||||
|
|
||||||
|
export async function fetchTransactionByIdAction(
|
||||||
|
transactionId: string,
|
||||||
|
): Promise<TransactionItem | null> {
|
||||||
|
const user = await getUser();
|
||||||
|
const rows = await fetchTransactionsWithRelations({
|
||||||
|
filters: [
|
||||||
|
eq(transactions.id, transactionId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
],
|
||||||
|
excludeInitialBalanceFromIncome: false,
|
||||||
|
});
|
||||||
|
const mapped = mapTransactionsData(rows);
|
||||||
|
return mapped[0] ?? null;
|
||||||
|
}
|
||||||
55
src/features/transactions/actions/fetch-dialog-options.ts
Normal file
55
src/features/transactions/actions/fetch-dialog-options.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildOptionSets,
|
||||||
|
buildSluggedFilters,
|
||||||
|
} from "@/features/transactions/page-helpers";
|
||||||
|
import {
|
||||||
|
fetchRecentEstablishments,
|
||||||
|
fetchTransactionFilterSources,
|
||||||
|
} from "@/features/transactions/queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import type { SelectOption } from "../components/types";
|
||||||
|
|
||||||
|
export type TransactionDialogOptions = {
|
||||||
|
payerOptions: SelectOption[];
|
||||||
|
splitPayerOptions: SelectOption[];
|
||||||
|
defaultPayerId: string | null;
|
||||||
|
accountOptions: SelectOption[];
|
||||||
|
cardOptions: SelectOption[];
|
||||||
|
categoryOptions: SelectOption[];
|
||||||
|
estabelecimentos: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchTransactionDialogOptionsAction(): Promise<TransactionDialogOptions> {
|
||||||
|
const userId = await getUserId();
|
||||||
|
|
||||||
|
const [filterSources, estabelecimentos] = await Promise.all([
|
||||||
|
fetchTransactionFilterSources(userId),
|
||||||
|
fetchRecentEstablishments(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
|
|
||||||
|
const {
|
||||||
|
payerOptions,
|
||||||
|
splitPayerOptions,
|
||||||
|
defaultPayerId,
|
||||||
|
accountOptions,
|
||||||
|
cardOptions,
|
||||||
|
categoryOptions,
|
||||||
|
} = buildOptionSets({
|
||||||
|
...sluggedFilters,
|
||||||
|
payerRows: filterSources.payerRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
payerOptions,
|
||||||
|
splitPayerOptions,
|
||||||
|
defaultPayerId,
|
||||||
|
accountOptions,
|
||||||
|
cardOptions,
|
||||||
|
categoryOptions,
|
||||||
|
estabelecimentos,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -85,7 +85,7 @@ export function AttachmentFilePicker({
|
|||||||
<RiAttachment2 className="size-4" />
|
<RiAttachment2 className="size-4" />
|
||||||
Adicionar anexo
|
Adicionar anexo
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px]">
|
<span className="text-xs">
|
||||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import {
|
|||||||
RiDownloadLine,
|
RiDownloadLine,
|
||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
RiFileImageLine,
|
RiFileImageLine,
|
||||||
RiFileLine,
|
RiFilePdf2Line,
|
||||||
RiFilePdfLine,
|
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -30,10 +29,9 @@ function formatBytes(bytes: number): string {
|
|||||||
|
|
||||||
function AttachmentIcon({ mimeType }: { mimeType: string }) {
|
function AttachmentIcon({ mimeType }: { mimeType: string }) {
|
||||||
if (mimeType === "application/pdf")
|
if (mimeType === "application/pdf")
|
||||||
return <RiFilePdfLine className="size-4 text-red-500 shrink-0" />;
|
return <RiFilePdf2Line className="size-4 text-red-500 shrink-0" />;
|
||||||
if (mimeType.startsWith("image/"))
|
if (mimeType.startsWith("image/"))
|
||||||
return <RiFileImageLine className="size-4 text-blue-500 shrink-0" />;
|
return <RiFileImageLine className="size-4 text-blue-500 shrink-0" />;
|
||||||
return <RiFileLine className="size-4 text-muted-foreground shrink-0" />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AttachmentPreview({
|
function AttachmentPreview({
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function TransactionDetailsDialog({
|
|||||||
}: TransactionDetailsDialogProps) {
|
}: TransactionDetailsDialogProps) {
|
||||||
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAttachmentCount(null);
|
setAttachmentCount(null);
|
||||||
}, [transaction?.id]);
|
}, [transaction?.id]);
|
||||||
@@ -87,7 +88,7 @@ export function TransactionDetailsDialog({
|
|||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
Resumo
|
Resumo
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-2xl font-semibold">
|
<p className="mt-1 text-2xl font-medium">
|
||||||
{currencyFormatter.format(valorTotal)}
|
{currencyFormatter.format(valorTotal)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,14 +236,14 @@ export function TransactionDetailsDialog({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{onEdit && !transaction.readonly && (
|
|
||||||
<Button variant="outline" onClick={handleEdit}>
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="button">Fechar</Button>
|
<Button type="button" variant="outline">
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
{onEdit && !transaction.readonly && (
|
||||||
|
<Button onClick={handleEdit}>Editar</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export function TransactionDialog({
|
|||||||
defaultTransactionType,
|
defaultTransactionType,
|
||||||
isImporting,
|
isImporting,
|
||||||
cardOptions,
|
cardOptions,
|
||||||
|
mode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const primaryPayerId = formState.payerId;
|
const primaryPayerId = formState.payerId;
|
||||||
@@ -555,38 +556,23 @@ export function TransactionDialog({
|
|||||||
<AttachmentSection
|
<AttachmentSection
|
||||||
transactionId={transaction?.id ?? ""}
|
transactionId={transaction?.id ?? ""}
|
||||||
maxSizeMb={maxSizeMb}
|
maxSizeMb={maxSizeMb}
|
||||||
pendingDetachIds={
|
pendingDetachIds={pendingDetachIds}
|
||||||
transaction?.seriesId ? pendingDetachIds : undefined
|
onPendingDetach={(id) =>
|
||||||
|
setPendingDetachIds((prev) => [...prev, id])
|
||||||
}
|
}
|
||||||
onPendingDetach={
|
onUndoPendingDetach={(id) =>
|
||||||
transaction?.seriesId
|
|
||||||
? (id) => setPendingDetachIds((prev) => [...prev, id])
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onUndoPendingDetach={
|
|
||||||
transaction?.seriesId
|
|
||||||
? (id) =>
|
|
||||||
setPendingDetachIds((prev) =>
|
setPendingDetachIds((prev) =>
|
||||||
prev.filter((x) => x !== id),
|
prev.filter((x) => x !== id),
|
||||||
)
|
)
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
pendingUploadFiles={
|
pendingUploadFiles={pendingUploadFiles}
|
||||||
transaction?.seriesId ? pendingUploadFiles : undefined
|
onPendingUpload={(file) =>
|
||||||
}
|
|
||||||
onPendingUpload={
|
|
||||||
transaction?.seriesId
|
|
||||||
? (file) =>
|
|
||||||
setPendingUploadFiles((prev) => [...prev, file])
|
setPendingUploadFiles((prev) => [...prev, file])
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
onCancelPendingUpload={
|
onCancelPendingUpload={(file) =>
|
||||||
transaction?.seriesId
|
|
||||||
? (file) =>
|
|
||||||
setPendingUploadFiles((prev) =>
|
setPendingUploadFiles((prev) =>
|
||||||
prev.filter((f) => f !== file),
|
prev.filter((f) => f !== file),
|
||||||
)
|
)
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
RiArrowLeftRightLine,
|
RiArrowLeftRightLine,
|
||||||
RiAtLine,
|
RiAtLine,
|
||||||
|
RiAttachmentLine,
|
||||||
RiBankCard2Line,
|
RiBankCard2Line,
|
||||||
RiBankLine,
|
RiBankLine,
|
||||||
RiBarChart2Line,
|
RiBarChart2Line,
|
||||||
@@ -110,6 +111,14 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
icon: <RiTodoLine className="size-4" />,
|
icon: <RiTodoLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/attachments",
|
||||||
|
label: "anexos",
|
||||||
|
description: "Comprovantes e documentos",
|
||||||
|
icon: <RiAttachmentLine className="size-4" />,
|
||||||
|
iconClass: "text-primary",
|
||||||
|
preservePeriod: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ export const revalidateConfig = {
|
|||||||
payers: ["/payers"],
|
payers: ["/payers"],
|
||||||
notes: ["/notes", "/notes/archived", "/dashboard"],
|
notes: ["/notes", "/notes/archived", "/dashboard"],
|
||||||
notifications: ["/dashboard"],
|
notifications: ["/dashboard"],
|
||||||
transactions: ["/transactions", "/accounts"],
|
transactions: ["/transactions", "/accounts", "/attachments"],
|
||||||
inbox: ["/inbox", "/transactions", "/dashboard"],
|
inbox: ["/inbox", "/transactions", "/dashboard"],
|
||||||
|
attachments: ["/attachments"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Entities whose mutations should invalidate the dashboard cache */
|
/** Entities whose mutations should invalidate the dashboard cache */
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Utility functions for safe number conversions
|
* Utility functions for safe number conversions and formatting
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely converts unknown value to number
|
* Safely converts unknown value to number
|
||||||
* @param value - Value to convert
|
* @param value - Value to convert
|
||||||
|
|||||||
Reference in New Issue
Block a user