forked from baron/baron-sso
682 lines
25 KiB
TypeScript
682 lines
25 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
CheckCircle2,
|
|
ClipboardCheck,
|
|
Clock,
|
|
Plus,
|
|
ShieldAlert,
|
|
X,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { useAuth } from "react-oidc-context";
|
|
import { PageHeader } from "../../../../common/core/components/page";
|
|
import {
|
|
commonStickyTableHeaderClass,
|
|
commonTableShellClass,
|
|
commonTableViewportClass,
|
|
} from "../../../../common/ui/table";
|
|
import { Badge } from "../../components/ui/badge";
|
|
import { Button } from "../../components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "../../components/ui/card";
|
|
import { Input } from "../../components/ui/input";
|
|
import { Label } from "../../components/ui/label";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "../../components/ui/table";
|
|
import { Textarea } from "../../components/ui/textarea";
|
|
import {
|
|
approveDeveloperRequest,
|
|
cancelDeveloperRequestApproval,
|
|
fetchDeveloperRequests,
|
|
fetchMyTenants,
|
|
rejectDeveloperRequest,
|
|
requestDeveloperAccess,
|
|
} from "../../lib/devApi";
|
|
import { t } from "../../lib/i18n";
|
|
import { resolveProfileRole } from "../../lib/role";
|
|
import { fetchMe } from "../auth/authApi";
|
|
import {
|
|
type DeveloperAccessPage,
|
|
developerAccessPageOptions,
|
|
normalizeDeveloperAccessPageSelection,
|
|
normalizeDeveloperAccessPages,
|
|
} from "../developer-access/developerAccessPages";
|
|
|
|
export default function DeveloperRequestPage() {
|
|
const auth = useAuth();
|
|
const queryClient = useQueryClient();
|
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
|
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
|
const role = resolveProfileRole(userProfile);
|
|
const tenantId = userProfile?.tenant_id as string | undefined;
|
|
const companyCode = userProfile?.companyCode as string | undefined;
|
|
|
|
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
|
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
|
|
|
|
const { data: requests, isLoading } = useQuery({
|
|
queryKey: ["developer-requests"],
|
|
queryFn: () => fetchDeveloperRequests(),
|
|
enabled: !!auth.user?.access_token,
|
|
});
|
|
const { data: tenants } = useQuery({
|
|
queryKey: ["myTenants"],
|
|
queryFn: fetchMyTenants,
|
|
enabled: !!auth.user?.access_token,
|
|
});
|
|
const { data: me } = useQuery({
|
|
queryKey: ["userMe"],
|
|
queryFn: fetchMe,
|
|
enabled: hasAccessToken,
|
|
});
|
|
|
|
const currentTenant = tenants?.find(
|
|
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
|
|
);
|
|
const organizationName = currentTenant?.name || companyCode || "";
|
|
const profileName = me?.name || (userProfile?.name as string) || "";
|
|
const profileEmail = me?.email || (userProfile?.email as string) || "";
|
|
const profilePhone =
|
|
me?.phone ||
|
|
(userProfile?.phone as string | undefined) ||
|
|
(userProfile?.phone_number as string | undefined) ||
|
|
"";
|
|
const profileRole = me?.role?.trim() || role;
|
|
const isSuperAdmin = profileRole === "super_admin";
|
|
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
|
|
|
|
const approveMutation = useMutation({
|
|
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
|
|
approveDeveloperRequest(id, adminNotes),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
|
alert(t("msg.dev.request.approved", "승인되었습니다."));
|
|
},
|
|
});
|
|
|
|
const rejectMutation = useMutation({
|
|
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
|
|
rejectDeveloperRequest(id, adminNotes),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
|
alert(t("msg.dev.request.rejected", "반려되었습니다."));
|
|
},
|
|
});
|
|
|
|
const cancelApprovalMutation = useMutation({
|
|
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
|
|
cancelDeveloperRequestApproval(id, adminNotes),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
|
queryClient.invalidateQueries({ queryKey: ["developer-request"] });
|
|
alert(t("msg.dev.request.cancelled", "승인이 취소되었습니다."));
|
|
},
|
|
});
|
|
|
|
const handleApprove = (id: number) => {
|
|
approveMutation.mutate({ id, adminNotes: adminNotes[id] || "" });
|
|
};
|
|
|
|
const handleReject = (id: number) => {
|
|
if (!adminNotes[id]) {
|
|
alert(t("msg.dev.request.need_notes", "반려 사유를 입력해주세요."));
|
|
return;
|
|
}
|
|
rejectMutation.mutate({ id, adminNotes: adminNotes[id] });
|
|
};
|
|
|
|
const handleCancelApproval = (id: number) => {
|
|
if (!adminNotes[id]) {
|
|
alert(
|
|
t(
|
|
"msg.dev.request.need_cancel_notes",
|
|
"승인 취소 사유를 입력해주세요.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
cancelApprovalMutation.mutate({ id, adminNotes: adminNotes[id] });
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-8 text-center">
|
|
{t("ui.common.loading", "Loading...")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const hasActiveRequest = requests?.some((r) => r.status === "pending");
|
|
const approvedRequestCount =
|
|
requests?.filter((request) => request.status === "approved").length ?? 0;
|
|
const isActionPending =
|
|
approveMutation.isPending ||
|
|
rejectMutation.isPending ||
|
|
cancelApprovalMutation.isPending;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
icon={<ClipboardCheck size={20} />}
|
|
title={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
|
description={
|
|
isSuperAdmin
|
|
? t(
|
|
"msg.dev.request.admin_desc",
|
|
"사용자들의 개발자 권한 신청 내역을 관리합니다.",
|
|
)
|
|
: t(
|
|
"msg.dev.request.user_desc",
|
|
"내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.",
|
|
)
|
|
}
|
|
actions={
|
|
!isSuperAdmin && !hasActiveRequest ? (
|
|
<Button onClick={() => setIsRequestModalOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
|
|
</Button>
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
<Card className="glass-panel">
|
|
<CardHeader>
|
|
<CardTitle className="text-xl">
|
|
{t("ui.dev.request.list.title", "신청 내역")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.dev.request.list.approved_count",
|
|
"총 {{count}}명의 사용자가 승인되었습니다.",
|
|
{ count: approvedRequestCount },
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className={commonTableShellClass}>
|
|
<div className={commonTableViewportClass}>
|
|
<Table>
|
|
<TableHeader className={commonStickyTableHeaderClass}>
|
|
<TableRow>
|
|
{isSuperAdmin && (
|
|
<TableHead>
|
|
{t("ui.dev.request.table.user", "사용자")}
|
|
</TableHead>
|
|
)}
|
|
<TableHead>
|
|
{t("ui.dev.request.table.org", "소속")}
|
|
</TableHead>
|
|
<TableHead>
|
|
{t("ui.dev.request.table.reason", "신청 사유")}
|
|
</TableHead>
|
|
<TableHead>
|
|
{t("ui.dev.request.table.pages", "권한 페이지")}
|
|
</TableHead>
|
|
<TableHead>
|
|
{t("ui.dev.request.table.status", "상태")}
|
|
</TableHead>
|
|
<TableHead>
|
|
{t("ui.dev.request.table.date", "신청일")}
|
|
</TableHead>
|
|
{isSuperAdmin && (
|
|
<TableHead className="text-right">
|
|
{t("ui.dev.request.table.actions", "관리")}
|
|
</TableHead>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{!requests || requests.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={isSuperAdmin ? 7 : 5}
|
|
className="h-32 text-center text-muted-foreground"
|
|
>
|
|
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
requests.map((req) => (
|
|
<TableRow key={req.id}>
|
|
{isSuperAdmin && (
|
|
<TableCell className="font-medium">
|
|
<div>{req.name}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{req.email || req.userId}
|
|
</div>
|
|
{(req.phone || req.role) && (
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
{[req.phone, req.role]
|
|
.filter(Boolean)
|
|
.join(" / ")}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
)}
|
|
<TableCell>
|
|
{req.organization?.trim() ||
|
|
t("ui.common.na", "없음")}
|
|
</TableCell>
|
|
<TableCell className="max-w-md">
|
|
<div className="truncate" title={req.reason}>
|
|
{req.reason}
|
|
</div>
|
|
{req.adminNotes && (
|
|
<div className="mt-1 rounded bg-amber-50 p-1.5 text-xs text-amber-600 dark:bg-amber-900/20">
|
|
<strong>Admin:</strong> {req.adminNotes}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
{req.accessPages?.length ? (
|
|
normalizeDeveloperAccessPages(
|
|
req.accessPages,
|
|
).map((page) => (
|
|
<Badge key={page} variant="outline">
|
|
{developerAccessPageOptions.find(
|
|
(option) => option.value === page,
|
|
)?.label ?? page}
|
|
</Badge>
|
|
))
|
|
) : (
|
|
<Badge variant="secondary">
|
|
{t("ui.common.na", "없음")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<StatusBadge status={req.status} />
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{new Date(req.createdAt).toLocaleDateString()}
|
|
</TableCell>
|
|
{isSuperAdmin && (
|
|
<TableCell className="text-right">
|
|
{req.status === "pending" ? (
|
|
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
|
|
<Input
|
|
placeholder={t(
|
|
"ui.dev.request.admin_notes_placeholder",
|
|
"메모 입력 (선택)...",
|
|
)}
|
|
className="h-8 text-xs"
|
|
value={adminNotes[req.id] || ""}
|
|
onChange={(e) =>
|
|
setAdminNotes({
|
|
...adminNotes,
|
|
[req.id]: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="text-destructive hover:bg-destructive/10"
|
|
onClick={() => handleReject(req.id)}
|
|
disabled={isActionPending}
|
|
>
|
|
<XCircle className="mr-1 h-3 w-3" />
|
|
{t("ui.common.reject", "반려")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="bg-emerald-600 hover:bg-emerald-700"
|
|
onClick={() => handleApprove(req.id)}
|
|
disabled={isActionPending}
|
|
>
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
{t("ui.common.approve", "승인")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : req.status === "approved" ? (
|
|
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
|
|
<Input
|
|
placeholder={t(
|
|
"ui.dev.request.cancel_notes_placeholder",
|
|
"승인 취소 사유 입력...",
|
|
)}
|
|
className="h-8 text-xs"
|
|
value={adminNotes[req.id] || ""}
|
|
onChange={(e) =>
|
|
setAdminNotes({
|
|
...adminNotes,
|
|
[req.id]: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="text-destructive hover:bg-destructive/10"
|
|
onClick={() => handleCancelApproval(req.id)}
|
|
disabled={isActionPending}
|
|
>
|
|
<XCircle className="mr-1 h-3 w-3" />
|
|
{t(
|
|
"ui.dev.request.cancel_approval",
|
|
"승인 취소",
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs italic text-muted-foreground">
|
|
{req.status === "cancelled"
|
|
? t(
|
|
"ui.dev.request.status.cancelled",
|
|
"승인 취소됨",
|
|
)
|
|
: t("ui.common.rejected", "반려됨")}
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<RequestAccessModal
|
|
isOpen={isRequestModalOpen}
|
|
onClose={() => setIsRequestModalOpen(false)}
|
|
onSuccess={() => {
|
|
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
|
setIsRequestModalOpen(false);
|
|
}}
|
|
tenantId={tenantId || ""}
|
|
initialName={profileName}
|
|
initialOrg={organizationName}
|
|
initialEmail={profileEmail}
|
|
initialPhone={profilePhone}
|
|
initialRole={profileRoleLabel}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
switch (status) {
|
|
case "pending":
|
|
return (
|
|
<Badge variant="warning" className="gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{t("ui.dev.request.status.pending", "대기 중")}
|
|
</Badge>
|
|
);
|
|
case "approved":
|
|
return (
|
|
<Badge variant="success" className="gap-1">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
{t("ui.dev.request.status.approved", "승인됨")}
|
|
</Badge>
|
|
);
|
|
case "rejected":
|
|
return (
|
|
<Badge variant="muted" className="gap-1">
|
|
<ShieldAlert className="h-3 w-3" />
|
|
{t("ui.dev.request.status.rejected", "반려됨")}
|
|
</Badge>
|
|
);
|
|
case "cancelled":
|
|
return (
|
|
<Badge variant="muted" className="gap-1">
|
|
<XCircle className="h-3 w-3" />
|
|
{t("ui.dev.request.status.cancelled", "승인 취소됨")}
|
|
</Badge>
|
|
);
|
|
default:
|
|
return <Badge variant="muted">{status}</Badge>;
|
|
}
|
|
}
|
|
|
|
interface RequestAccessModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
tenantId: string;
|
|
initialName: string;
|
|
initialOrg: string;
|
|
initialEmail: string;
|
|
initialPhone: string;
|
|
initialRole: string;
|
|
}
|
|
|
|
function RequestAccessModal({
|
|
isOpen,
|
|
onClose,
|
|
onSuccess,
|
|
tenantId,
|
|
initialName,
|
|
initialOrg,
|
|
initialEmail,
|
|
initialPhone,
|
|
initialRole,
|
|
}: RequestAccessModalProps) {
|
|
const [name, setName] = useState(initialName);
|
|
const [organization, setOrganization] = useState(initialOrg);
|
|
const [reason, setReason] = useState("");
|
|
const [accessPages, setAccessPages] = useState<DeveloperAccessPage[]>([
|
|
"all",
|
|
]);
|
|
const organizationDisplay = organization.trim() || t("ui.common.na", "없음");
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
setName(initialName);
|
|
setOrganization(initialOrg);
|
|
setAccessPages(["all"]);
|
|
}, [initialName, initialOrg, isOpen]);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: requestDeveloperAccess,
|
|
onSuccess: () => {
|
|
onSuccess();
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
mutation.mutate({
|
|
name,
|
|
organization,
|
|
reason,
|
|
tenantId,
|
|
accessPages: normalizeDeveloperAccessPageSelection(accessPages),
|
|
});
|
|
};
|
|
|
|
const handleAccessPageToggle = (page: DeveloperAccessPage) => {
|
|
setAccessPages((current) => {
|
|
if (page === "all") {
|
|
return ["all"];
|
|
}
|
|
const withoutAll = current.filter((item) => item !== "all");
|
|
if (withoutAll.includes(page)) {
|
|
const next = withoutAll.filter((item) => item !== page);
|
|
return next.length > 0 ? next : ["all"];
|
|
}
|
|
return normalizeDeveloperAccessPageSelection([...withoutAll, page]);
|
|
});
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in duration-200">
|
|
<div className="relative w-full max-w-lg bg-card border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
|
<div className="flex items-center justify-between p-6 border-b border-border/40">
|
|
<div>
|
|
<h2 className="text-xl font-bold tracking-tight">
|
|
{t("ui.dev.request.modal.title", "개발자 등록 신청")}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{t(
|
|
"msg.dev.request.modal.desc",
|
|
"신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="rounded-full"
|
|
onClick={onClose}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="name">
|
|
{t("ui.dev.request.modal.name", "성함")}
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
value={name}
|
|
readOnly
|
|
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="org">
|
|
{t("ui.dev.request.modal.org", "소속")}
|
|
</Label>
|
|
<Input
|
|
id="org"
|
|
value={organizationDisplay}
|
|
readOnly
|
|
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="email">
|
|
{t("ui.dev.request.modal.email", "이메일")}
|
|
</Label>
|
|
<Input
|
|
id="email"
|
|
value={initialEmail}
|
|
readOnly
|
|
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="phone">
|
|
{t("ui.dev.request.modal.phone", "전화번호")}
|
|
</Label>
|
|
<Input
|
|
id="phone"
|
|
value={initialPhone}
|
|
readOnly
|
|
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="role">
|
|
{t("ui.dev.request.modal.role", "역할")}
|
|
</Label>
|
|
<Input
|
|
id="role"
|
|
value={initialRole}
|
|
readOnly
|
|
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-3">
|
|
<Label>
|
|
{t("ui.dev.request.modal.pages", "권한 페이지")}{" "}
|
|
<span className="text-destructive">*</span>
|
|
</Label>
|
|
<div className="grid gap-2 rounded-lg border border-border/60 bg-muted/20 p-3">
|
|
{developerAccessPageOptions.map((option) => {
|
|
const checked =
|
|
option.value === "all"
|
|
? accessPages.includes("all")
|
|
: accessPages.includes(option.value);
|
|
return (
|
|
<label
|
|
key={option.value}
|
|
className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-background/60"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={() => handleAccessPageToggle(option.value)}
|
|
/>
|
|
<span className="font-medium">{option.label}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.dev.request.modal.pages_hint",
|
|
"전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="reason">
|
|
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}
|
|
<span className="text-destructive">*</span>
|
|
</Label>
|
|
<Textarea
|
|
id="reason"
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder={t(
|
|
"ui.dev.request.modal.reason_placeholder",
|
|
"예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.",
|
|
)}
|
|
className="min-h-[120px] resize-none border-primary/50 bg-background focus-visible:ring-primary/40"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 pt-2">
|
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
{t("ui.common.cancel", "취소")}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={mutation.isPending}
|
|
className="px-8 font-bold"
|
|
>
|
|
{mutation.isPending
|
|
? t("ui.common.submitting", "제출 중...")
|
|
: t("ui.common.submit", "신청하기")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|