feat(cartoes): exigir limite e bloquear lançamentos acima do disponível

- campo limite passa a ser NOT NULL DEFAULT 0 no schema (migration 0029)
- validação Zod com requiredDecimalSchema garante valor positivo no formulário
- validateCardLimit() em transactions/actions/core.ts bloqueia criação e edição
  de despesas em cartão que ultrapassem o limite disponível, retornando mensagem
  com o valor exato restante
- tipos Card.limit e Card.limitAvailable deixam de ser nullable
- branch "sem limite registrado" removido de card-item.tsx

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-02 22:07:56 +00:00
parent 19b5aa00ee
commit 8389752172
11 changed files with 400 additions and 290 deletions

View File

@@ -154,13 +154,21 @@ export function CardDialog({
}
const rawLimit = normalizeDecimalInput(formState.limit);
const limitValue = rawLimit ? Number(rawLimit) : 0;
if (!Number.isFinite(limitValue) || limitValue <= 0) {
const message = "Informe um limite maior que zero.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload: CardCreatePayload = {
name: formState.name.trim(),
brand: formState.brand,
status: formState.status,
closingDay: formState.closingDay,
dueDay: formState.dueDay,
limit: rawLimit ? Number(rawLimit) : null,
limit: limitValue,
note: formState.note.trim() || null,
logo: formState.logo,
accountId: formState.accountId,

View File

@@ -112,12 +112,13 @@ export function CardFormFields({
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-limit">Limite (R$)</Label>
<Label htmlFor="card-limit">Limite</Label>
<CurrencyInput
id="card-limit"
value={values.limit}
onValueChange={(value) => onChange("limit", value)}
placeholder="R$ 0,00"
required
/>
</div>

View File

@@ -30,9 +30,9 @@ interface CardItemProps {
status: string;
closingDay: string;
dueDay: string;
limit: number | null;
limitInUse?: number | null;
limitAvailable?: number | null;
limit: number;
limitInUse?: number;
limitAvailable?: number;
accountName: string;
logo?: string | null;
note?: string | null;
@@ -61,30 +61,18 @@ export function CardItem({
}: CardItemProps) {
void _accountName;
const limitTotal = limit ?? null;
const used =
limitInUse ??
(limitTotal !== null && limitAvailable != null
? Math.max(limitTotal - limitAvailable, 0)
: limitTotal !== null
? 0
: null);
(limitAvailable !== undefined ? Math.max(limit - limitAvailable, 0) : 0);
const available =
limitAvailable ??
(limitTotal !== null && used !== null
? Math.max(limitTotal - used, 0)
: null);
const available = limitAvailable ?? Math.max(limit - used, 0);
const usagePercent =
limitTotal && limitTotal > 0 && used !== null
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
: 0;
limit > 0 ? Math.min(Math.max((used / limit) * 100, 0), 100) : 0;
const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(brand);
const isInactive = status?.toLowerCase() === "inativo";
const hasMetrics = limitTotal !== null && used !== null && available !== null;
return (
<Card className="flex flex-col p-6 w-full">
@@ -174,54 +162,45 @@ export function CardItem({
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4 px-0">
{hasMetrics &&
available !== null &&
used !== null &&
limitTotal !== null ? (
<>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Disponível</span>
<MoneyValues
amount={available}
className="text-xl font-semibold text-success"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite disponível
</span>
<MoneyValues
amount={available}
className="text-xl font-semibold text-success"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite total
</span>
<MoneyValues
amount={limitTotal}
className="text-sm font-semibold text-foreground"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Em uso</span>
<MoneyValues
amount={used}
className="text-sm font-semibold text-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Limite total</span>
<MoneyValues
amount={limit}
className="text-sm font-semibold text-foreground"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite utilizado
</span>
<MoneyValues
amount={used}
className="text-sm font-semibold text-destructive"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Progress
value={usagePercent}
className="h-2.5"
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
</span>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Ainda não limite registrado para este cartão.
</p>
)}
<div className="flex flex-col gap-2">
<Progress
value={usagePercent}
className="h-2.5"
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
</span>
</div>
</CardContent>
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">

View File

@@ -7,11 +7,11 @@ export type Card = {
dueDay: string;
note: string | null;
logo: string | null;
limit: number | null;
limit: number;
accountId: string;
accountName: string;
limitInUse: number;
limitAvailable: number | null;
limitAvailable: number;
};
export type CardFormValues = {