mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 03:11:46 +00:00
fix(lançamentos): reforçar validações e revisar formulário
This commit is contained in:
@@ -10,14 +10,16 @@ import {
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
interface AttachmentFilePickerProps {
|
||||
file: File | null;
|
||||
onChange: (file: File | null) => void;
|
||||
files: File[];
|
||||
onAdd: (file: File) => void;
|
||||
onRemove: (file: File) => void;
|
||||
maxSizeMb?: number;
|
||||
}
|
||||
|
||||
export function AttachmentFilePicker({
|
||||
file,
|
||||
onChange,
|
||||
files,
|
||||
onAdd,
|
||||
onRemove,
|
||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||
}: AttachmentFilePickerProps) {
|
||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||
@@ -45,12 +47,12 @@ export function AttachmentFilePicker({
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(selected);
|
||||
onAdd(selected);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs">Anexo</p>
|
||||
<p className="text-xs font-medium">Anexos</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
@@ -59,37 +61,44 @@ export function AttachmentFilePicker({
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{file ? (
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
|
||||
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0"
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</Button>
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={`${file.name}-${file.size}-${file.lastModified}`}
|
||||
className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0"
|
||||
onClick={() => onRemove(file)}
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<RiAttachment2 className="size-4" />
|
||||
Adicionar anexo
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<RiAttachment2 className="size-4" />
|
||||
Adicionar anexo
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function CategorySection({
|
||||
>
|
||||
<Label htmlFor="categoria">Categoria</Label>
|
||||
<Select
|
||||
value={formState.categoryId}
|
||||
value={formState.categoryId ?? ""}
|
||||
onValueChange={(value) => onFieldChange("categoryId", value)}
|
||||
>
|
||||
<SelectTrigger id="categoria" className="w-full">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { RiSliceFill } from "@remixicon/react";
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { PayerSelectContent } from "../../select-items";
|
||||
import type { PayerSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
@@ -34,75 +37,59 @@ export function PayerSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div className="w-full space-y-1">
|
||||
<Label htmlFor="payer">Pagador</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formState.payerId}
|
||||
onValueChange={(value) => onFieldChange("payerId", value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="payer"
|
||||
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.payerId &&
|
||||
(() => {
|
||||
const selectedOption = payerOptions.find(
|
||||
(opt) => opt.value === formState.payerId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PayerSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{payerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PayerSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.isSplit && (
|
||||
<CurrencyInput
|
||||
value={formState.primarySplitAmount}
|
||||
onValueChange={handlePrimaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
|
||||
formState.isSplit
|
||||
? "border-primary/20 bg-primary/5"
|
||||
: "border-border bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Dividir lançamento</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Atribuir parte do valor a outro pagador.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CheckboxPrimitive.Root
|
||||
checked={formState.isSplit}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSplit", Boolean(checked))
|
||||
}
|
||||
aria-label="Dividir lançamento"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
formState.isSplit
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input dark:bg-input/30",
|
||||
)}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
||||
<RiSliceFill className="size-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
</div>
|
||||
|
||||
{formState.isSplit ? (
|
||||
<div className="w-full space-y-1 mb-1">
|
||||
<Label htmlFor="secondaryPayer">Dividir com</Label>
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div className="w-full space-y-1">
|
||||
<Label htmlFor="payer">Pagador</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formState.secondaryPayerId}
|
||||
onValueChange={(value) =>
|
||||
onFieldChange("secondaryPayerId", value)
|
||||
}
|
||||
value={formState.payerId ?? ""}
|
||||
onValueChange={(value) => onFieldChange("payerId", value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="secondaryPayer"
|
||||
disabled={secondaryPayerOptions.length === 0}
|
||||
className="w-[55%]"
|
||||
id="payer"
|
||||
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.secondaryPayerId &&
|
||||
{formState.payerId &&
|
||||
(() => {
|
||||
const selectedOption = secondaryPayerOptions.find(
|
||||
(opt) => opt.value === formState.secondaryPayerId,
|
||||
const selectedOption = payerOptions.find(
|
||||
(opt) => opt.value === formState.payerId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PayerSelectContent
|
||||
@@ -114,7 +101,7 @@ export function PayerSection({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{secondaryPayerOptions.map((option) => (
|
||||
{payerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PayerSelectContent
|
||||
label={option.label}
|
||||
@@ -124,15 +111,68 @@ export function PayerSection({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CurrencyInput
|
||||
value={formState.secondarySplitAmount}
|
||||
onValueChange={handleSecondaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
/>
|
||||
{formState.isSplit && (
|
||||
<CurrencyInput
|
||||
value={formState.primarySplitAmount}
|
||||
onValueChange={handlePrimaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{formState.isSplit ? (
|
||||
<div className="w-full space-y-1 mb-1">
|
||||
<Label htmlFor="secondaryPayer">Dividir com</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formState.secondaryPayerId ?? ""}
|
||||
onValueChange={(value) =>
|
||||
onFieldChange("secondaryPayerId", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="secondaryPayer"
|
||||
disabled={secondaryPayerOptions.length === 0}
|
||||
className="w-[55%]"
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.secondaryPayerId &&
|
||||
(() => {
|
||||
const selectedOption = secondaryPayerOptions.find(
|
||||
(opt) => opt.value === formState.secondaryPayerId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PayerSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{secondaryPayerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PayerSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CurrencyInput
|
||||
value={formState.secondarySplitAmount}
|
||||
onValueChange={handleSecondaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiCheckboxBlankCircleLine,
|
||||
RiCheckboxCircleFill,
|
||||
} from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { PAYMENT_METHODS } from "@/features/transactions/constants";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { MonthPicker } from "@/shared/components/ui/month-picker";
|
||||
import {
|
||||
@@ -71,6 +76,7 @@ export function PaymentMethodSection({
|
||||
isUpdateMode,
|
||||
disablePaymentMethod,
|
||||
disableCardSelect,
|
||||
showSettledToggle,
|
||||
}: PaymentMethodSectionProps) {
|
||||
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
|
||||
const showContaSelect = [
|
||||
@@ -92,154 +98,200 @@ export function PaymentMethodSection({
|
||||
const hasSecondaryColumn = isCartaoSelected || showContaSelect;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
{!isUpdateMode ? (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full space-y-1",
|
||||
hasSecondaryColumn ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
|
||||
<Select
|
||||
value={formState.paymentMethod}
|
||||
onValueChange={(value) => onFieldChange("paymentMethod", value)}
|
||||
disabled={disablePaymentMethod}
|
||||
<div className="space-y-3">
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
{!isUpdateMode ? (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full space-y-1",
|
||||
hasSecondaryColumn ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="paymentMethod"
|
||||
className="w-full"
|
||||
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
|
||||
<Select
|
||||
value={formState.paymentMethod}
|
||||
onValueChange={(value) => onFieldChange("paymentMethod", value)}
|
||||
disabled={disablePaymentMethod}
|
||||
>
|
||||
<SelectValue placeholder="Selecione" className="w-full">
|
||||
{formState.paymentMethod && (
|
||||
<PaymentMethodSelectContent label={formState.paymentMethod} />
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHODS.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
<PaymentMethodSelectContent label={method} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<SelectTrigger
|
||||
id="paymentMethod"
|
||||
className="w-full"
|
||||
disabled={disablePaymentMethod}
|
||||
>
|
||||
<SelectValue placeholder="Selecione" className="w-full">
|
||||
{formState.paymentMethod && (
|
||||
<PaymentMethodSelectContent
|
||||
label={formState.paymentMethod}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHODS.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
<PaymentMethodSelectContent label={method} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isCartaoSelected ? (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full space-y-1",
|
||||
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="cartao">Cartão</Label>
|
||||
<Select
|
||||
value={formState.cardId}
|
||||
onValueChange={(value) => onFieldChange("cardId", value)}
|
||||
disabled={disableCardSelect}
|
||||
{isCartaoSelected ? (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full space-y-1",
|
||||
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="cartao"
|
||||
className="w-full"
|
||||
<Label htmlFor="cartao">Cartão</Label>
|
||||
<Select
|
||||
value={formState.cardId ?? ""}
|
||||
onValueChange={(value) => onFieldChange("cardId", value)}
|
||||
disabled={disableCardSelect}
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.cardId &&
|
||||
(() => {
|
||||
const selectedOption = cardOptions.find(
|
||||
(opt) => opt.value === formState.cardId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<SelectTrigger
|
||||
id="cartao"
|
||||
className="w-full"
|
||||
disabled={disableCardSelect}
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.cardId &&
|
||||
(() => {
|
||||
const selectedOption = cardOptions.find(
|
||||
(opt) => opt.value === formState.cardId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<AccountCardSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cardOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum cartão cadastrado
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
cardOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<AccountCardSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cardOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum cartão cadastrado
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
cardOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<AccountCardSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.cardId ? (
|
||||
<InlinePeriodPicker
|
||||
period={formState.period}
|
||||
onPeriodChange={(value) => onFieldChange("period", value)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.cardId ? (
|
||||
<InlinePeriodPicker
|
||||
period={formState.period}
|
||||
onPeriodChange={(value) => onFieldChange("period", value)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCartaoSelected && showContaSelect ? (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full space-y-1",
|
||||
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="conta">Conta</Label>
|
||||
<Select
|
||||
value={formState.accountId}
|
||||
onValueChange={(value) => onFieldChange("accountId", value)}
|
||||
{!isCartaoSelected && showContaSelect ? (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full space-y-1",
|
||||
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<SelectTrigger id="conta" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.accountId &&
|
||||
(() => {
|
||||
const selectedOption = filteredContaOptions.find(
|
||||
(opt) => opt.value === formState.accountId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<Label htmlFor="conta">Conta</Label>
|
||||
<Select
|
||||
value={formState.accountId ?? ""}
|
||||
onValueChange={(value) => onFieldChange("accountId", value)}
|
||||
>
|
||||
<SelectTrigger id="conta" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.accountId &&
|
||||
(() => {
|
||||
const selectedOption = filteredContaOptions.find(
|
||||
(opt) => opt.value === formState.accountId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<AccountCardSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredContaOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conta cadastrada
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredContaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<AccountCardSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredContaOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conta cadastrada
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredContaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<AccountCardSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showSettledToggle ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
|
||||
formState.isSettled
|
||||
? "border-success/20 bg-success/5"
|
||||
: "border-border bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm text-foreground text-left">
|
||||
Marcar como pago
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-left">
|
||||
Indica que o valor já foi pago.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onFieldChange("isSettled", !formState.isSettled)}
|
||||
aria-label={
|
||||
formState.isSettled ? "Desfazer pagamento" : "Marcar como pago"
|
||||
}
|
||||
aria-pressed={Boolean(formState.isSettled)}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
formState.isSettled
|
||||
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{formState.isSettled ? (
|
||||
<RiCheckboxCircleFill className="size-4" />
|
||||
) : (
|
||||
<RiCheckboxBlankCircleLine className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import type { SplitAndSettlementSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
export function SplitAndSettlementSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
showSettledToggle,
|
||||
}: SplitAndSettlementSectionProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1",
|
||||
showSettledToggle ? "md:w-1/2 md:pr-2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Dividir lançamento</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Atribuir parte do valor a outro pagador.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={formState.isSplit}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSplit", Boolean(checked))
|
||||
}
|
||||
aria-label="Dividir lançamento"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSettledToggle ? (
|
||||
<div className="space-y-1 md:w-1/2 md:pr-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Marcar como pago</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Indica que o valor já foi pago.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={Boolean(formState.isSettled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSettled", Boolean(checked))
|
||||
}
|
||||
aria-label="Marcar como concluído"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ export interface TransactionDialogProps {
|
||||
maxSizeMb?: number;
|
||||
onBulkEditRequest?: (data: {
|
||||
id: string;
|
||||
purchaseDate: string;
|
||||
period: string;
|
||||
name: string;
|
||||
categoryId: string | undefined;
|
||||
note: string;
|
||||
@@ -71,10 +73,6 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
|
||||
hideTransactionType?: boolean;
|
||||
}
|
||||
|
||||
export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {
|
||||
showSettledToggle: boolean;
|
||||
}
|
||||
|
||||
export interface PayerSectionProps extends BaseFieldSectionProps {
|
||||
payerOptions: SelectOption[];
|
||||
secondaryPayerOptions: SelectOption[];
|
||||
@@ -87,6 +85,7 @@ export interface PaymentMethodSectionProps extends BaseFieldSectionProps {
|
||||
isUpdateMode: boolean;
|
||||
disablePaymentMethod: boolean;
|
||||
disableCardSelect: boolean;
|
||||
showSettledToggle: boolean;
|
||||
}
|
||||
|
||||
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {
|
||||
|
||||
@@ -46,7 +46,6 @@ import { ConditionSection } from "./condition-section";
|
||||
import { NoteSection } from "./note-section";
|
||||
import { PayerSection } from "./payer-section";
|
||||
import { PaymentMethodSection } from "./payment-method-section";
|
||||
import { SplitAndSettlementSection } from "./split-settlement-section";
|
||||
import type {
|
||||
FormState,
|
||||
TransactionDialogProps,
|
||||
@@ -99,7 +98,7 @@ export function TransactionDialog({
|
||||
);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
||||
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
||||
|
||||
@@ -139,7 +138,7 @@ export function TransactionDialog({
|
||||
|
||||
setFormState(initial);
|
||||
setErrorMessage(null);
|
||||
setPendingFile(null);
|
||||
setPendingFiles([]);
|
||||
setPendingDetachIds([]);
|
||||
setPendingUploadFiles([]);
|
||||
}
|
||||
@@ -330,27 +329,29 @@ export function TransactionDialog({
|
||||
const result = await createTransactionAction(payload);
|
||||
|
||||
if (result.success) {
|
||||
if (pendingFile && result.data?.ids?.length) {
|
||||
if (pendingFiles.length > 0 && result.data?.ids?.length) {
|
||||
const firstId = result.data.ids[0];
|
||||
const isNewSeries =
|
||||
formState.condition === "Parcelado" ||
|
||||
formState.condition === "Recorrente";
|
||||
const presign = await getPresignedUploadUrlAction({
|
||||
fileName: pendingFile.name,
|
||||
mimeType: pendingFile.type,
|
||||
fileSize: pendingFile.size,
|
||||
transactionId: firstId,
|
||||
});
|
||||
if (presign.success) {
|
||||
await fetch(presign.presignedUrl, {
|
||||
method: "PUT",
|
||||
body: pendingFile,
|
||||
headers: { "Content-Type": pendingFile.type },
|
||||
});
|
||||
await confirmAttachmentUploadAction({
|
||||
uploadToken: presign.uploadToken,
|
||||
scope: isNewSeries ? "all" : "current",
|
||||
for (const file of pendingFiles) {
|
||||
const presign = await getPresignedUploadUrlAction({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
fileSize: file.size,
|
||||
transactionId: firstId,
|
||||
});
|
||||
if (presign.success) {
|
||||
await fetch(presign.presignedUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
});
|
||||
await confirmAttachmentUploadAction({
|
||||
uploadToken: presign.uploadToken,
|
||||
scope: isNewSeries ? "all" : "current",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success(result.message);
|
||||
@@ -371,6 +372,8 @@ export function TransactionDialog({
|
||||
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
|
||||
onBulkEditRequest({
|
||||
id: transaction?.id ?? "",
|
||||
purchaseDate: formState.purchaseDate,
|
||||
period: formState.period,
|
||||
name: formState.name.trim(),
|
||||
categoryId: formState.categoryId,
|
||||
note: formState.note.trim() || "",
|
||||
@@ -493,30 +496,30 @@ export function TransactionDialog({
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<div className="min-w-0 space-y-3 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
||||
<BasicFieldsSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
estabelecimentos={estabelecimentos}
|
||||
/>
|
||||
<div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
||||
{/* Detalhes */}
|
||||
<div className="space-y-3">
|
||||
<BasicFieldsSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
estabelecimentos={estabelecimentos}
|
||||
/>
|
||||
|
||||
<CategorySection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
categoryOptions={categoryOptions}
|
||||
categoryGroups={categoryGroups}
|
||||
isUpdateMode={isUpdateMode}
|
||||
hideTransactionType={
|
||||
Boolean(isNewWithType) && !forceShowTransactionType
|
||||
}
|
||||
/>
|
||||
<CategorySection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
categoryOptions={categoryOptions}
|
||||
categoryGroups={categoryGroups}
|
||||
isUpdateMode={isUpdateMode}
|
||||
hideTransactionType={
|
||||
Boolean(isNewWithType) && !forceShowTransactionType
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SplitAndSettlementSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showSettledToggle={showSettledToggle}
|
||||
/>
|
||||
<div className="border-t border-border/40 my-3" />
|
||||
|
||||
{/* Pagador */}
|
||||
<PayerSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
@@ -525,56 +528,66 @@ export function TransactionDialog({
|
||||
totalAmount={totalAmount}
|
||||
/>
|
||||
|
||||
<PaymentMethodSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
accountOptions={accountOptions}
|
||||
cardOptions={cardOptions}
|
||||
isUpdateMode={isUpdateMode}
|
||||
disablePaymentMethod={disablePaymentMethod}
|
||||
disableCardSelect={disableCardSelect}
|
||||
/>
|
||||
<div className="border-t border-border/40 my-3" />
|
||||
|
||||
{showDueDate ? (
|
||||
<BoletoFieldsSection
|
||||
{/* Pagamento */}
|
||||
<div className="space-y-3">
|
||||
<PaymentMethodSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showPaymentDate={showPaymentDate}
|
||||
accountOptions={accountOptions}
|
||||
cardOptions={cardOptions}
|
||||
isUpdateMode={isUpdateMode}
|
||||
disablePaymentMethod={disablePaymentMethod}
|
||||
disableCardSelect={disableCardSelect}
|
||||
showSettledToggle={showSettledToggle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isUpdateMode ? (
|
||||
<>
|
||||
<NoteSection
|
||||
{showDueDate ? (
|
||||
<BoletoFieldsSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showPaymentDate={showPaymentDate}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium leading-none">
|
||||
Anexos
|
||||
</Label>
|
||||
<AttachmentSection
|
||||
transactionId={transaction?.id ?? ""}
|
||||
maxSizeMb={maxSizeMb}
|
||||
pendingDetachIds={pendingDetachIds}
|
||||
onPendingDetach={(id) =>
|
||||
setPendingDetachIds((prev) => [...prev, id])
|
||||
}
|
||||
onUndoPendingDetach={(id) =>
|
||||
setPendingDetachIds((prev) =>
|
||||
prev.filter((x) => x !== id),
|
||||
)
|
||||
}
|
||||
pendingUploadFiles={pendingUploadFiles}
|
||||
onPendingUpload={(file) =>
|
||||
setPendingUploadFiles((prev) => [...prev, file])
|
||||
}
|
||||
onCancelPendingUpload={(file) =>
|
||||
setPendingUploadFiles((prev) =>
|
||||
prev.filter((f) => f !== file),
|
||||
)
|
||||
}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Extras */}
|
||||
{isUpdateMode ? (
|
||||
<>
|
||||
<div className="border-t border-border/40 my-3" />
|
||||
<div className="space-y-3">
|
||||
<NoteSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium leading-none">
|
||||
Anexos
|
||||
</Label>
|
||||
<AttachmentSection
|
||||
transactionId={transaction?.id ?? ""}
|
||||
maxSizeMb={maxSizeMb}
|
||||
pendingDetachIds={pendingDetachIds}
|
||||
onPendingDetach={(id) =>
|
||||
setPendingDetachIds((prev) => [...prev, id])
|
||||
}
|
||||
onUndoPendingDetach={(id) =>
|
||||
setPendingDetachIds((prev) =>
|
||||
prev.filter((x) => x !== id),
|
||||
)
|
||||
}
|
||||
pendingUploadFiles={pendingUploadFiles}
|
||||
onPendingUpload={(file) =>
|
||||
setPendingUploadFiles((prev) => [...prev, file])
|
||||
}
|
||||
onCancelPendingUpload={(file) =>
|
||||
setPendingUploadFiles((prev) =>
|
||||
prev.filter((f) => f !== file),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -598,8 +611,11 @@ export function TransactionDialog({
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
<AttachmentFilePicker
|
||||
file={pendingFile}
|
||||
onChange={setPendingFile}
|
||||
files={pendingFiles}
|
||||
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
|
||||
onRemove={(file) =>
|
||||
setPendingFiles((prev) => prev.filter((f) => f !== file))
|
||||
}
|
||||
maxSizeMb={maxSizeMb}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
|
||||
@@ -127,6 +127,8 @@ export function TransactionsPage({
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
const [pendingEditData, setPendingEditData] = useState<{
|
||||
id: string;
|
||||
purchaseDate: string;
|
||||
period: string;
|
||||
name: string;
|
||||
categoryId: string | undefined;
|
||||
note: string;
|
||||
@@ -245,6 +247,8 @@ export function TransactionsPage({
|
||||
|
||||
const handleBulkEditRequest = (data: {
|
||||
id: string;
|
||||
purchaseDate: string;
|
||||
period: string;
|
||||
name: string;
|
||||
categoryId: string | undefined;
|
||||
note: string;
|
||||
@@ -278,6 +282,8 @@ export function TransactionsPage({
|
||||
const result = await updateTransactionBulkAction({
|
||||
id: pendingEditData.id,
|
||||
scope,
|
||||
purchaseDate: pendingEditData.purchaseDate,
|
||||
period: pendingEditData.period,
|
||||
name: pendingEditData.name,
|
||||
categoryId: pendingEditData.categoryId,
|
||||
note: pendingEditData.note,
|
||||
|
||||
Reference in New Issue
Block a user