import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowLeft, Building2, ClipboardCopy, Loader2, Plus, Save, Trash2, } from "lucide-react"; import * as React from "react"; import { useForm } from "react-hook-form"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Button } from "../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../../components/ui/card"; import { Checkbox } from "../../components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { Switch } from "../../components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "../../components/ui/tabs"; import { type TenantSummary, type UserAppointment, type UserCreateRequest, type UserCreateResponse, createUser, fetchAllTenants, fetchMe, fetchTenant, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { isSuperAdminRole } from "../../lib/roles"; import { type OrgChartTenantSelection, buildAuthenticatedOrgChartTenantPickerUrl, filterNonHanmacFamilyTenants, parseOrgChartTenantSelection, } from "./orgChartPicker"; import type { UserSchemaField } from "./userSchemaFields"; import { resolvePersonalTenant } from "./utils/personalTenant"; type UserFormValues = UserCreateRequest & { metadata: Record }; type UserCategory = "hanmac" | "external" | "personal"; type PickerTarget = { kind: "appointment"; index: number }; type AppointmentDraft = UserAppointment & { draftId: string; }; function createDraftId() { return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`; } async function resolveTenantSelection( selection: OrgChartTenantSelection, tenants: TenantSummary[], ) { const cached = tenants.find((tenant) => tenant.id === selection.id); if (cached) { return { id: cached.id, name: cached.name, slug: cached.slug, }; } const tenant = await fetchTenant(selection.id); return { id: tenant.id, name: tenant.name, slug: tenant.slug, }; } function createEmptyAppointment(): AppointmentDraft { return { draftId: createDraftId(), tenantId: "", tenantName: "", tenantSlug: "", isOwner: false, grade: "", jobTitle: "", position: "", }; } function UserCreatePage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); const [error, setError] = React.useState(null); const [generatedPassword, setGeneratedPassword] = React.useState< string | null >(null); const [createdEmail, setCreatedEmail] = React.useState(null); const [autoPassword, setAutoPassword] = React.useState(true); const [userCategory, setUserCategory] = React.useState("hanmac"); const [additionalAppointments, setAdditionalAppointments] = React.useState< AppointmentDraft[] >([]); const [pickerTarget, setPickerTarget] = React.useState( null, ); const [isResolvingTenant, setIsResolvingTenant] = React.useState(false); const { data: tenantsData } = useQuery({ queryKey: ["tenants", "all"], queryFn: () => fetchAllTenants(), }); const tenants = tenantsData?.items ?? []; const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, }); const { register, handleSubmit, watch, setValue, formState: { errors }, } = useForm({ defaultValues: { email: "", password: "", name: "", phone: "", tenantSlug: searchParams.get("tenantSlug") || "", department: "", grade: "", position: "", jobTitle: "", role: "user", metadata: {}, }, }); // Lock company for tenant_admin React.useEffect(() => { if (profile?.role === "tenant_admin" && profile.tenantSlug) { setValue("tenantSlug", profile.tenantSlug); } }, [profile, setValue]); const hanmacFamilyTenantId = React.useMemo(() => { const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; if (typeof envTenantId === "string" && envTenantId.trim()) { return envTenantId.trim(); } return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? ""; }, [tenants]); const nonHanmacFamilyTenants = React.useMemo( () => filterNonHanmacFamilyTenants(tenants, hanmacFamilyTenantId), [tenants, hanmacFamilyTenantId], ); const selectedTenantSlug = watch("tenantSlug"); const personalTenant = React.useMemo( () => resolvePersonalTenant(tenants), [tenants], ); const selectedTenant = userCategory !== "external" ? undefined : nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug); const selectedTenantId = selectedTenant?.id ?? ""; const { data: tenantDetail } = useQuery({ queryKey: ["tenant", selectedTenantId], queryFn: () => fetchTenant(selectedTenantId), enabled: selectedTenantId.length > 0, }); const userSchema: UserSchemaField[] = Array.isArray( tenantDetail?.config?.userSchema, ) ? (tenantDetail?.config?.userSchema as UserSchemaField[]) : []; const registerMetadata = (field: UserSchemaField) => register(`metadata.${field.key}` as `metadata.${string}`, { required: field.required ? t( "msg.admin.users.create.form.field_required", "{{label}}은(는) 필수입니다.", { label: field.label || field.key, }, ) : false, pattern: field.validation ? { value: new RegExp(field.validation), message: t( "msg.admin.users.create.form.field_invalid", "{{label}} 형식이 올바르지 않습니다.", { label: field.label || field.key }, ), } : undefined, }); const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( import.meta.env.ORGFRONT_URL, { tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined, }, ); const applyTenantSelection = React.useCallback( async (selection: OrgChartTenantSelection, target: PickerTarget) => { setIsResolvingTenant(true); try { const tenant = await resolveTenantSelection(selection, tenants); setAdditionalAppointments((current) => current.map((appointment, index) => index === target.index ? { ...appointment, tenantId: tenant.id, tenantName: tenant.name, tenantSlug: tenant.slug, } : appointment, ), ); setPickerTarget(null); } catch (_) { setError( t( "msg.admin.users.create.tenant_resolve_failed", "선택한 테넌트 정보를 불러오지 못했습니다.", ), ); } finally { setIsResolvingTenant(false); } }, [tenants], ); React.useEffect(() => { if (!pickerTarget) return; const onMessage = (event: MessageEvent) => { const selection = parseOrgChartTenantSelection(event.data); if (!selection) return; void applyTenantSelection(selection, pickerTarget); }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [applyTenantSelection, pickerTarget]); const addAppointment = () => { setAdditionalAppointments((current) => [ ...current, createEmptyAppointment(), ]); }; const updateAppointment = ( index: number, patch: Partial, ) => { setAdditionalAppointments((current) => current.map((appointment, currentIndex) => { if (currentIndex === index) { return { ...appointment, ...patch }; } if (patch.isOwner === true) { return { ...appointment, isOwner: false }; } return appointment; }), ); }; const removeAppointment = (index: number) => { setAdditionalAppointments((current) => current.filter((_, currentIndex) => currentIndex !== index), ); }; const handleUserCategoryChange = (value: string) => { const nextCategory = value as UserCategory; setUserCategory(nextCategory); if (nextCategory !== "hanmac") { setAdditionalAppointments([]); } }; const ensurePersonalTenant = async () => { return personalTenant; }; const mutation = useMutation({ mutationFn: createUser, onSuccess: (data: UserCreateResponse) => { queryClient.invalidateQueries({ queryKey: ["users"] }); if (data.initialPassword) { setGeneratedPassword(data.initialPassword); setCreatedEmail(data.email); return; } navigate("/users"); }, onError: (err: AxiosError<{ error?: string }>) => { setError( err.response?.data?.error || t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."), ); }, }); const onSubmit = async (data: UserFormValues) => { setError(null); setGeneratedPassword(null); setCreatedEmail(null); const { hanmacFamily: _hanmacFamily, userType: _userType, ...formMetadata } = data.metadata ?? {}; const metadata: Record = { ...formMetadata, }; const payload: UserCreateRequest = { email: data.email, password: data.password, name: data.name, phone: data.phone, role: data.role, metadata, }; if (userCategory === "external") { if (!data.tenantSlug) { setError( t( "msg.admin.users.create.external_tenant_required", "외부 사용자는 대표소속을 선택해 주세요.", ), ); return; } payload.tenantSlug = data.tenantSlug; payload.department = data.department; payload.grade = data.grade; payload.position = data.position; payload.jobTitle = data.jobTitle; } if (userCategory === "personal") { try { const tenant = await ensurePersonalTenant(); payload.tenantSlug = tenant.slug; payload.metadata = { ...metadata, personalTenantId: tenant.id, }; } catch (_) { setError( t( "msg.admin.users.create.personal_tenant_failed", "Personal 테넌트를 준비하지 못했습니다.", ), ); return; } } if (userCategory === "hanmac") { const appointments = additionalAppointments .filter((appointment) => appointment.tenantId) .map((appointment) => ({ tenantId: appointment.tenantId, tenantSlug: appointment.tenantSlug, tenantName: appointment.tenantName, isPrimary: appointment.isOwner, isOwner: appointment.isOwner, grade: appointment.grade, jobTitle: appointment.jobTitle, position: appointment.position, })); if (appointments.length === 0) { setError( t( "msg.admin.users.create.appointment_required", "한맥 가족 구성원은 소속 테넌트를 하나 이상 선택해 주세요.", ), ); return; } const primary = appointments.find((a) => a.isOwner); if (primary) { metadata.primaryTenantId = primary.tenantId; metadata.primaryTenantSlug = primary.tenantSlug; metadata.primaryTenantName = primary.tenantName; metadata.primaryTenantIsOwner = true; } payload.additionalAppointments = appointments; payload.metadata = { ...metadata, additionalAppointments: appointments, }; } if (autoPassword) { payload.password = ""; } else if (!data.password) { setError( t( "msg.admin.users.create.password_required", "비밀번호를 입력하거나 자동 생성을 사용해 주세요.", ), ); return; } mutation.mutate(payload); }; const onCopyPassword = async () => { if (!generatedPassword) return; try { await navigator.clipboard.writeText(generatedPassword); } catch (_) { // ignore } }; return (

{t("ui.admin.users.create.title", "사용자 추가")}

{generatedPassword && ( {t( "ui.admin.users.create.password_generated.title", "초기 비밀번호 생성 완료", )} {createdEmail ? t( "msg.admin.users.create.password_generated.with_email", "{{email}} 계정의 초기 비밀번호입니다.", { email: createdEmail }, ) : t( "msg.admin.users.create.password_generated.default", "초기 비밀번호가 생성되었습니다.", )}
{generatedPassword}
)} {t("ui.admin.users.create.account.title", "계정 정보")} {t( "msg.admin.users.create.account.subtitle", "새로운 사용자를 시스템에 등록합니다.", )}
{error && (
{error}
)}
{errors.email && (

{errors.email.message}

)}

{autoPassword ? t( "msg.admin.users.create.form.password_auto_help", "비워두면 시스템이 초기 비밀번호를 자동 생성합니다.", ) : t( "msg.admin.users.create.form.password_manual_help", "초기 비밀번호를 직접 설정합니다.", )}

{errors.name && (

{errors.name.message}

)}

{t( "msg.admin.users.create.form.role_help", "시스템 접근 권한을 결정합니다.", )}

한맥가족 구성원 외부 기업 회원 개인 회원

소속별 직급/직책/직무

테넌트별 조직장 여부, 직급, 직책, 직무를 입력합니다.

{additionalAppointments.map((appointment, index) => (
{appointment.tenantSlug && ( {appointment.tenantSlug} )}
updateAppointment(index, { grade: event.target.value, }) } />
updateAppointment(index, { jobTitle: event.target.value, }) } />
updateAppointment(index, { position: event.target.value, }) } />
))}
{personalTenant ? `Personal (${personalTenant.slug})` : "Personal 테넌트로 생성됩니다."}
{userSchema.length > 0 && (

{t( "ui.admin.users.create.custom_fields.title", "테넌트 확장 정보 (Custom Fields)", )}

{userSchema.map((field) => (
{errors.metadata?.[field.key] && (

{ ( errors.metadata[field.key] as { message?: string; } )?.message }

)}
))}
)}
{ if (!open) setPickerTarget(null); }} > {t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")} {t( "msg.admin.users.create.form.picker_description", "org-chart에서 테넌트를 선택하면 사용자 소속에 반영됩니다.", )}