refactor: migrate from ESLint to Biome and extract SQL queries to data.ts
- Replace ESLint with Biome for linting and formatting - Configure Biome with tabs, double quotes, and organized imports - Move all SQL/Drizzle queries from page.tsx files to data.ts files - Create new data.ts files for: ajustes, dashboard, relatorios/categorias - Update existing data.ts files: extrato, fatura (add lancamentos queries) - Remove all drizzle-orm imports from page.tsx files - Update README.md with new tooling info Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,250 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createCardAction,
|
||||
updateCardAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createCardAction,
|
||||
updateCardAction,
|
||||
} from "@/app/(dashboard)/cartoes/actions";
|
||||
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/hooks/use-form-state";
|
||||
import { useLogoSelection } from "@/hooks/use-logo-selection";
|
||||
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
|
||||
import { formatLimitInput } from "@/lib/utils/currency";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DEFAULT_CARD_BRANDS, DEFAULT_CARD_STATUS } from "./constants";
|
||||
import { CardFormFields } from "./card-form-fields";
|
||||
import { DEFAULT_CARD_BRANDS, DEFAULT_CARD_STATUS } from "./constants";
|
||||
import type { Card, CardFormValues } from "./types";
|
||||
|
||||
type AccountOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
interface CardDialogProps {
|
||||
mode: "create" | "update";
|
||||
trigger?: React.ReactNode;
|
||||
logoOptions: string[];
|
||||
accounts: AccountOption[];
|
||||
card?: Card;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
mode: "create" | "update";
|
||||
trigger?: React.ReactNode;
|
||||
logoOptions: string[];
|
||||
accounts: AccountOption[];
|
||||
card?: Card;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = ({
|
||||
card,
|
||||
logoOptions,
|
||||
accounts,
|
||||
card,
|
||||
logoOptions,
|
||||
accounts,
|
||||
}: {
|
||||
card?: Card;
|
||||
logoOptions: string[];
|
||||
accounts: AccountOption[];
|
||||
card?: Card;
|
||||
logoOptions: string[];
|
||||
accounts: AccountOption[];
|
||||
}): CardFormValues => {
|
||||
const fallbackLogo = logoOptions[0] ?? "";
|
||||
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
|
||||
const derivedName = deriveNameFromLogo(selectedLogo);
|
||||
const fallbackLogo = logoOptions[0] ?? "";
|
||||
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
|
||||
const derivedName = deriveNameFromLogo(selectedLogo);
|
||||
|
||||
return {
|
||||
name: card?.name ?? derivedName,
|
||||
brand: card?.brand ?? DEFAULT_CARD_BRANDS[0],
|
||||
status: card?.status ?? DEFAULT_CARD_STATUS[0],
|
||||
closingDay: card?.closingDay ?? "01",
|
||||
dueDay: card?.dueDay ?? "10",
|
||||
limit: formatLimitInput(card?.limit ?? null),
|
||||
note: card?.note ?? "",
|
||||
logo: selectedLogo,
|
||||
contaId: card?.contaId ?? accounts[0]?.id ?? "",
|
||||
};
|
||||
return {
|
||||
name: card?.name ?? derivedName,
|
||||
brand: card?.brand ?? DEFAULT_CARD_BRANDS[0],
|
||||
status: card?.status ?? DEFAULT_CARD_STATUS[0],
|
||||
closingDay: card?.closingDay ?? "01",
|
||||
dueDay: card?.dueDay ?? "10",
|
||||
limit: formatLimitInput(card?.limit ?? null),
|
||||
note: card?.note ?? "",
|
||||
logo: selectedLogo,
|
||||
contaId: card?.contaId ?? accounts[0]?.id ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
export function CardDialog({
|
||||
mode,
|
||||
trigger,
|
||||
logoOptions,
|
||||
accounts,
|
||||
card,
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
trigger,
|
||||
logoOptions,
|
||||
accounts,
|
||||
card,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CardDialogProps) {
|
||||
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Use controlled state hook for dialog open state
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange
|
||||
);
|
||||
// Use controlled state hook for dialog open state
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
const initialState = useMemo(
|
||||
() => buildInitialValues({ card, logoOptions, accounts }),
|
||||
[card, logoOptions, accounts]
|
||||
);
|
||||
const initialState = useMemo(
|
||||
() => buildInitialValues({ card, logoOptions, accounts }),
|
||||
[card, logoOptions, accounts],
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, updateField, updateFields, setFormState } =
|
||||
useFormState<CardFormValues>(initialState);
|
||||
// Use form state hook for form management
|
||||
const { formState, updateField, updateFields, setFormState } =
|
||||
useFormState<CardFormValues>(initialState);
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setFormState(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, setFormState]);
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setFormState(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, setFormState]);
|
||||
|
||||
// Close logo dialog when main dialog closes
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setErrorMessage(null);
|
||||
setLogoDialogOpen(false);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
// Close logo dialog when main dialog closes
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setErrorMessage(null);
|
||||
setLogoDialogOpen(false);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
// Use logo selection hook
|
||||
const handleLogoSelection = useLogoSelection({
|
||||
mode,
|
||||
currentLogo: formState.logo,
|
||||
currentName: formState.name,
|
||||
onUpdate: (updates) => {
|
||||
updateFields(updates);
|
||||
setLogoDialogOpen(false);
|
||||
},
|
||||
});
|
||||
// Use logo selection hook
|
||||
const handleLogoSelection = useLogoSelection({
|
||||
mode,
|
||||
currentLogo: formState.logo,
|
||||
currentName: formState.name,
|
||||
onUpdate: (updates) => {
|
||||
updateFields(updates);
|
||||
setLogoDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
const handleSubmit = useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (mode === "update" && !card?.id) {
|
||||
const message = "Cartão inválido.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (mode === "update" && !card?.id) {
|
||||
const message = "Cartão inválido.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formState.contaId) {
|
||||
const message = "Selecione a conta vinculada.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (!formState.contaId) {
|
||||
const message = "Selecione a conta vinculada.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { ...formState };
|
||||
const payload = { ...formState };
|
||||
|
||||
if (!payload.logo) {
|
||||
const message = "Selecione um logo.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (!payload.logo) {
|
||||
const message = "Selecione um logo.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createCardAction(payload)
|
||||
: await updateCardAction({
|
||||
id: card?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createCardAction(payload)
|
||||
: await updateCardAction({
|
||||
id: card?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
setFormState(initialState);
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
setFormState(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
},
|
||||
[card?.id, formState, initialState, mode, setDialogOpen, setFormState]
|
||||
);
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
},
|
||||
[card?.id, formState, initialState, mode, setDialogOpen, setFormState],
|
||||
);
|
||||
|
||||
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
|
||||
const description =
|
||||
mode === "create"
|
||||
? "Inclua um novo cartão de crédito para acompanhar seus gastos."
|
||||
: "Atualize as informações do cartão selecionado.";
|
||||
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
|
||||
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
|
||||
const description =
|
||||
mode === "create"
|
||||
? "Inclua um novo cartão de crédito para acompanhar seus gastos."
|
||||
: "Atualize as informações do cartão selecionado.";
|
||||
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<LogoPickerTrigger
|
||||
selectedLogo={formState.logo}
|
||||
disabled={logoOptions.length === 0}
|
||||
helperText="Clique para escolher o logo do cartão"
|
||||
onOpen={() => {
|
||||
if (logoOptions.length > 0) {
|
||||
setLogoDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<LogoPickerTrigger
|
||||
selectedLogo={formState.logo}
|
||||
disabled={logoOptions.length === 0}
|
||||
helperText="Clique para escolher o logo do cartão"
|
||||
onOpen={() => {
|
||||
if (logoOptions.length > 0) {
|
||||
setLogoDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardFormFields
|
||||
values={formState}
|
||||
accountOptions={accounts}
|
||||
onChange={updateField}
|
||||
/>
|
||||
<CardFormFields
|
||||
values={formState}
|
||||
accountOptions={accounts}
|
||||
onChange={updateField}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogFooter className="gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<LogoPickerDialog
|
||||
open={logoDialogOpen}
|
||||
logos={logoOptions}
|
||||
value={formState.logo}
|
||||
onOpenChange={setLogoDialogOpen}
|
||||
onSelect={handleLogoSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<LogoPickerDialog
|
||||
open={logoDialogOpen}
|
||||
logos={logoOptions}
|
||||
value={formState.logo}
|
||||
onOpenChange={setLogoDialogOpen}
|
||||
onSelect={handleLogoSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,211 +4,212 @@ import { CurrencyInput } from "@/components/ui/currency-input";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
DAYS_IN_MONTH,
|
||||
DEFAULT_CARD_BRANDS,
|
||||
DEFAULT_CARD_STATUS,
|
||||
AccountSelectContent,
|
||||
BrandSelectContent,
|
||||
StatusSelectContent,
|
||||
} from "./card-select-items";
|
||||
import {
|
||||
DAYS_IN_MONTH,
|
||||
DEFAULT_CARD_BRANDS,
|
||||
DEFAULT_CARD_STATUS,
|
||||
} from "./constants";
|
||||
import type { CardFormValues } from "./types";
|
||||
import {
|
||||
BrandSelectContent,
|
||||
StatusSelectContent,
|
||||
AccountSelectContent,
|
||||
} from "./card-select-items";
|
||||
|
||||
interface AccountOption {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
}
|
||||
|
||||
interface CardFormFieldsProps {
|
||||
values: CardFormValues;
|
||||
accountOptions: AccountOption[];
|
||||
onChange: (field: keyof CardFormValues, value: string) => void;
|
||||
values: CardFormValues;
|
||||
accountOptions: AccountOption[];
|
||||
onChange: (field: keyof CardFormValues, value: string) => void;
|
||||
}
|
||||
|
||||
const ensureOption = (options: string[], value: string) => {
|
||||
if (!value) {
|
||||
return options;
|
||||
}
|
||||
return options.includes(value) ? options : [value, ...options];
|
||||
if (!value) {
|
||||
return options;
|
||||
}
|
||||
return options.includes(value) ? options : [value, ...options];
|
||||
};
|
||||
|
||||
export function CardFormFields({
|
||||
values,
|
||||
accountOptions,
|
||||
onChange,
|
||||
values,
|
||||
accountOptions,
|
||||
onChange,
|
||||
}: CardFormFieldsProps) {
|
||||
const brands = ensureOption(
|
||||
DEFAULT_CARD_BRANDS as unknown as string[],
|
||||
values.brand
|
||||
);
|
||||
const statuses = ensureOption(
|
||||
DEFAULT_CARD_STATUS as unknown as string[],
|
||||
values.status
|
||||
);
|
||||
const brands = ensureOption(
|
||||
DEFAULT_CARD_BRANDS as unknown as string[],
|
||||
values.brand,
|
||||
);
|
||||
const statuses = ensureOption(
|
||||
DEFAULT_CARD_STATUS as unknown as string[],
|
||||
values.status,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-name">Nome do cartão</Label>
|
||||
<Input
|
||||
id="card-name"
|
||||
value={values.name}
|
||||
onChange={(event) => onChange("name", event.target.value)}
|
||||
placeholder="Ex.: Nubank Platinum"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-name">Nome do cartão</Label>
|
||||
<Input
|
||||
id="card-name"
|
||||
value={values.name}
|
||||
onChange={(event) => onChange("name", event.target.value)}
|
||||
placeholder="Ex.: Nubank Platinum"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-brand">Bandeira</Label>
|
||||
<Select
|
||||
value={values.brand}
|
||||
onValueChange={(value) => onChange("brand", value)}
|
||||
>
|
||||
<SelectTrigger id="card-brand" className="w-full">
|
||||
<SelectValue placeholder="Selecione a bandeira">
|
||||
{values.brand && <BrandSelectContent label={values.brand} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{brands.map((brand) => (
|
||||
<SelectItem key={brand} value={brand}>
|
||||
<BrandSelectContent label={brand} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-brand">Bandeira</Label>
|
||||
<Select
|
||||
value={values.brand}
|
||||
onValueChange={(value) => onChange("brand", value)}
|
||||
>
|
||||
<SelectTrigger id="card-brand" className="w-full">
|
||||
<SelectValue placeholder="Selecione a bandeira">
|
||||
{values.brand && <BrandSelectContent label={values.brand} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{brands.map((brand) => (
|
||||
<SelectItem key={brand} value={brand}>
|
||||
<BrandSelectContent label={brand} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-status">Status</Label>
|
||||
<Select
|
||||
value={values.status}
|
||||
onValueChange={(value) => onChange("status", value)}
|
||||
>
|
||||
<SelectTrigger id="card-status" className="w-full">
|
||||
<SelectValue placeholder="Selecione o status">
|
||||
{values.status && <StatusSelectContent label={values.status} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<StatusSelectContent label={status} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-status">Status</Label>
|
||||
<Select
|
||||
value={values.status}
|
||||
onValueChange={(value) => onChange("status", value)}
|
||||
>
|
||||
<SelectTrigger id="card-status" className="w-full">
|
||||
<SelectValue placeholder="Selecione o status">
|
||||
{values.status && <StatusSelectContent label={values.status} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<StatusSelectContent label={status} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-limit">Limite (R$)</Label>
|
||||
<CurrencyInput
|
||||
id="card-limit"
|
||||
value={values.limit}
|
||||
onValueChange={(value) => onChange("limit", value)}
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-limit">Limite (R$)</Label>
|
||||
<CurrencyInput
|
||||
id="card-limit"
|
||||
value={values.limit}
|
||||
onValueChange={(value) => onChange("limit", value)}
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-closing-day">Dia de fechamento</Label>
|
||||
<Select
|
||||
value={values.closingDay}
|
||||
onValueChange={(value) => onChange("closingDay", value)}
|
||||
>
|
||||
<SelectTrigger id="card-closing-day" className="w-full">
|
||||
<SelectValue placeholder="Dia de fechamento" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS_IN_MONTH.map((day) => (
|
||||
<SelectItem key={day} value={day}>
|
||||
Dia {day}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-closing-day">Dia de fechamento</Label>
|
||||
<Select
|
||||
value={values.closingDay}
|
||||
onValueChange={(value) => onChange("closingDay", value)}
|
||||
>
|
||||
<SelectTrigger id="card-closing-day" className="w-full">
|
||||
<SelectValue placeholder="Dia de fechamento" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS_IN_MONTH.map((day) => (
|
||||
<SelectItem key={day} value={day}>
|
||||
Dia {day}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-due-day">Dia de vencimento</Label>
|
||||
<Select
|
||||
value={values.dueDay}
|
||||
onValueChange={(value) => onChange("dueDay", value)}
|
||||
>
|
||||
<SelectTrigger id="card-due-day" className="w-full">
|
||||
<SelectValue placeholder="Dia de vencimento" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS_IN_MONTH.map((day) => (
|
||||
<SelectItem key={day} value={day}>
|
||||
Dia {day}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="card-due-day">Dia de vencimento</Label>
|
||||
<Select
|
||||
value={values.dueDay}
|
||||
onValueChange={(value) => onChange("dueDay", value)}
|
||||
>
|
||||
<SelectTrigger id="card-due-day" className="w-full">
|
||||
<SelectValue placeholder="Dia de vencimento" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS_IN_MONTH.map((day) => (
|
||||
<SelectItem key={day} value={day}>
|
||||
Dia {day}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="card-account">Conta vinculada</Label>
|
||||
<Select
|
||||
value={values.contaId}
|
||||
onValueChange={(value) => onChange("contaId", value)}
|
||||
disabled={accountOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger id="card-account" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
accountOptions.length === 0
|
||||
? "Cadastre uma conta primeiro"
|
||||
: "Selecione a conta"
|
||||
}
|
||||
>
|
||||
{values.contaId && (() => {
|
||||
const selectedAccount = accountOptions.find(
|
||||
(acc) => acc.id === values.contaId
|
||||
);
|
||||
return selectedAccount ? (
|
||||
<AccountSelectContent
|
||||
label={selectedAccount.name}
|
||||
logo={selectedAccount.logo}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountOptions.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<AccountSelectContent
|
||||
label={account.name}
|
||||
logo={account.logo}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="card-account">Conta vinculada</Label>
|
||||
<Select
|
||||
value={values.contaId}
|
||||
onValueChange={(value) => onChange("contaId", value)}
|
||||
disabled={accountOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger id="card-account" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
accountOptions.length === 0
|
||||
? "Cadastre uma conta primeiro"
|
||||
: "Selecione a conta"
|
||||
}
|
||||
>
|
||||
{values.contaId &&
|
||||
(() => {
|
||||
const selectedAccount = accountOptions.find(
|
||||
(acc) => acc.id === values.contaId,
|
||||
);
|
||||
return selectedAccount ? (
|
||||
<AccountSelectContent
|
||||
label={selectedAccount.name}
|
||||
logo={selectedAccount.logo}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountOptions.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<AccountSelectContent
|
||||
label={account.name}
|
||||
logo={account.logo}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="card-note">Anotação</Label>
|
||||
<Textarea
|
||||
id="card-note"
|
||||
value={values.note}
|
||||
onChange={(event) => onChange("note", event.target.value)}
|
||||
placeholder="Observações sobre este cartão"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label htmlFor="card-note">Anotação</Label>
|
||||
<Textarea
|
||||
id="card-note"
|
||||
value={values.note}
|
||||
onChange={(event) => onChange("note", event.target.value)}
|
||||
placeholder="Observações sobre este cartão"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,307 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import {
|
||||
RiChat3Line,
|
||||
RiDeleteBin5Line,
|
||||
RiEyeLine,
|
||||
RiPencilLine,
|
||||
RiChat3Line,
|
||||
RiDeleteBin5Line,
|
||||
RiEyeLine,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import MoneyValues from "../money-values";
|
||||
|
||||
interface CardItemProps {
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
limit: number | null;
|
||||
limitInUse?: number | null;
|
||||
limitAvailable?: number | null;
|
||||
contaName: string;
|
||||
logo?: string | null;
|
||||
note?: string | null;
|
||||
onEdit?: () => void;
|
||||
onInvoice?: () => void;
|
||||
onRemove?: () => void;
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
limit: number | null;
|
||||
limitInUse?: number | null;
|
||||
limitAvailable?: number | null;
|
||||
contaName: string;
|
||||
logo?: string | null;
|
||||
note?: string | null;
|
||||
onEdit?: () => void;
|
||||
onInvoice?: () => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const BRAND_ASSETS: Record<string, string> = {
|
||||
visa: "/bandeiras/visa.svg",
|
||||
mastercard: "/bandeiras/mastercard.svg",
|
||||
amex: "/bandeiras/amex.svg",
|
||||
american: "/bandeiras/amex.svg",
|
||||
elo: "/bandeiras/elo.svg",
|
||||
hipercard: "/bandeiras/hipercard.svg",
|
||||
hiper: "/bandeiras/hipercard.svg",
|
||||
visa: "/bandeiras/visa.svg",
|
||||
mastercard: "/bandeiras/mastercard.svg",
|
||||
amex: "/bandeiras/amex.svg",
|
||||
american: "/bandeiras/amex.svg",
|
||||
elo: "/bandeiras/elo.svg",
|
||||
hipercard: "/bandeiras/hipercard.svg",
|
||||
hiper: "/bandeiras/hipercard.svg",
|
||||
};
|
||||
|
||||
const resolveBrandAsset = (brand: string) => {
|
||||
const normalized = brand.trim().toLowerCase();
|
||||
const normalized = brand.trim().toLowerCase();
|
||||
|
||||
const match = (
|
||||
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
|
||||
).find((entry) => normalized.includes(entry));
|
||||
const match = (
|
||||
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
|
||||
).find((entry) => normalized.includes(entry));
|
||||
|
||||
return match ? BRAND_ASSETS[match] : null;
|
||||
return match ? BRAND_ASSETS[match] : null;
|
||||
};
|
||||
|
||||
const formatDay = (value: string) => value.padStart(2, "0");
|
||||
|
||||
export function CardItem({
|
||||
name,
|
||||
brand,
|
||||
status,
|
||||
closingDay,
|
||||
dueDay,
|
||||
limit,
|
||||
limitInUse,
|
||||
limitAvailable,
|
||||
contaName: _contaName,
|
||||
logo,
|
||||
note,
|
||||
onEdit,
|
||||
onInvoice,
|
||||
onRemove,
|
||||
name,
|
||||
brand,
|
||||
status,
|
||||
closingDay,
|
||||
dueDay,
|
||||
limit,
|
||||
limitInUse,
|
||||
limitAvailable,
|
||||
contaName: _contaName,
|
||||
logo,
|
||||
note,
|
||||
onEdit,
|
||||
onInvoice,
|
||||
onRemove,
|
||||
}: CardItemProps) {
|
||||
void _contaName;
|
||||
void _contaName;
|
||||
|
||||
const limitTotal = limit ?? null;
|
||||
const used =
|
||||
limitInUse ??
|
||||
(limitTotal !== null && limitAvailable !== null
|
||||
? Math.max(limitTotal - limitAvailable, 0)
|
||||
: limitTotal !== null
|
||||
? 0
|
||||
: null);
|
||||
const limitTotal = limit ?? null;
|
||||
const used =
|
||||
limitInUse ??
|
||||
(limitTotal !== null && limitAvailable !== null
|
||||
? Math.max(limitTotal - limitAvailable, 0)
|
||||
: limitTotal !== null
|
||||
? 0
|
||||
: null);
|
||||
|
||||
const available =
|
||||
limitAvailable ??
|
||||
(limitTotal !== null && used !== null
|
||||
? Math.max(limitTotal - used, 0)
|
||||
: null);
|
||||
const available =
|
||||
limitAvailable ??
|
||||
(limitTotal !== null && used !== null
|
||||
? Math.max(limitTotal - used, 0)
|
||||
: null);
|
||||
|
||||
const usagePercent =
|
||||
limitTotal && limitTotal > 0 && used !== null
|
||||
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
|
||||
: 0;
|
||||
const usagePercent =
|
||||
limitTotal && limitTotal > 0 && used !== null
|
||||
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
|
||||
: 0;
|
||||
|
||||
const logoPath = useMemo(() => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
const logoPath = useMemo(() => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
logo.startsWith("http://") ||
|
||||
logo.startsWith("https://") ||
|
||||
logo.startsWith("data:")
|
||||
) {
|
||||
return logo;
|
||||
}
|
||||
if (
|
||||
logo.startsWith("http://") ||
|
||||
logo.startsWith("https://") ||
|
||||
logo.startsWith("data:")
|
||||
) {
|
||||
return logo;
|
||||
}
|
||||
|
||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
||||
}, [logo]);
|
||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
||||
}, [logo]);
|
||||
|
||||
const brandAsset = useMemo(() => resolveBrandAsset(brand), [brand]);
|
||||
const brandAsset = useMemo(() => resolveBrandAsset(brand), [brand]);
|
||||
|
||||
const isInactive = useMemo(
|
||||
() => status?.toLowerCase() === "inativo",
|
||||
[status]
|
||||
);
|
||||
const isInactive = useMemo(
|
||||
() => status?.toLowerCase() === "inativo",
|
||||
[status],
|
||||
);
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
if (limitTotal === null) return null;
|
||||
const metrics = useMemo(() => {
|
||||
if (limitTotal === null) return null;
|
||||
|
||||
return [
|
||||
{ label: "Limite Total", value: limitTotal },
|
||||
{ label: "Em uso", value: used },
|
||||
{ label: "Disponível", value: available },
|
||||
];
|
||||
}, [available, limitTotal, used]);
|
||||
return [
|
||||
{ label: "Limite Total", value: limitTotal },
|
||||
{ label: "Em uso", value: used },
|
||||
{ label: "Disponível", value: available },
|
||||
];
|
||||
}, [available, limitTotal, used]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "editar",
|
||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||
onClick: onEdit,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "ver fatura",
|
||||
icon: <RiEyeLine className="size-4" aria-hidden />,
|
||||
onClick: onInvoice,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "remover",
|
||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||
onClick: onRemove,
|
||||
className: "text-destructive",
|
||||
},
|
||||
],
|
||||
[onEdit, onInvoice, onRemove]
|
||||
);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "editar",
|
||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||
onClick: onEdit,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "ver fatura",
|
||||
icon: <RiEyeLine className="size-4" aria-hidden />,
|
||||
onClick: onInvoice,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "remover",
|
||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||
onClick: onRemove,
|
||||
className: "text-destructive",
|
||||
},
|
||||
],
|
||||
[onEdit, onInvoice, onRemove],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="flex p-6 h-[300px] w-[440px]">
|
||||
<CardHeader className="space-y-2 px-0 pb-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{logoPath ? (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`Logo do cartão ${name}`}
|
||||
width={42}
|
||||
height={42}
|
||||
className={cn(
|
||||
"rounded-lg",
|
||||
isInactive && "grayscale opacity-40"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
return (
|
||||
<Card className="flex p-6 h-[300px] w-[440px]">
|
||||
<CardHeader className="space-y-2 px-0 pb-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{logoPath ? (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`Logo do cartão ${name}`}
|
||||
width={42}
|
||||
height={42}
|
||||
className={cn(
|
||||
"rounded-lg",
|
||||
isInactive && "grayscale opacity-40",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
{name}
|
||||
</h3>
|
||||
{note ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
aria-label="Observações do cartão"
|
||||
>
|
||||
<RiChat3Line className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{note}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
{name}
|
||||
</h3>
|
||||
{note ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
aria-label="Observações do cartão"
|
||||
>
|
||||
<RiChat3Line className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{note}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{status ? (
|
||||
<span className="text-xs tracking-wide text-muted-foreground">
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{status ? (
|
||||
<span className="text-xs tracking-wide text-muted-foreground">
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{brandAsset ? (
|
||||
<div className="flex items-center justify-center rounded-lg py-1">
|
||||
<Image
|
||||
src={brandAsset}
|
||||
alt={`Bandeira ${brand}`}
|
||||
width={42}
|
||||
height={42}
|
||||
className={cn(
|
||||
"h-6 w-auto rounded",
|
||||
isInactive && "grayscale opacity-40"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{brand}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{brandAsset ? (
|
||||
<div className="flex items-center justify-center rounded-lg py-1">
|
||||
<Image
|
||||
src={brandAsset}
|
||||
alt={`Bandeira ${brand}`}
|
||||
width={42}
|
||||
height={42}
|
||||
className={cn(
|
||||
"h-6 w-auto rounded",
|
||||
isInactive && "grayscale opacity-40",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{brand}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-y border-dashed py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
||||
<span>
|
||||
Fecha dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDay(closingDay)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Vence dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDay(dueDay)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="flex items-center justify-between border-y border-dashed py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
||||
<span>
|
||||
Fecha dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDay(closingDay)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Vence dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDay(dueDay)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col gap-5 px-0">
|
||||
{metrics ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={metrics[0].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[0].label}
|
||||
</span>
|
||||
</div>
|
||||
<CardContent className="flex flex-1 flex-col gap-5 px-0">
|
||||
{metrics ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={metrics[0].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[0].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
<MoneyValues amount={metrics[1].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[1].label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
<MoneyValues amount={metrics[1].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[1].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={metrics[2].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[2].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={metrics[2].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{metrics[2].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={usagePercent} className="h-3" />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda não há limite registrado para este cartão.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<Progress value={usagePercent} className="h-3" />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda não há limite registrado para este cartão.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm">
|
||||
{actions.map(({ label, icon, onClick, className }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm">
|
||||
{actions.map(({ label, icon, onClick, className }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import DotIcon from "@/components/dot-icon";
|
||||
import Image from "next/image";
|
||||
import { RiBankLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import DotIcon from "@/components/dot-icon";
|
||||
|
||||
type SelectItemContentProps = {
|
||||
label: string;
|
||||
logo?: string | null;
|
||||
label: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
const resolveLogoSrc = (logo: string | null) => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
|
||||
return `/logos/${fileName}`;
|
||||
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
|
||||
return `/logos/${fileName}`;
|
||||
};
|
||||
|
||||
const getBrandLogo = (brand: string): string | null => {
|
||||
const brandMap: Record<string, string> = {
|
||||
Visa: "visa.png",
|
||||
Mastercard: "mastercard.png",
|
||||
Elo: "elo.png",
|
||||
};
|
||||
const brandMap: Record<string, string> = {
|
||||
Visa: "visa.png",
|
||||
Mastercard: "mastercard.png",
|
||||
Elo: "elo.png",
|
||||
};
|
||||
|
||||
return brandMap[brand] ?? null;
|
||||
return brandMap[brand] ?? null;
|
||||
};
|
||||
|
||||
export function BrandSelectContent({ label }: { label: string }) {
|
||||
const brandLogo = getBrandLogo(label);
|
||||
const logoSrc = brandLogo ? `/logos/${brandLogo}` : null;
|
||||
const brandLogo = getBrandLogo(label);
|
||||
const logoSrc = brandLogo ? `/logos/${brandLogo}` : null;
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo ${label}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<RiBankLine className="size-5 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo ${label}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<RiBankLine className="size-5 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusSelectContent({ label }: { label: string }) {
|
||||
const isActive = label === "Ativo";
|
||||
const isActive = label === "Ativo";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<DotIcon
|
||||
bg_dot={
|
||||
isActive
|
||||
? "bg-emerald-600 dark:bg-emerald-300"
|
||||
: "bg-slate-400 dark:bg-slate-500"
|
||||
}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<DotIcon
|
||||
bg_dot={
|
||||
isActive
|
||||
? "bg-emerald-600 dark:bg-emerald-300"
|
||||
: "bg-slate-400 dark:bg-slate-500"
|
||||
}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSelectContent({ label, logo }: SelectItemContentProps) {
|
||||
const logoSrc = resolveLogoSrc(logo);
|
||||
const logoSrc = resolveLogoSrc(logo);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo de ${label}`}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded"
|
||||
/>
|
||||
) : (
|
||||
<RiBankLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo de ${label}`}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded"
|
||||
/>
|
||||
) : (
|
||||
<RiBankLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,189 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteCardAction } from "@/app/(dashboard)/cartoes/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CardDialog } from "./card-dialog";
|
||||
import { CardItem } from "./card-item";
|
||||
|
||||
type AccountOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface CardsPageProps {
|
||||
cards: Card[];
|
||||
accounts: AccountOption[];
|
||||
logoOptions: string[];
|
||||
isInativos?: boolean;
|
||||
cards: Card[];
|
||||
accounts: AccountOption[];
|
||||
logoOptions: string[];
|
||||
isInativos?: boolean;
|
||||
}
|
||||
|
||||
export function CardsPage({
|
||||
cards,
|
||||
accounts,
|
||||
logoOptions,
|
||||
isInativos = false,
|
||||
cards,
|
||||
accounts,
|
||||
logoOptions,
|
||||
isInativos = false,
|
||||
}: CardsPageProps) {
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [cardToRemove, setCardToRemove] = useState<Card | null>(null);
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [cardToRemove, setCardToRemove] = useState<Card | null>(null);
|
||||
|
||||
const hasCards = cards.length > 0;
|
||||
const hasCards = cards.length > 0;
|
||||
|
||||
const orderedCards = useMemo(
|
||||
() =>
|
||||
[...cards].sort((a, b) => {
|
||||
// Coloca inativos no final
|
||||
const aIsInactive = a.status?.toLowerCase() === "inativo";
|
||||
const bIsInactive = b.status?.toLowerCase() === "inativo";
|
||||
const orderedCards = useMemo(
|
||||
() =>
|
||||
[...cards].sort((a, b) => {
|
||||
// Coloca inativos no final
|
||||
const aIsInactive = a.status?.toLowerCase() === "inativo";
|
||||
const bIsInactive = b.status?.toLowerCase() === "inativo";
|
||||
|
||||
if (aIsInactive && !bIsInactive) return 1;
|
||||
if (!aIsInactive && bIsInactive) return -1;
|
||||
if (aIsInactive && !bIsInactive) return 1;
|
||||
if (!aIsInactive && bIsInactive) return -1;
|
||||
|
||||
// Mesma ordem alfabética dentro de cada grupo
|
||||
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
|
||||
}),
|
||||
[cards]
|
||||
);
|
||||
// Mesma ordem alfabética dentro de cada grupo
|
||||
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
|
||||
}),
|
||||
[cards],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback((card: Card) => {
|
||||
setSelectedCard(card);
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
const handleEdit = useCallback((card: Card) => {
|
||||
setSelectedCard(card);
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedCard(null);
|
||||
}
|
||||
}, []);
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedCard(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveRequest = useCallback((card: Card) => {
|
||||
setCardToRemove(card);
|
||||
setRemoveOpen(true);
|
||||
}, []);
|
||||
const handleRemoveRequest = useCallback((card: Card) => {
|
||||
setCardToRemove(card);
|
||||
setRemoveOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleInvoice = useCallback(
|
||||
(card: Card) => {
|
||||
router.push(`/cartoes/${card.id}/fatura`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
const handleInvoice = useCallback(
|
||||
(card: Card) => {
|
||||
router.push(`/cartoes/${card.id}/fatura`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setCardToRemove(null);
|
||||
}
|
||||
}, []);
|
||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setCardToRemove(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveConfirm = useCallback(async () => {
|
||||
if (!cardToRemove) {
|
||||
return;
|
||||
}
|
||||
const handleRemoveConfirm = useCallback(async () => {
|
||||
if (!cardToRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteCardAction({ id: cardToRemove.id });
|
||||
const result = await deleteCardAction({ id: cardToRemove.id });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}, [cardToRemove]);
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}, [cardToRemove]);
|
||||
|
||||
const removeTitle = cardToRemove
|
||||
? `Remover cartão "${cardToRemove.name}"?`
|
||||
: "Remover cartão?";
|
||||
const removeTitle = cardToRemove
|
||||
? `Remover cartão "${cardToRemove.name}"?`
|
||||
: "Remover cartão?";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{!isInativos && (
|
||||
<div className="flex justify-start">
|
||||
<CardDialog
|
||||
mode="create"
|
||||
accounts={accounts}
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Novo cartão
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{!isInativos && (
|
||||
<div className="flex justify-start">
|
||||
<CardDialog
|
||||
mode="create"
|
||||
accounts={accounts}
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Novo cartão
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCards ? (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{orderedCards.map((card) => (
|
||||
<CardItem
|
||||
key={card.id}
|
||||
name={card.name}
|
||||
brand={card.brand}
|
||||
status={card.status}
|
||||
closingDay={card.closingDay}
|
||||
dueDay={card.dueDay}
|
||||
limit={card.limit}
|
||||
limitInUse={card.limitInUse ?? null}
|
||||
limitAvailable={card.limitAvailable ?? card.limit ?? null}
|
||||
contaName={card.contaName}
|
||||
logo={card.logo}
|
||||
note={card.note}
|
||||
onEdit={() => handleEdit(card)}
|
||||
onInvoice={() => handleInvoice(card)}
|
||||
onRemove={() => handleRemoveRequest(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="flex w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiBankCard2Line className="size-6 text-primary" />}
|
||||
title={
|
||||
isInativos
|
||||
? "Nenhum cartão inativo"
|
||||
: "Nenhum cartão cadastrado"
|
||||
}
|
||||
description={
|
||||
isInativos
|
||||
? "Os cartões inativos aparecerão aqui."
|
||||
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{hasCards ? (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{orderedCards.map((card) => (
|
||||
<CardItem
|
||||
key={card.id}
|
||||
name={card.name}
|
||||
brand={card.brand}
|
||||
status={card.status}
|
||||
closingDay={card.closingDay}
|
||||
dueDay={card.dueDay}
|
||||
limit={card.limit}
|
||||
limitInUse={card.limitInUse ?? null}
|
||||
limitAvailable={card.limitAvailable ?? card.limit ?? null}
|
||||
contaName={card.contaName}
|
||||
logo={card.logo}
|
||||
note={card.note}
|
||||
onEdit={() => handleEdit(card)}
|
||||
onInvoice={() => handleInvoice(card)}
|
||||
onRemove={() => handleRemoveRequest(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="flex w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiBankCard2Line className="size-6 text-primary" />}
|
||||
title={
|
||||
isInativos
|
||||
? "Nenhum cartão inativo"
|
||||
: "Nenhum cartão cadastrado"
|
||||
}
|
||||
description={
|
||||
isInativos
|
||||
? "Os cartões inativos aparecerão aqui."
|
||||
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardDialog
|
||||
mode="update"
|
||||
accounts={accounts}
|
||||
logoOptions={logoOptions}
|
||||
card={selectedCard ?? undefined}
|
||||
open={editOpen && !!selectedCard}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
<CardDialog
|
||||
mode="update"
|
||||
accounts={accounts}
|
||||
logoOptions={logoOptions}
|
||||
card={selectedCard ?? undefined}
|
||||
open={editOpen && !!selectedCard}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={removeOpen && !!cardToRemove}
|
||||
onOpenChange={handleRemoveOpenChange}
|
||||
title={removeTitle}
|
||||
description="Ao remover este cartão, os registros relacionados a ele serão excluídos permanentemente."
|
||||
confirmLabel="Remover cartão"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleRemoveConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<ConfirmActionDialog
|
||||
open={removeOpen && !!cardToRemove}
|
||||
onOpenChange={handleRemoveOpenChange}
|
||||
title={removeTitle}
|
||||
description="Ao remover este cartão, os registros relacionados a ele serão excluídos permanentemente."
|
||||
confirmLabel="Remover cartão"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleRemoveConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ export const DEFAULT_CARD_BRANDS = ["Visa", "Mastercard", "Elo"] as const;
|
||||
export const DEFAULT_CARD_STATUS = ["Ativo", "Inativo"] as const;
|
||||
|
||||
export const DAYS_IN_MONTH = Array.from({ length: 31 }, (_, index) =>
|
||||
String(index + 1).padStart(2, "0")
|
||||
String(index + 1).padStart(2, "0"),
|
||||
);
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
export type Card = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
limit: number | null;
|
||||
contaId: string;
|
||||
contaName: string;
|
||||
limitInUse?: number | null;
|
||||
limitAvailable?: number | null;
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
limit: number | null;
|
||||
contaId: string;
|
||||
contaName: string;
|
||||
limitInUse?: number | null;
|
||||
limitAvailable?: number | null;
|
||||
};
|
||||
|
||||
export type CardFormValues = {
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
limit: string;
|
||||
note: string;
|
||||
logo: string;
|
||||
contaId: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
closingDay: string;
|
||||
dueDay: string;
|
||||
limit: string;
|
||||
note: string;
|
||||
logo: string;
|
||||
contaId: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user