Background agora usa position absolute para cobrir desde o topo do card, eliminando a faixa branca acima. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
614 lines
21 KiB
TypeScript
614 lines
21 KiB
TypeScript
"use client"
|
|
|
|
import { FormEvent, useMemo, useRef, useState } from "react"
|
|
import Link from "next/link"
|
|
import { useRouter } from "next/navigation"
|
|
import dynamic from "next/dynamic"
|
|
import { toast } from "sonner"
|
|
import {
|
|
Share2,
|
|
ShieldCheck,
|
|
UserPlus,
|
|
Users2,
|
|
Layers3,
|
|
MessageSquareText,
|
|
BellRing,
|
|
ClipboardList,
|
|
LogOut,
|
|
Mail,
|
|
Key,
|
|
User,
|
|
Shield,
|
|
Clock,
|
|
Camera,
|
|
Loader2,
|
|
Trash2,
|
|
} from "lucide-react"
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import { useAuth, signOut } from "@/lib/auth-client"
|
|
|
|
const ShaderBackground = dynamic(
|
|
() => import("@/components/background-paper-shaders-wrapper"),
|
|
{ ssr: false }
|
|
)
|
|
|
|
import type { LucideIcon } from "lucide-react"
|
|
|
|
type RoleRequirement = "admin" | "staff"
|
|
|
|
type SettingsAction = {
|
|
title: string
|
|
description: string
|
|
href: string
|
|
cta: string
|
|
requiredRole?: RoleRequirement
|
|
icon: LucideIcon
|
|
}
|
|
|
|
const ROLE_LABELS: Record<string, string> = {
|
|
admin: "Administrador",
|
|
manager: "Gestor",
|
|
agent: "Agente",
|
|
collaborator: "Colaborador",
|
|
customer: "Cliente",
|
|
}
|
|
|
|
const ROLE_COLORS: Record<string, string> = {
|
|
admin: "bg-neutral-900 text-white border-neutral-900",
|
|
manager: "bg-neutral-800 text-white border-neutral-800",
|
|
agent: "bg-neutral-700 text-white border-neutral-700",
|
|
collaborator: "bg-neutral-600 text-white border-neutral-600",
|
|
customer: "bg-neutral-500 text-white border-neutral-500",
|
|
}
|
|
|
|
const SETTINGS_ACTIONS: SettingsAction[] = [
|
|
{
|
|
title: "Campos personalizados",
|
|
description: "Configure campos extras para enriquecer os tickets.",
|
|
href: "/admin/custom-fields",
|
|
cta: "Configurar",
|
|
requiredRole: "admin",
|
|
icon: ClipboardList,
|
|
},
|
|
{
|
|
title: "Times e equipes",
|
|
description: "Gerencie times e atribua permissões por equipe.",
|
|
href: "/admin/teams",
|
|
cta: "Gerenciar",
|
|
requiredRole: "admin",
|
|
icon: Users2,
|
|
},
|
|
{
|
|
title: "Filas de atendimento",
|
|
description: "Configure filas e regras de distribuição.",
|
|
href: "/admin/channels",
|
|
cta: "Configurar",
|
|
requiredRole: "admin",
|
|
icon: Share2,
|
|
},
|
|
{
|
|
title: "Categorias",
|
|
description: "Gerencie categorias e formulários de tickets.",
|
|
href: "/admin/fields",
|
|
cta: "Gerenciar",
|
|
requiredRole: "admin",
|
|
icon: Layers3,
|
|
},
|
|
{
|
|
title: "Usuários e convites",
|
|
description: "Convide novos usuários e gerencie acessos.",
|
|
href: "/admin/users",
|
|
cta: "Gerenciar",
|
|
requiredRole: "admin",
|
|
icon: UserPlus,
|
|
},
|
|
{
|
|
title: "Templates de comentários",
|
|
description: "Mensagens rápidas para os atendimentos.",
|
|
href: "/settings/templates",
|
|
cta: "Gerenciar",
|
|
requiredRole: "staff",
|
|
icon: MessageSquareText,
|
|
},
|
|
{
|
|
title: "Notificações",
|
|
description: "Configure quais e-mails deseja receber.",
|
|
href: "/settings/notifications",
|
|
cta: "Configurar",
|
|
requiredRole: "staff",
|
|
icon: BellRing,
|
|
},
|
|
{
|
|
title: "Políticas de SLA",
|
|
description: "Acompanhe e configure níveis de serviço.",
|
|
href: "/admin/slas",
|
|
cta: "Gerenciar",
|
|
requiredRole: "admin",
|
|
icon: ShieldCheck,
|
|
},
|
|
]
|
|
|
|
export function SettingsContent() {
|
|
const { session, isAdmin, isStaff } = useAuth()
|
|
const [isSigningOut, setIsSigningOut] = useState(false)
|
|
const router = useRouter()
|
|
|
|
const normalizedRole = session?.user.role?.toLowerCase() ?? "agent"
|
|
const roleLabel = ROLE_LABELS[normalizedRole] ?? "Agente"
|
|
const roleColorClass = ROLE_COLORS[normalizedRole] ?? ROLE_COLORS.agent
|
|
|
|
const sessionExpiry = useMemo(() => {
|
|
const expiresAt = session?.session?.expiresAt
|
|
if (!expiresAt) return null
|
|
return new Intl.DateTimeFormat("pt-BR", {
|
|
dateStyle: "long",
|
|
timeStyle: "short",
|
|
}).format(new Date(expiresAt))
|
|
}, [session?.session?.expiresAt])
|
|
|
|
async function handleSignOut() {
|
|
if (isSigningOut) return
|
|
setIsSigningOut(true)
|
|
try {
|
|
await signOut()
|
|
toast.success("Sessão encerrada")
|
|
router.replace("/login")
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível encerrar a sessão.")
|
|
} finally {
|
|
setIsSigningOut(false)
|
|
}
|
|
}
|
|
|
|
function canAccess(requiredRole?: RoleRequirement) {
|
|
if (!requiredRole) return true
|
|
if (requiredRole === "admin") return isAdmin
|
|
if (requiredRole === "staff") return isStaff
|
|
return false
|
|
}
|
|
|
|
const initials = useMemo(() => {
|
|
const name = session?.user.name ?? ""
|
|
const parts = name.split(" ").filter(Boolean)
|
|
if (parts.length >= 2) {
|
|
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
|
|
}
|
|
return name.slice(0, 2).toUpperCase() || "U"
|
|
}, [session?.user.name])
|
|
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 pb-12 lg:px-6">
|
|
{/* Perfil do usuario */}
|
|
<section className="space-y-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-neutral-900">Meu perfil</h2>
|
|
<p className="text-sm text-neutral-500">
|
|
Suas informações pessoais e configurações de conta.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
|
<ProfileEditCard
|
|
name={session?.user.name ?? ""}
|
|
email={session?.user.email ?? ""}
|
|
avatarUrl={session?.user.avatarUrl ?? null}
|
|
initials={initials}
|
|
/>
|
|
|
|
<div className="space-y-4">
|
|
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
|
|
<Shield className="size-4 text-neutral-500" />
|
|
Acesso
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-neutral-600">Papel</span>
|
|
<Badge className={`rounded-full border px-3 py-1 text-xs font-medium ${roleColorClass}`}>
|
|
{roleLabel}
|
|
</Badge>
|
|
</div>
|
|
<Separator />
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-neutral-600">Sessão</span>
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-2 animate-pulse rounded-full bg-green-500" />
|
|
<span className="text-xs text-neutral-500">Ativa</span>
|
|
</div>
|
|
</div>
|
|
{sessionExpiry && (
|
|
<p className="text-xs text-neutral-400">
|
|
Expira em {sessionExpiry}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
<CardFooter className="pt-0">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full gap-2 text-red-600 hover:bg-red-50 hover:text-red-700"
|
|
onClick={handleSignOut}
|
|
disabled={isSigningOut}
|
|
>
|
|
<LogOut className="size-4" />
|
|
{isSigningOut ? "Encerrando..." : "Encerrar sessão"}
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
|
|
<Clock className="size-4 text-neutral-500" />
|
|
Segurança
|
|
</CardTitle>
|
|
<CardDescription className="text-xs text-neutral-500">
|
|
Gerencie a segurança da sua conta
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<Button variant="outline" size="sm" className="w-full justify-start gap-2" asChild>
|
|
<Link href="/recuperar">
|
|
<Key className="size-4" />
|
|
Alterar senha
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Configuracoes do workspace */}
|
|
<section className="space-y-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-neutral-900">Configurações</h2>
|
|
<p className="text-sm text-neutral-500">
|
|
Gerencie times, filas, categorias e outras configurações do sistema.
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{SETTINGS_ACTIONS.map((action) => {
|
|
const allowed = canAccess(action.requiredRole)
|
|
const Icon = action.icon
|
|
return (
|
|
<Card
|
|
key={action.title}
|
|
className={`rounded-2xl border border-border/60 bg-white shadow-sm transition-all ${
|
|
allowed ? "hover:border-primary/30 hover:shadow-md" : "opacity-60"
|
|
}`}
|
|
>
|
|
<CardHeader className="flex flex-row items-start gap-3 pb-2">
|
|
<div className="flex size-9 items-center justify-center rounded-xl bg-neutral-100 text-neutral-600">
|
|
<Icon className="size-4" />
|
|
</div>
|
|
<div className="flex-1 space-y-0.5">
|
|
<CardTitle className="text-sm font-semibold text-neutral-900">{action.title}</CardTitle>
|
|
<CardDescription className="text-xs text-neutral-500 leading-relaxed">
|
|
{action.description}
|
|
</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
<CardFooter className="pt-2">
|
|
{allowed ? (
|
|
<Button asChild size="sm" variant="outline" className="w-full">
|
|
<Link href={action.href}>{action.cta}</Link>
|
|
</Button>
|
|
) : (
|
|
<Button size="sm" variant="outline" className="w-full" disabled>
|
|
Acesso restrito
|
|
</Button>
|
|
)}
|
|
</CardFooter>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ProfileEditCard({
|
|
name,
|
|
email,
|
|
avatarUrl,
|
|
initials,
|
|
}: {
|
|
name: string
|
|
email: string
|
|
avatarUrl: string | null
|
|
initials: string
|
|
}) {
|
|
const [editName, setEditName] = useState(name)
|
|
const [editEmail, setEditEmail] = useState(email)
|
|
const [newPassword, setNewPassword] = useState("")
|
|
const [confirmPassword, setConfirmPassword] = useState("")
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [localAvatarUrl, setLocalAvatarUrl] = useState(avatarUrl)
|
|
const [pendingAvatarFile, setPendingAvatarFile] = useState<File | null>(null)
|
|
const [pendingAvatarPreview, setPendingAvatarPreview] = useState<string | null>(null)
|
|
const [pendingRemoveAvatar, setPendingRemoveAvatar] = useState(false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// URL de exibição: preview pendente > URL atual (se não marcado para remoção)
|
|
const displayAvatarUrl = pendingAvatarPreview ?? (pendingRemoveAvatar ? null : localAvatarUrl)
|
|
|
|
function handleAvatarSelect(event: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0]
|
|
if (!file) return
|
|
|
|
// Valida tamanho (5MB)
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
toast.error("Arquivo muito grande. Máximo 5MB.")
|
|
return
|
|
}
|
|
|
|
// Valida tipo
|
|
if (!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(file.type)) {
|
|
toast.error("Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF.")
|
|
return
|
|
}
|
|
|
|
// Cria preview local
|
|
const previewUrl = URL.createObjectURL(file)
|
|
setPendingAvatarFile(file)
|
|
setPendingAvatarPreview(previewUrl)
|
|
setPendingRemoveAvatar(false)
|
|
|
|
// Limpa o input para permitir reselecionar o mesmo arquivo
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = ""
|
|
}
|
|
}
|
|
|
|
function handleRemoveAvatarClick() {
|
|
// Limpa preview pendente se houver
|
|
if (pendingAvatarPreview) {
|
|
URL.revokeObjectURL(pendingAvatarPreview)
|
|
}
|
|
setPendingAvatarFile(null)
|
|
setPendingAvatarPreview(null)
|
|
// Marca para remoção apenas se já tiver avatar salvo
|
|
if (localAvatarUrl) {
|
|
setPendingRemoveAvatar(true)
|
|
}
|
|
}
|
|
|
|
const hasChanges = useMemo(() => {
|
|
const nameChanged = editName.trim() !== name
|
|
const emailChanged = editEmail.trim().toLowerCase() !== email.toLowerCase()
|
|
const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0
|
|
const avatarChanged = pendingAvatarFile !== null || pendingRemoveAvatar
|
|
return nameChanged || emailChanged || passwordChanged || avatarChanged
|
|
}, [editName, name, editEmail, email, newPassword, confirmPassword, pendingAvatarFile, pendingRemoveAvatar])
|
|
|
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault()
|
|
if (!hasChanges) {
|
|
toast.info("Nenhuma alteração a salvar.")
|
|
return
|
|
}
|
|
|
|
const payload: Record<string, unknown> = {}
|
|
const trimmedEmail = editEmail.trim()
|
|
if (trimmedEmail && trimmedEmail.toLowerCase() !== email.toLowerCase()) {
|
|
payload.email = trimmedEmail
|
|
}
|
|
if (newPassword || confirmPassword) {
|
|
if (newPassword !== confirmPassword) {
|
|
toast.error("As senhas não conferem")
|
|
return
|
|
}
|
|
if (newPassword.length < 8) {
|
|
toast.error("A senha deve ter pelo menos 8 caracteres")
|
|
return
|
|
}
|
|
payload.password = { newPassword, confirmPassword }
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
// Processa avatar primeiro
|
|
if (pendingAvatarFile) {
|
|
const formData = new FormData()
|
|
formData.append("file", pendingAvatarFile)
|
|
|
|
const avatarRes = await fetch("/api/profile/avatar", {
|
|
method: "POST",
|
|
body: formData,
|
|
})
|
|
|
|
if (!avatarRes.ok) {
|
|
const data = await avatarRes.json().catch(() => ({ error: "Erro ao fazer upload" }))
|
|
throw new Error(data.error || "Erro ao fazer upload da foto")
|
|
}
|
|
|
|
const avatarData = await avatarRes.json()
|
|
setLocalAvatarUrl(avatarData.avatarUrl)
|
|
|
|
// Limpa preview
|
|
if (pendingAvatarPreview) {
|
|
URL.revokeObjectURL(pendingAvatarPreview)
|
|
}
|
|
setPendingAvatarFile(null)
|
|
setPendingAvatarPreview(null)
|
|
} else if (pendingRemoveAvatar) {
|
|
const avatarRes = await fetch("/api/profile/avatar", {
|
|
method: "DELETE",
|
|
})
|
|
|
|
if (!avatarRes.ok) {
|
|
const data = await avatarRes.json().catch(() => ({ error: "Erro ao remover foto" }))
|
|
throw new Error(data.error || "Erro ao remover foto")
|
|
}
|
|
|
|
setLocalAvatarUrl(null)
|
|
setPendingRemoveAvatar(false)
|
|
}
|
|
|
|
// Processa outros dados do perfil se houver
|
|
if (Object.keys(payload).length > 0) {
|
|
const res = await fetch("/api/portal/profile", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({ error: "Falha ao atualizar perfil" }))
|
|
const message = typeof data.error === "string" ? data.error : "Falha ao atualizar perfil"
|
|
toast.error(message)
|
|
return
|
|
}
|
|
const data = (await res.json().catch(() => null)) as { email?: string } | null
|
|
if (data?.email) {
|
|
setEditEmail(data.email)
|
|
}
|
|
}
|
|
|
|
setNewPassword("")
|
|
setConfirmPassword("")
|
|
toast.success("Dados atualizados com sucesso!")
|
|
} catch (error) {
|
|
console.error("Falha ao atualizar perfil", error)
|
|
toast.error(error instanceof Error ? error.message : "Não foi possível atualizar o perfil.")
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card className="relative rounded-2xl border border-border/60 bg-white shadow-sm overflow-hidden">
|
|
{/* Background absoluto no topo do card */}
|
|
<div className="absolute inset-x-0 top-0 h-20">
|
|
<ShaderBackground className="h-full w-full" />
|
|
</div>
|
|
{/* Conteudo com padding-top para ficar abaixo do background */}
|
|
<CardHeader className="relative pb-4 pt-10">
|
|
<div className="flex items-end gap-4">
|
|
<div className="relative group">
|
|
<Avatar className="size-20 border-4 border-white shadow-lg ring-2 ring-neutral-200">
|
|
<AvatarImage src={displayAvatarUrl ?? undefined} alt={name} />
|
|
<AvatarFallback className="bg-neutral-200 text-xl font-semibold text-neutral-700">
|
|
{initials}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
onChange={handleAvatarSelect}
|
|
className="hidden"
|
|
/>
|
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
|
{isSubmitting ? (
|
|
<Loader2 className="size-5 text-white animate-spin" />
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="flex items-center justify-center size-8 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
title="Alterar foto"
|
|
>
|
|
<Camera className="size-4 text-white" />
|
|
</button>
|
|
{(displayAvatarUrl || pendingAvatarFile) && !pendingRemoveAvatar && (
|
|
<button
|
|
type="button"
|
|
className="flex items-center justify-center size-8 rounded-full bg-red-500/80 hover:bg-red-500 transition-colors"
|
|
onClick={handleRemoveAvatarClick}
|
|
title="Remover foto"
|
|
>
|
|
<Trash2 className="size-4 text-white" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 pb-1">
|
|
<CardTitle className="text-lg font-semibold text-neutral-900">{name || "Usuário"}</CardTitle>
|
|
<CardDescription className="text-sm text-neutral-500">{email}</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="profile-name" className="flex items-center gap-2 text-sm font-medium text-neutral-700">
|
|
<User className="size-4 text-neutral-400" />
|
|
Nome
|
|
</Label>
|
|
<Input
|
|
id="profile-name"
|
|
type="text"
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
placeholder="Seu nome"
|
|
disabled
|
|
className="bg-neutral-50"
|
|
/>
|
|
<p className="text-xs text-neutral-400">Editável apenas por administradores</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="profile-email" className="flex items-center gap-2 text-sm font-medium text-neutral-700">
|
|
<Mail className="size-4 text-neutral-400" />
|
|
E-mail
|
|
</Label>
|
|
<Input
|
|
id="profile-email"
|
|
type="email"
|
|
value={editEmail}
|
|
onChange={(e) => setEditEmail(e.target.value)}
|
|
placeholder="seu@email.com"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Separator />
|
|
<div className="space-y-3">
|
|
<Label className="flex items-center gap-2 text-sm font-medium text-neutral-700">
|
|
<Key className="size-4 text-neutral-400" />
|
|
Alterar senha
|
|
</Label>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Input
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
placeholder="Nova senha"
|
|
autoComplete="new-password"
|
|
/>
|
|
<Input
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
placeholder="Confirmar senha"
|
|
autoComplete="new-password"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-neutral-400">
|
|
Mínimo de 8 caracteres. Deixe em branco se não quiser alterar.
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end pt-2">
|
|
<Button type="submit" disabled={isSubmitting || !hasChanges}>
|
|
{isSubmitting ? "Salvando..." : "Salvar alterações"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|