mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-11 03:31:47 +00:00
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:
@@ -13,10 +13,10 @@ import { db } from "@/shared/lib/db";
|
||||
import {
|
||||
dayOfMonthSchema,
|
||||
noteSchema,
|
||||
optionalDecimalSchema,
|
||||
requiredDecimalSchema,
|
||||
uuidSchema,
|
||||
} from "@/shared/lib/schemas/common";
|
||||
import { formatDecimalForDb } from "@/shared/utils/currency";
|
||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||
import { normalizeFilePath } from "@/shared/utils/string";
|
||||
|
||||
const cardBaseSchema = z.object({
|
||||
@@ -35,7 +35,7 @@ const cardBaseSchema = z.object({
|
||||
closingDay: dayOfMonthSchema,
|
||||
dueDay: dayOfMonthSchema,
|
||||
note: noteSchema,
|
||||
limit: optionalDecimalSchema,
|
||||
limit: requiredDecimalSchema("limite"),
|
||||
logo: z
|
||||
.string({ message: "Selecione um logo." })
|
||||
.trim()
|
||||
@@ -87,7 +87,7 @@ export async function createCardAction(
|
||||
closingDay: data.closingDay,
|
||||
dueDay: data.dueDay,
|
||||
note: data.note ?? null,
|
||||
limit: formatDecimalForDb(data.limit),
|
||||
limit: formatDecimalForDbRequired(data.limit),
|
||||
logo: logoFile,
|
||||
accountId: data.accountId,
|
||||
userId: user.id,
|
||||
@@ -121,7 +121,7 @@ export async function updateCardAction(
|
||||
closingDay: data.closingDay,
|
||||
dueDay: data.dueDay,
|
||||
note: data.note ?? null,
|
||||
limit: formatDecimalForDb(data.limit),
|
||||
limit: formatDecimalForDbRequired(data.limit),
|
||||
logo: logoFile,
|
||||
accountId: data.accountId,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 há 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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -12,9 +12,9 @@ type CardData = {
|
||||
dueDay: string;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
limit: number | null;
|
||||
limit: number;
|
||||
limitInUse: number;
|
||||
limitAvailable: number | null;
|
||||
limitAvailable: number;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
};
|
||||
@@ -96,15 +96,12 @@ async function fetchCardsByStatus(
|
||||
dueDay: card.dueDay,
|
||||
note: card.note,
|
||||
logo: card.logo,
|
||||
limit: card.limit ? Number(card.limit) : null,
|
||||
limit: Number(card.limit),
|
||||
limitInUse: (() => {
|
||||
const total = usageMap.get(card.id) ?? 0;
|
||||
return total < 0 ? Math.abs(total) : 0;
|
||||
})(),
|
||||
limitAvailable: (() => {
|
||||
if (!card.limit) {
|
||||
return null;
|
||||
}
|
||||
const total = usageMap.get(card.id) ?? 0;
|
||||
const inUse = total < 0 ? Math.abs(total) : 0;
|
||||
return Math.max(Number(card.limit) - inUse, 0);
|
||||
|
||||
Reference in New Issue
Block a user