mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-15 13:01:47 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a768bc8ba | ||
|
|
8a03a50132 | ||
|
|
246bb14a00 | ||
|
|
86bcffec66 | ||
|
|
81e7151876 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -5,6 +5,20 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||
|
||||
## [2.5.7] - 2026-05-14
|
||||
|
||||
Esta versão faz um polimento visual no relatório de análise de parcelas, deixando o estabelecimento como referência principal do card e mantendo o cartão visível de forma mais discreta no contexto da compra.
|
||||
|
||||
### Alterado
|
||||
- Relatórios: em `/reports/installment-analysis`, os cards de parcelas passam a usar o logo do estabelecimento como avatar principal; o logo do cartão agora aparece menor ao lado do nome do cartão, tanto no card quanto no modal de detalhes.
|
||||
- Relatórios: a página de análise de parcelas pré-carrega os mapeamentos de logos de estabelecimentos para evitar troca visual após o primeiro render.
|
||||
- Lançamentos: o campo de anexos no modal agora aceita arquivos colados com `Ctrl+V`, mantendo o botão para buscar arquivos normalmente.
|
||||
- Lançamentos: o modal agora usa uma única área interna de rolagem, com cabeçalho e rodapé estáveis, reduzindo travadas ao rolar e ao abrir "Condições, anotações e anexos".
|
||||
- Anotações: tarefas agora podem ser editadas inline no modal "Atualizar anotação"; clicar no texto abre o input e o botão de remover vira botão de salvar naquela linha.
|
||||
|
||||
### Corrigido
|
||||
- Relatórios: o join com cartões na análise de parcelas agora também valida `cards.userId`, mantendo o filtro de ownership explícito na consulta.
|
||||
|
||||
## [2.5.6] - 2026-05-07
|
||||
|
||||
Esta versão entrega um conjunto de melhorias em torno do fluxo de lançamentos: filtros mais úteis, divisão por porcentagem, indicador de orçamento dentro do modal e correção de um bug em totais por pessoa que considerava contas excluídas do saldo. Também inclui ajustes de robustez no display da calculadora (sem mais overflow do modal com valores longos) e o fix do cache de RSC nos filtros multi-seleção.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.5.6",
|
||||
"version": "2.5.7",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { connection } from "next/server";
|
||||
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
||||
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const data = await fetchInstallmentAnalysis(user.id);
|
||||
const logoMappings = await prefetchLogoMappings(
|
||||
user.id,
|
||||
data.installmentGroups.map((group) => group.name),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 pb-8">
|
||||
<InstallmentAnalysisPage data={data} />
|
||||
<LogoPrefetchProvider mappings={logoMappings}>
|
||||
<InstallmentAnalysisPage data={data} />
|
||||
</LogoPrefetchProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import {
|
||||
RiBankCard2Line,
|
||||
RiCheckboxCircleFill,
|
||||
RiEyeLine,
|
||||
RiFileList2Line,
|
||||
RiTimeLine,
|
||||
} from "@remixicon/react";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { cn } from "@/shared/utils";
|
||||
import type { InstallmentGroup } from "./types";
|
||||
|
||||
@@ -79,6 +81,8 @@ export function InstallmentGroupCard({
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
|
||||
const cardName = group.cartaoName ?? "Compra parcelada";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -111,25 +115,24 @@ export function InstallmentGroupCard({
|
||||
{/* Info principal */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{group.cartaoLogo ? (
|
||||
<Image
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
alt={group.cartaoName ?? "Cartão"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-10 flex items-center justify-center">
|
||||
<RiBankCard2Line className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<EstablishmentLogo name={group.name} size={40} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base truncate">
|
||||
{group.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{group.cartaoName ?? "Compra parcelada"}
|
||||
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
|
||||
{cardLogoSrc ? (
|
||||
<Image
|
||||
src={cardLogoSrc}
|
||||
alt={`Logo do cartão ${cardName}`}
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4.5 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">{cardName}</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +150,7 @@ export function InstallmentGroupCard({
|
||||
|
||||
<CardContent>
|
||||
{/* Grid de valores */}
|
||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-muted/50 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-primary/5 mb-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
Valor total
|
||||
@@ -165,7 +168,7 @@ export function InstallmentGroupCard({
|
||||
amount={pendingAmount}
|
||||
className={cn(
|
||||
"text-lg font-semibold",
|
||||
pendingAmount > 0 ? "text-amber-600" : "text-success-600",
|
||||
pendingAmount > 0 ? "text-primary" : "text-success",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -183,14 +186,18 @@ export function InstallmentGroupCard({
|
||||
</div>
|
||||
{unpaidCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<RiTimeLine className="size-3.5 text-amber-600" />
|
||||
<RiTimeLine className="size-3.5" />
|
||||
<span>
|
||||
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={progress} className="h-2.5" />
|
||||
<Progress
|
||||
value={progress}
|
||||
className="h-2.5 bg-muted"
|
||||
indicatorClassName="bg-success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Valor selecionado */}
|
||||
@@ -212,13 +219,13 @@ export function InstallmentGroupCard({
|
||||
{/* Botão para abrir detalhes */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full gap-1.5"
|
||||
onClick={() => setIsDetailsOpen(true)}
|
||||
>
|
||||
<RiEyeLine className="size-4" />
|
||||
Ver detalhes ({group.pendingInstallments.length} parcelas)
|
||||
<RiFileList2Line className="size-4" />
|
||||
detalhes ({group.pendingInstallments.length} parcelas)
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -228,18 +235,26 @@ export function InstallmentGroupCard({
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{group.cartaoLogo ? (
|
||||
<img
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
alt={group.cartaoName ?? "Cartão"}
|
||||
className="size-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
||||
<EstablishmentLogo name={group.name} size={32} />
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate text-base">
|
||||
{group.name}
|
||||
</DialogTitle>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{cardLogoSrc ? (
|
||||
<Image
|
||||
src={cardLogoSrc}
|
||||
alt={`Logo do cartão ${cardName}`}
|
||||
width={14}
|
||||
height={14}
|
||||
className="size-3.5 shrink-0 rounded-full object-cover opacity-75"
|
||||
/>
|
||||
) : (
|
||||
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">{cardName}</span>
|
||||
</div>
|
||||
)}
|
||||
<DialogTitle className="text-base">{group.name}</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription className="sr-only">
|
||||
Detalhes das parcelas do grupo {group.name}
|
||||
|
||||
@@ -92,7 +92,10 @@ export async function fetchInstallmentAnalysis(
|
||||
cartaoLogo: cards.logo,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
||||
.leftJoin(
|
||||
cards,
|
||||
and(eq(transactions.cardId, cards.id), eq(cards.userId, userId)),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddCircleFill, RiDeleteBinLine } from "@remixicon/react";
|
||||
import {
|
||||
RiAddCircleFill,
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
} from "@remixicon/react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
@@ -69,10 +73,13 @@ export function NoteDialog({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [newTaskText, setNewTaskText] = useState("");
|
||||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||
const [editingTaskText, setEditingTaskText] = useState("");
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const descRef = useRef<HTMLTextAreaElement>(null);
|
||||
const newTaskRef = useRef<HTMLInputElement>(null);
|
||||
const editingTaskRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
@@ -90,6 +97,8 @@ export function NoteDialog({
|
||||
resetForm(buildInitialValues(note));
|
||||
setErrorMessage(null);
|
||||
setNewTaskText("");
|
||||
setEditingTaskId(null);
|
||||
setEditingTaskText("");
|
||||
requestAnimationFrame(() => titleRef.current?.focus());
|
||||
}
|
||||
}, [dialogOpen, note, resetForm]);
|
||||
@@ -126,7 +135,12 @@ export function NoteDialog({
|
||||
formState.description.trim() === (note?.description ?? "").trim() &&
|
||||
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
|
||||
|
||||
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
|
||||
const disableSubmit =
|
||||
isPending ||
|
||||
onlySpaces ||
|
||||
unchanged ||
|
||||
invalidLen ||
|
||||
Boolean(editingTaskId);
|
||||
|
||||
const handleOpenChange = (v: boolean) => {
|
||||
setDialogOpen(v);
|
||||
@@ -159,6 +173,10 @@ export function NoteDialog({
|
||||
"tasks",
|
||||
(formState.tasks || []).filter((t) => t.id !== taskId),
|
||||
);
|
||||
if (editingTaskId === taskId) {
|
||||
setEditingTaskId(null);
|
||||
setEditingTaskText("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTask = (taskId: string) => {
|
||||
@@ -170,6 +188,40 @@ export function NoteDialog({
|
||||
);
|
||||
};
|
||||
|
||||
const handleStartEditTask = (task: Task) => {
|
||||
if (isPending) return;
|
||||
|
||||
setEditingTaskId(task.id);
|
||||
setEditingTaskText(task.text);
|
||||
requestAnimationFrame(() => {
|
||||
editingTaskRef.current?.focus();
|
||||
editingTaskRef.current?.select();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveTask = (taskId: string) => {
|
||||
const text = normalize(editingTaskText);
|
||||
if (!text) {
|
||||
toast.error("O texto da tarefa não pode estar vazio.");
|
||||
editingTaskRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
updateField(
|
||||
"tasks",
|
||||
(formState.tasks || []).map((t) =>
|
||||
t.id === taskId ? { ...t, text } : t,
|
||||
),
|
||||
);
|
||||
setEditingTaskId(null);
|
||||
setEditingTaskText("");
|
||||
};
|
||||
|
||||
const handleCancelEditTask = () => {
|
||||
setEditingTaskId(null);
|
||||
setEditingTaskText("");
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
@@ -373,33 +425,78 @@ export function NoteDialog({
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
|
||||
checked={task.completed}
|
||||
onCheckedChange={() => handleToggleTask(task.id)}
|
||||
disabled={isPending}
|
||||
aria-label={`Marcar "${task.text}" como ${
|
||||
task.completed ? "não concluída" : "concluída"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 text-sm wrap-break-word",
|
||||
task.completed
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
{editingTaskId === task.id ? (
|
||||
<Input
|
||||
ref={editingTaskRef}
|
||||
value={editingTaskText}
|
||||
onChange={(e) => setEditingTaskText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSaveTask(task.id);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCancelEditTask();
|
||||
}
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="h-8 min-w-0 flex-1"
|
||||
aria-label={`Editar "${task.text}"`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
|
||||
checked={task.completed}
|
||||
onCheckedChange={() => handleToggleTask(task.id)}
|
||||
disabled={isPending}
|
||||
aria-label={`Marcar "${task.text}" como ${
|
||||
task.completed ? "não concluída" : "concluída"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartEditTask(task)}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 cursor-text text-left text-sm wrap-break-word transition-colors hover:text-primary disabled:cursor-not-allowed",
|
||||
task.completed
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{task.text}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTask(task.id)}
|
||||
onClick={() =>
|
||||
editingTaskId === task.id
|
||||
? handleSaveTask(task.id)
|
||||
: handleRemoveTask(task.id)
|
||||
}
|
||||
disabled={isPending}
|
||||
className="shrink-0 text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||
aria-label={`Remover "${task.text}"`}
|
||||
className={cn(
|
||||
"shrink-0 transition-colors",
|
||||
editingTaskId === task.id
|
||||
? "text-success hover:text-success/80"
|
||||
: "text-muted-foreground/50 hover:text-destructive",
|
||||
)}
|
||||
aria-label={
|
||||
editingTaskId === task.id
|
||||
? `Salvar "${task.text}"`
|
||||
: `Remover "${task.text}"`
|
||||
}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
{editingTaskId === task.id ? (
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
) : (
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE_MB,
|
||||
} from "@/features/transactions/lib/attachments-config";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
getFilesFromClipboard,
|
||||
isTextEditingTarget,
|
||||
validateAttachmentFile,
|
||||
} from "./attachment-file-utils";
|
||||
|
||||
interface AttachmentFilePickerProps {
|
||||
files: File[];
|
||||
@@ -22,34 +27,54 @@ export function AttachmentFilePicker({
|
||||
onRemove,
|
||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||
}: AttachmentFilePickerProps) {
|
||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function addFile(file: File) {
|
||||
const validation = validateAttachmentFile(file, maxSizeMb);
|
||||
if (!validation.ok) {
|
||||
toast.error(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd(file);
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const selected = e.target.files?.[0];
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
if (
|
||||
!ALLOWED_MIME_TYPES.includes(
|
||||
selected.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||
)
|
||||
) {
|
||||
toast.error(
|
||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.size > maxFileSizeBytes) {
|
||||
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd(selected);
|
||||
addFile(selected);
|
||||
}
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
|
||||
const pastedFiles = getFilesFromClipboard(event);
|
||||
if (pastedFiles.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
for (const file of pastedFiles) {
|
||||
addFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleDocumentPaste(event: ClipboardEvent) {
|
||||
if (isTextEditingTarget(event.target)) return;
|
||||
|
||||
const pastedFiles = getFilesFromClipboard(event);
|
||||
if (pastedFiles.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
for (const file of pastedFiles) {
|
||||
addFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("paste", handleDocumentPaste);
|
||||
return () => document.removeEventListener("paste", handleDocumentPaste);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">Anexos</p>
|
||||
@@ -90,13 +115,15 @@ export function AttachmentFilePicker({
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onPaste={handlePaste}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<RiAttachment2 className="size-4" />
|
||||
Adicionar anexo
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
|
||||
MB
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE_MB,
|
||||
} from "@/features/transactions/lib/attachments-config";
|
||||
|
||||
type AttachmentValidationResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export function validateAttachmentFile(
|
||||
file: File,
|
||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||
): AttachmentValidationResult {
|
||||
if (
|
||||
!ALLOWED_MIME_TYPES.includes(
|
||||
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||
};
|
||||
}
|
||||
|
||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||
if (file.size > maxFileSizeBytes) {
|
||||
return { ok: false, error: `O arquivo deve ter no máximo ${maxSizeMb}MB.` };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
type ClipboardLikeEvent = ClipboardEvent | React.ClipboardEvent;
|
||||
|
||||
export function getFilesFromClipboard(event: ClipboardLikeEvent): File[] {
|
||||
const files = Array.from(event.clipboardData?.files ?? []);
|
||||
if (files.length > 0) return files;
|
||||
|
||||
return Array.from(event.clipboardData?.items ?? [])
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
}
|
||||
|
||||
export function isTextEditingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
return (
|
||||
tagName === "input" ||
|
||||
tagName === "textarea" ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[contenteditable="true"]') !== null
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RiAttachment2 } from "@remixicon/react";
|
||||
import { useRef, useTransition } from "react";
|
||||
import { useEffect, useRef, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
confirmAttachmentUploadAction,
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE_MB,
|
||||
} from "@/features/transactions/lib/attachments-config";
|
||||
import {
|
||||
getFilesFromClipboard,
|
||||
isTextEditingTarget,
|
||||
validateAttachmentFile,
|
||||
} from "./attachment-file-utils";
|
||||
|
||||
interface AttachmentUploadProps {
|
||||
transactionId: string;
|
||||
@@ -25,7 +30,6 @@ export function AttachmentUpload({
|
||||
onPendingUpload,
|
||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||
}: AttachmentUploadProps) {
|
||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@@ -36,19 +40,13 @@ export function AttachmentUpload({
|
||||
|
||||
if (!file) return;
|
||||
|
||||
if (
|
||||
!ALLOWED_MIME_TYPES.includes(
|
||||
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||
)
|
||||
) {
|
||||
toast.error(
|
||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleFile(file);
|
||||
}
|
||||
|
||||
if (file.size > maxFileSizeBytes) {
|
||||
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
||||
function handleFile(file: File) {
|
||||
const validation = validateAttachmentFile(file, maxSizeMb);
|
||||
if (!validation.ok) {
|
||||
toast.error(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +92,29 @@ export function AttachmentUpload({
|
||||
});
|
||||
}
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
|
||||
const [file] = getFilesFromClipboard(event);
|
||||
if (!file) return;
|
||||
|
||||
event.preventDefault();
|
||||
handleFile(file);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleDocumentPaste(event: ClipboardEvent) {
|
||||
if (isPending || isTextEditingTarget(event.target)) return;
|
||||
|
||||
const [file] = getFilesFromClipboard(event);
|
||||
if (!file) return;
|
||||
|
||||
event.preventDefault();
|
||||
handleFile(file);
|
||||
}
|
||||
|
||||
document.addEventListener("paste", handleDocumentPaste);
|
||||
return () => document.removeEventListener("paste", handleDocumentPaste);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
@@ -107,6 +128,7 @@ export function AttachmentUpload({
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onPaste={handlePaste}
|
||||
disabled={isPending}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -115,7 +137,8 @@ export function AttachmentUpload({
|
||||
</span>
|
||||
{!isPending && (
|
||||
<span className="text-xs">
|
||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
|
||||
MB
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
import {
|
||||
currencyFormatter,
|
||||
formatCondition,
|
||||
formatDate,
|
||||
formatPeriod,
|
||||
} from "@/features/transactions/lib/formatting-helpers";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -34,7 +34,7 @@ import { Separator } from "@/shared/components/ui/separator";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { getCategoryColorFromName } from "@/shared/utils/category-colors";
|
||||
import { parseLocalDateString } from "@/shared/utils/date";
|
||||
import { formatDate, parseLocalDateString } from "@/shared/utils/date";
|
||||
import { getIconComponent, getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||
import { AttachmentSection } from "../attachments/attachment-section";
|
||||
import { InstallmentTimeline } from "../shared/installment-timeline";
|
||||
@@ -55,10 +55,9 @@ export function TransactionDetailsDialog({
|
||||
}: TransactionDetailsDialogProps) {
|
||||
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador
|
||||
useEffect(() => {
|
||||
setAttachmentCount(null);
|
||||
}, [transaction?.id]);
|
||||
}, []);
|
||||
|
||||
if (!transaction) return null;
|
||||
|
||||
@@ -87,11 +86,16 @@ export function TransactionDetailsDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{transaction.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatDate(transaction.purchaseDate)}
|
||||
</DialogDescription>
|
||||
<DialogHeader className="text-left">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<EstablishmentLogo size={40} name={transaction.name} />
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">{transaction.name}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{formatDate(transaction.purchaseDate)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { RiArrowDropDownLine } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createTransactionAction,
|
||||
@@ -102,6 +102,8 @@ export function TransactionDialog({
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
||||
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
||||
const [extrasOpen, setExtrasOpen] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
@@ -142,6 +144,7 @@ export function TransactionDialog({
|
||||
setPendingFiles([]);
|
||||
setPendingDetachIds([]);
|
||||
setPendingUploadFiles([]);
|
||||
setExtrasOpen(initial.condition !== "À vista");
|
||||
}
|
||||
}, [
|
||||
dialogOpen,
|
||||
@@ -211,6 +214,22 @@ export function TransactionDialog({
|
||||
});
|
||||
}
|
||||
|
||||
function handleExtrasOpenChange(nextOpen: boolean) {
|
||||
setExtrasOpen(nextOpen);
|
||||
|
||||
if (nextOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
@@ -527,18 +546,21 @@ export function TransactionDialog({
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="min-w-0 overflow-x-hidden">
|
||||
<DialogContent className="flex max-h-[90vh] min-w-0 flex-col overflow-hidden p-4 sm:p-10">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="flex min-w-0 flex-col gap-0"
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col gap-0"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1 pb-1"
|
||||
>
|
||||
{/* Detalhes */}
|
||||
<div className="space-y-3">
|
||||
<BasicFieldsSection
|
||||
@@ -634,7 +656,8 @@ export function TransactionDialog({
|
||||
</>
|
||||
) : (
|
||||
<Collapsible
|
||||
defaultOpen={formState.condition !== "À vista"}
|
||||
open={extrasOpen}
|
||||
onOpenChange={handleExtrasOpenChange}
|
||||
className="min-w-0"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
||||
@@ -680,7 +703,7 @@ export function TransactionDialog({
|
||||
<p className="mt-3 text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogFooter className="mt-4 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -87,7 +87,7 @@ function Calendar({
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-xs select-none",
|
||||
|
||||
@@ -172,8 +172,8 @@ export function DatePicker({
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
onSelect={handleCalendarSelect}
|
||||
fromYear={2020}
|
||||
toYear={new Date().getFullYear() + 10}
|
||||
startMonth={new Date(2020, 0)}
|
||||
endMonth={new Date(new Date().getFullYear() + 10, 11)}
|
||||
locale={ptBR}
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
||||
Reference in New Issue
Block a user