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 | 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>({}); 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 (
{t("ui.common.loading", "Loading...")}
); } 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 (
} title={t("ui.dev.nav.developer_request", "개발자 권한 신청")} description={ isSuperAdmin ? t( "msg.dev.request.admin_desc", "사용자들의 개발자 권한 신청 내역을 관리합니다.", ) : t( "msg.dev.request.user_desc", "내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.", ) } actions={ !isSuperAdmin && !hasActiveRequest ? ( ) : null } /> {t("ui.dev.request.list.title", "신청 내역")} {t( "msg.dev.request.list.approved_count", "총 {{count}}명의 사용자가 승인되었습니다.", { count: approvedRequestCount }, )}
{isSuperAdmin && ( {t("ui.dev.request.table.user", "사용자")} )} {t("ui.dev.request.table.org", "소속")} {t("ui.dev.request.table.reason", "신청 사유")} {t("ui.dev.request.table.pages", "권한 페이지")} {t("ui.dev.request.table.status", "상태")} {t("ui.dev.request.table.date", "신청일")} {isSuperAdmin && ( {t("ui.dev.request.table.actions", "관리")} )} {!requests || requests.length === 0 ? ( {t("msg.dev.request.empty", "신청 내역이 없습니다.")} ) : ( requests.map((req) => ( {isSuperAdmin && (
{req.name}
{req.email || req.userId}
{(req.phone || req.role) && (
{[req.phone, req.role] .filter(Boolean) .join(" / ")}
)}
)} {req.organization?.trim() || t("ui.common.na", "없음")}
{req.reason}
{req.adminNotes && (
Admin: {req.adminNotes}
)}
{req.accessPages?.length ? ( normalizeDeveloperAccessPages( req.accessPages, ).map((page) => ( {developerAccessPageOptions.find( (option) => option.value === page, )?.label ?? page} )) ) : ( {t("ui.common.na", "없음")} )}
{new Date(req.createdAt).toLocaleDateString()} {isSuperAdmin && ( {req.status === "pending" ? (
setAdminNotes({ ...adminNotes, [req.id]: e.target.value, }) } />
) : req.status === "approved" ? (
setAdminNotes({ ...adminNotes, [req.id]: e.target.value, }) } />
) : ( {req.status === "cancelled" ? t( "ui.dev.request.status.cancelled", "승인 취소됨", ) : t("ui.common.rejected", "반려됨")} )}
)}
)) )}
setIsRequestModalOpen(false)} onSuccess={() => { queryClient.invalidateQueries({ queryKey: ["developer-requests"] }); setIsRequestModalOpen(false); }} tenantId={tenantId || ""} initialName={profileName} initialOrg={organizationName} initialEmail={profileEmail} initialPhone={profilePhone} initialRole={profileRoleLabel} />
); } function StatusBadge({ status }: { status: string }) { switch (status) { case "pending": return ( {t("ui.dev.request.status.pending", "대기 중")} ); case "approved": return ( {t("ui.dev.request.status.approved", "승인됨")} ); case "rejected": return ( {t("ui.dev.request.status.rejected", "반려됨")} ); case "cancelled": return ( {t("ui.dev.request.status.cancelled", "승인 취소됨")} ); default: return {status}; } } 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([ "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 (

{t("ui.dev.request.modal.title", "개발자 등록 신청")}

{t( "msg.dev.request.modal.desc", "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다.", )}

{developerAccessPageOptions.map((option) => { const checked = option.value === "all" ? accessPages.includes("all") : accessPages.includes(option.value); return ( ); })}

{t( "msg.dev.request.modal.pages_hint", "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.", )}