mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
277
src/features/cards/components/card-dialog.tsx
Normal file
277
src/features/cards/components/card-dialog.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createCardAction, updateCardAction } from "@/features/cards/actions";
|
||||
import {
|
||||
LogoPickerDialog,
|
||||
LogoPickerTrigger,
|
||||
} from "@/shared/components/logo-picker";
|
||||
import { useLogoSelection } from "@/shared/components/logo-picker/use-logo-selection";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/shared/hooks/use-form-state";
|
||||
import {
|
||||
DEFAULT_CARD_BRANDS,
|
||||
DEFAULT_CARD_STATUS,
|
||||
} from "@/shared/lib/cards/constants";
|
||||
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
|
||||
import {
|
||||
formatLimitInput,
|
||||
normalizeDecimalInput,
|
||||
} from "@/shared/utils/currency";
|
||||
import { CardFormFields } from "./card-form-fields";
|
||||
import type { Card, CardFormValues } from "./types";
|
||||
|
||||
type AccountOption = {
|
||||
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;
|
||||
}
|
||||
|
||||
const buildInitialValues = ({
|
||||
card,
|
||||
logoOptions,
|
||||
accounts,
|
||||
}: {
|
||||
card?: Card;
|
||||
logoOptions: string[];
|
||||
accounts: AccountOption[];
|
||||
}): CardFormValues => {
|
||||
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 ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
export function CardDialog({
|
||||
mode,
|
||||
trigger,
|
||||
logoOptions,
|
||||
accounts,
|
||||
card,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CardDialogProps) {
|
||||
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,
|
||||
);
|
||||
|
||||
const initialState = useMemo(
|
||||
() => buildInitialValues({ card, logoOptions, accounts }),
|
||||
[card, logoOptions, accounts],
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, resetForm, updateField, updateFields } =
|
||||
useFormState<CardFormValues>(initialState);
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
resetForm(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, resetForm]);
|
||||
|
||||
// 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);
|
||||
// Delay closing to avoid race condition on mobile
|
||||
requestAnimationFrame(() => {
|
||||
setLogoDialogOpen(false);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
type CardCreatePayload = Parameters<typeof createCardAction>[0];
|
||||
|
||||
const handleSubmit = (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 (!formState.contaId) {
|
||||
const message = "Selecione a conta vinculada.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawLimit = normalizeDecimalInput(formState.limit);
|
||||
const payload: CardCreatePayload = {
|
||||
name: formState.name.trim(),
|
||||
brand: formState.brand,
|
||||
status: formState.status,
|
||||
closingDay: formState.closingDay,
|
||||
dueDay: formState.dueDay,
|
||||
limit: rawLimit ? Number(rawLimit) : null,
|
||||
note: formState.note.trim() || null,
|
||||
logo: formState.logo,
|
||||
contaId: formState.contaId,
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
resetForm(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
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 handleMainDialogOpenChange = (open: boolean) => {
|
||||
if (!open && logoDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setDialogOpen(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent
|
||||
className=""
|
||||
onPointerDownOutside={(e) => {
|
||||
if (logoDialogOpen) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (logoDialogOpen) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardFormFields
|
||||
values={formState}
|
||||
accountOptions={accounts}
|
||||
onChange={updateField}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
215
src/features/cards/components/card-form-fields.tsx
Normal file
215
src/features/cards/components/card-form-fields.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { Textarea } from "@/shared/components/ui/textarea";
|
||||
import {
|
||||
DAYS_IN_MONTH,
|
||||
DEFAULT_CARD_BRANDS,
|
||||
DEFAULT_CARD_STATUS,
|
||||
} from "@/shared/lib/cards/constants";
|
||||
import {
|
||||
AccountSelectContent,
|
||||
BrandSelectContent,
|
||||
StatusSelectContent,
|
||||
} from "./card-select-items";
|
||||
import type { CardFormValues } from "./types";
|
||||
|
||||
interface AccountOption {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
}
|
||||
|
||||
interface CardFormFieldsProps {
|
||||
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];
|
||||
};
|
||||
|
||||
export function CardFormFields({
|
||||
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,
|
||||
);
|
||||
|
||||
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-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-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 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>
|
||||
);
|
||||
}
|
||||
264
src/features/cards/components/card-item.tsx
Normal file
264
src/features/cards/components/card-item.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiChat3Line,
|
||||
RiDeleteBin5Line,
|
||||
RiFileList2Line,
|
||||
RiPencilLine,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
}: CardItemProps) {
|
||||
void _contaName;
|
||||
|
||||
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 usagePercent =
|
||||
limitTotal && limitTotal > 0 && used !== null
|
||||
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
|
||||
: 0;
|
||||
|
||||
const logoPath = resolveLogoSrc(logo);
|
||||
const brandAsset = resolveCardBrandAsset(brand);
|
||||
const isInactive = status?.toLowerCase() === "inativo";
|
||||
const metrics =
|
||||
limitTotal === null || used === null || available === null
|
||||
? null
|
||||
: [
|
||||
{ label: "Limite Total", value: limitTotal },
|
||||
{ label: "Em uso", value: used },
|
||||
{ label: "Disponível", value: available },
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: "editar",
|
||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||
onClick: onEdit,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "ver fatura",
|
||||
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
||||
onClick: onInvoice,
|
||||
className: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "remover",
|
||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||
onClick: onRemove,
|
||||
className: "text-destructive",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col p-6 w-full">
|
||||
<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-full",
|
||||
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>
|
||||
|
||||
{status ? (
|
||||
<span className="text-xs tracking-wide text-muted-foreground">
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{brandAsset ? (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Image
|
||||
src={brandAsset}
|
||||
alt={`Bandeira ${brand}`}
|
||||
width={36}
|
||||
height={36}
|
||||
className={cn(
|
||||
"h-5 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>
|
||||
|
||||
<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-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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
67
src/features/cards/components/card-select-items.tsx
Normal file
67
src/features/cards/components/card-select-items.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { RiBankLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
import { resolveCardBrandLogoSrc } from "@/shared/lib/cards/brand-assets";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
|
||||
type SelectItemContentProps = {
|
||||
label: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
export function BrandSelectContent({ label }: { label: string }) {
|
||||
const logoSrc = resolveCardBrandLogoSrc(label);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo ${label}`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full 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";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot
|
||||
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSelectContent({ label, logo }: SelectItemContentProps) {
|
||||
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-full"
|
||||
/>
|
||||
) : (
|
||||
<RiBankLine className="size-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
211
src/features/cards/components/cards-page.tsx
Normal file
211
src/features/cards/components/cards-page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteCardAction } from "@/features/cards/actions";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/shared/components/empty-state";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card as UiCard } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { CardDialog } from "./card-dialog";
|
||||
import { CardItem } from "./card-item";
|
||||
import type { Card as CreditCard } from "./types";
|
||||
|
||||
type AccountOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
interface CardsPageProps {
|
||||
cards: CreditCard[];
|
||||
archivedCards: CreditCard[];
|
||||
accounts: AccountOption[];
|
||||
logoOptions: string[];
|
||||
}
|
||||
|
||||
export function CardsPage({
|
||||
cards,
|
||||
archivedCards,
|
||||
accounts,
|
||||
logoOptions,
|
||||
}: CardsPageProps) {
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState("ativos");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedCard, setSelectedCard] = useState<CreditCard | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [cardToRemove, setCardToRemove] = useState<CreditCard | null>(null);
|
||||
|
||||
const orderedCards = useMemo(
|
||||
() =>
|
||||
[...cards].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
|
||||
),
|
||||
[cards],
|
||||
);
|
||||
const orderedArchivedCards = useMemo(
|
||||
() =>
|
||||
[...archivedCards].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
|
||||
),
|
||||
[archivedCards],
|
||||
);
|
||||
|
||||
const handleEdit = (card: CreditCard) => {
|
||||
setSelectedCard(card);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleEditOpenChange = (open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedCard(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRequest = (card: CreditCard) => {
|
||||
setCardToRemove(card);
|
||||
setRemoveOpen(true);
|
||||
};
|
||||
|
||||
const handleInvoice = (card: CreditCard) => {
|
||||
router.push(`/cards/${card.id}/invoice`);
|
||||
};
|
||||
|
||||
const handleRemoveOpenChange = (open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setCardToRemove(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = async () => {
|
||||
if (!cardToRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteCardAction({ id: cardToRemove.id });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
};
|
||||
|
||||
const removeTitle = cardToRemove
|
||||
? `Remover cartão "${cardToRemove.name}"?`
|
||||
: "Remover cartão?";
|
||||
|
||||
const renderCardList = (list: CreditCard[], isArchived: boolean) => {
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<UiCard className="flex w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiBankCard2Line className="size-6 text-primary" />}
|
||||
title={
|
||||
isArchived
|
||||
? "Nenhum cartão arquivado"
|
||||
: "Nenhum cartão cadastrado"
|
||||
}
|
||||
description={
|
||||
isArchived
|
||||
? "Os cartões arquivados aparecerão aqui."
|
||||
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
|
||||
}
|
||||
/>
|
||||
</UiCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex">
|
||||
<CardDialog
|
||||
mode="create"
|
||||
accounts={accounts}
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button className="w-full sm:w-auto">
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Novo cartão
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="ativos">Ativos</TabsTrigger>
|
||||
<TabsTrigger value="arquivados">Arquivados</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ativos" className="mt-4">
|
||||
{renderCardList(orderedCards, false)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="arquivados" className="mt-4">
|
||||
{renderCardList(orderedArchivedCards, true)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/features/cards/components/types.ts
Normal file
27
src/features/cards/components/types.ts
Normal file
@@ -0,0 +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;
|
||||
};
|
||||
|
||||
export type CardFormValues = {
|
||||
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