diff --git a/adminfront/biome.json b/adminfront/biome.json index 74913169..bf601407 100644 --- a/adminfront/biome.json +++ b/adminfront/biome.json @@ -24,6 +24,7 @@ "node_modules", "tsconfig*.json", "test-results", + "test-results.nobody-backup", "playwright-report" ] } diff --git a/adminfront/src/features/tenants/components/DomainTagInput.test.tsx b/adminfront/src/features/tenants/components/DomainTagInput.test.tsx index e9940c4b..d7834013 100644 --- a/adminfront/src/features/tenants/components/DomainTagInput.test.tsx +++ b/adminfront/src/features/tenants/components/DomainTagInput.test.tsx @@ -34,7 +34,10 @@ describe("DomainTagInput", () => { />, ); - await user.type(screen.getByPlaceholderText("example.com"), "samaneng.com "); + await user.type( + screen.getByPlaceholderText("example.com"), + "samaneng.com ", + ); expect( await screen.findByText( diff --git a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index 251abc44..dca46f15 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -71,8 +71,7 @@ export function normalizeSchemaField(field: unknown): SchemaField { type, required: Boolean(source.required), adminOnly: Boolean(source.adminOnly), - validation: - typeof source.validation === "string" ? source.validation : "", + validation: typeof source.validation === "string" ? source.validation : "", unsigned: Boolean(source.unsigned), isLoginId, indexed: isLoginId || Boolean(source.indexed), diff --git a/adminfront/src/features/tenants/utils/domainTags.ts b/adminfront/src/features/tenants/utils/domainTags.ts index 91893b3d..582169ee 100644 --- a/adminfront/src/features/tenants/utils/domainTags.ts +++ b/adminfront/src/features/tenants/utils/domainTags.ts @@ -54,6 +54,9 @@ export function formatDomainConflictMessage( const tenantName = "tenant" in conflict ? conflict.tenant.name - : conflict.tenantName || conflict.tenantSlug || conflict.tenantId || "다른"; + : conflict.tenantName || + conflict.tenantSlug || + conflict.tenantId || + "다른"; return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`; } diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.ts index 340484b7..4f155b7c 100644 --- a/adminfront/src/features/tenants/utils/tenantCsvImport.ts +++ b/adminfront/src/features/tenants/utils/tenantCsvImport.ts @@ -129,7 +129,10 @@ export function buildTenantImportPreview( candidates[0] && candidates[0].score >= 0.95 ? candidates[0].tenantId : "", - defaultCreateSlug: suggestUniqueTenantSlug(row.slug || row.name, tenants), + defaultCreateSlug: suggestUniqueTenantSlug( + row.slug || row.name, + tenants, + ), }; }) .sort((a, b) => { @@ -148,10 +151,7 @@ export function serializeTenantImportCSV( const sortedRows = [...previewRows].sort( (a, b) => a.row.rowNumber - b.row.rowNumber, ); - const targetTenantIds = buildTargetTenantIds( - sortedRows, - selectedTenantIds, - ); + const targetTenantIds = buildTargetTenantIds(sortedRows, selectedTenantIds); for (const preview of sortedRows) { const resolution = selectedTenantIds[preview.row.rowNumber] ?? ""; @@ -241,7 +241,9 @@ function remapParentTenantId( return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId; } if (parentTenantSlug) { - return targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? ""; + return ( + targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? "" + ); } return ""; } diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index a90b87de..5900db79 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -1,58 +1,58 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { - ArrowLeft, - Building2, - ClipboardCopy, - Loader2, - Plus, - Save, - Trash2, + ArrowLeft, + Building2, + ClipboardCopy, + Loader2, + Plus, + Save, + Trash2, } from "lucide-react"; import * as React from "react"; import { useForm } from "react-hook-form"; import { Link, useNavigate } from "react-router-dom"; import { Button } from "../../components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "../../components/ui/card"; import { Checkbox } from "../../components/ui/checkbox"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, + Tabs, + TabsContent, + TabsList, + TabsTrigger, } from "../../components/ui/tabs"; import { - type TenantSummary, - type UserAppointment, - type UserCreateRequest, - type UserCreateResponse, - createTenant, - createUser, - fetchMe, - fetchTenant, - fetchTenants, + type TenantSummary, + type UserAppointment, + type UserCreateRequest, + type UserCreateResponse, + createTenant, + createUser, + fetchMe, + fetchTenant, + fetchTenants, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { - type OrgChartTenantSelection, - buildAuthenticatedOrgChartTenantPickerUrl, - filterNonHanmacFamilyTenants, - parseOrgChartTenantSelection, + type OrgChartTenantSelection, + buildAuthenticatedOrgChartTenantPickerUrl, + filterNonHanmacFamilyTenants, + parseOrgChartTenantSelection, } from "./orgChartPicker"; import type { UserSchemaField } from "./userSchemaFields"; @@ -62,998 +62,869 @@ type UserType = "hanmac" | "external" | "personal"; type PickerTarget = { kind: "appointment"; index: number }; type AppointmentDraft = UserAppointment & { - draftId: string; + draftId: string; }; function createDraftId() { - return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`; + return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`; } async function resolveTenantSelection( - selection: OrgChartTenantSelection, - tenants: TenantSummary[], + 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); + const cached = tenants.find((tenant) => tenant.id === selection.id); + if (cached) { return { - id: tenant.id, - name: tenant.name, - slug: tenant.slug, + 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, - jobTitle: "", - position: "", - }; + return { + draftId: createDraftId(), + tenantId: "", + tenantName: "", + tenantSlug: "", + isOwner: false, + jobTitle: "", + position: "", + }; } function UserCreatePage() { - const navigate = useNavigate(); - 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 [isHanmacFamily, setIsHanmacFamily] = React.useState(true); - const [userType, setUserType] = React.useState("hanmac"); - const [additionalAppointments, setAdditionalAppointments] = React.useState< - AppointmentDraft[] - >([]); - const [pickerTarget, setPickerTarget] = React.useState( - null, - ); - const [isResolvingTenant, setIsResolvingTenant] = React.useState(false); + const navigate = useNavigate(); + 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 [isHanmacFamily, setIsHanmacFamily] = React.useState(true); + const [userType, setUserType] = 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", { limit: 100 }], - queryFn: () => fetchTenants(100, 0), - }); - const tenants = tenantsData?.items ?? []; + const { data: tenantsData } = useQuery({ + queryKey: ["tenants", { limit: 100 }], + queryFn: () => fetchTenants(100, 0), + }); + const tenants = tenantsData?.items ?? []; - const { data: profile } = useQuery({ - queryKey: ["me"], - queryFn: fetchMe, - }); + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); - const { - register, - handleSubmit, - watch, - setValue, - formState: { errors }, - } = useForm({ - defaultValues: { - email: "", - password: "", - name: "", - phone: "", - tenantSlug: "", - department: "", - position: "", - jobTitle: "", - metadata: {}, - }, - }); + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: { + email: "", + password: "", + name: "", + phone: "", + tenantSlug: "", + department: "", + position: "", + jobTitle: "", + metadata: {}, + }, + }); - // Lock company for tenant_admin - React.useEffect(() => { - if (profile?.role === "tenant_admin" && profile.tenantSlug) { - setValue("tenantSlug", profile.tenantSlug); - } - }, [profile, setValue]); + // 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 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( - () => - tenants.find( - (tenant) => - tenant.slug === "personal" || - (tenant.type === "PERSONAL" && - tenant.name.toLowerCase() === "personal"), + const selectedTenantSlug = watch("tenantSlug"); + const personalTenant = React.useMemo( + () => + tenants.find( + (tenant) => + tenant.slug === "personal" || + (tenant.type === "PERSONAL" && + tenant.name.toLowerCase() === "personal"), + ), + [tenants], + ); + const selectedTenant = + userType !== "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 }, ), - [tenants], - ); - const selectedTenant = - userType !== "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, + } + : undefined, }); - const userSchema: UserSchemaField[] = Array.isArray( - tenantDetail?.config?.userSchema, - ) - ? (tenantDetail?.config?.userSchema as UserSchemaField[]) - : []; + const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( + import.meta.env.ORGFRONT_URL, + { + tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined, + }, + ); - const registerMetadata = (field: UserSchemaField) => - register(`metadata.${field.key}` as `metadata.${string}`, { - required: field.required + 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) => + currentIndex === index ? { ...appointment, ...patch } : appointment, + ), + ); + }; + + const removeAppointment = (index: number) => { + setAdditionalAppointments((current) => + current.filter((_, currentIndex) => currentIndex !== index), + ); + }; + + const handleUserTypeChange = (value: string) => { + const nextType = value as UserType; + setUserType(nextType); + setIsHanmacFamily(nextType === "hanmac"); + if (nextType !== "hanmac") { + setAdditionalAppointments([]); + } + }; + + const ensurePersonalTenant = async () => { + if (personalTenant) return personalTenant; + const tenant = await createTenant({ + name: "Personal", + slug: "personal", + type: "PERSONAL", + status: "active", + }); + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + return tenant; + }; + + 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 metadata = { + ...(data.metadata ?? {}), + hanmacFamily: userType === "hanmac" && isHanmacFamily, + userType, + }; + + const payload: UserCreateRequest = { + email: data.email, + password: data.password, + name: data.name, + phone: data.phone, + metadata, + }; + + if (userType === "external") { + if (!data.tenantSlug) { + setError( + t( + "msg.admin.users.create.external_tenant_required", + "외부 사용자는 대표소속을 선택해 주세요.", + ), + ); + return; + } + payload.tenantSlug = data.tenantSlug; + payload.department = data.department; + payload.position = data.position; + payload.jobTitle = data.jobTitle; + } + + if (userType === "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 (userType === "hanmac") { + const appointments = additionalAppointments + .filter((appointment) => appointment.tenantId) + .map((appointment) => ({ + tenantId: appointment.tenantId, + tenantSlug: appointment.tenantSlug, + tenantName: appointment.tenantName, + isOwner: appointment.isOwner, + jobTitle: appointment.jobTitle, + position: appointment.position, + })); + + if (appointments.length === 0) { + setError( + t( + "msg.admin.users.create.appointment_required", + "한맥 가족 구성원은 소속 테넌트를 하나 이상 선택해 주세요.", + ), + ); + return; + } + + 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.form.field_required", - "{{label}}은(는) 필수입니다.", - { - label: field.label || field.key, - }, + "msg.admin.users.create.password_generated.with_email", + "{{email}} 계정의 초기 비밀번호입니다.", + { email: createdEmail }, ) - : 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, - }); + : t( + "msg.admin.users.create.password_generated.default", + "초기 비밀번호가 생성되었습니다.", + )} + + + +
+ {generatedPassword} + +
+
+ +
+
+
+ )} - const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( - import.meta.env.ORGFRONT_URL, - { - tenantId: userType === "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) => - currentIndex === index - ? { ...appointment, ...patch } - : appointment, - ), - ); - }; - - const removeAppointment = (index: number) => { - setAdditionalAppointments((current) => - current.filter((_, currentIndex) => currentIndex !== index), - ); - }; - - const handleUserTypeChange = (value: string) => { - const nextType = value as UserType; - setUserType(nextType); - setIsHanmacFamily(nextType === "hanmac"); - if (nextType !== "hanmac") { - setAdditionalAppointments([]); - } - }; - - const ensurePersonalTenant = async () => { - if (personalTenant) return personalTenant; - const tenant = await createTenant({ - name: "Personal", - slug: "personal", - type: "PERSONAL", - status: "active", - }); - queryClient.invalidateQueries({ queryKey: ["tenants"] }); - return tenant; - }; - - 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 metadata = { - ...(data.metadata ?? {}), - hanmacFamily: userType === "hanmac" && isHanmacFamily, - userType, - }; - - const payload: UserCreateRequest = { - email: data.email, - password: data.password, - name: data.name, - phone: data.phone, - metadata, - }; - - if (userType === "external") { - if (!data.tenantSlug) { - setError( - t( - "msg.admin.users.create.external_tenant_required", - "외부 사용자는 대표소속을 선택해 주세요.", - ), - ); - return; - } - payload.tenantSlug = data.tenantSlug; - payload.department = data.department; - payload.position = data.position; - payload.jobTitle = data.jobTitle; - } - - if (userType === "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 (userType === "hanmac") { - const appointments = additionalAppointments - .filter((appointment) => appointment.tenantId) - .map((appointment) => ({ - tenantId: appointment.tenantId, - tenantSlug: appointment.tenantSlug, - tenantName: appointment.tenantName, - isOwner: appointment.isOwner, - jobTitle: appointment.jobTitle, - position: appointment.position, - })); - - if (appointments.length === 0) { - setError( - t( - "msg.admin.users.create.appointment_required", - "한맥 가족 구성원은 소속 테넌트를 하나 이상 선택해 주세요.", - ), - ); - return; - } - - 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} +
)} - - - - {t("ui.admin.users.create.account.title", "계정 정보")} - - - {t( - "msg.admin.users.create.account.subtitle", - "새로운 사용자를 시스템에 등록합니다.", - )} - - - - + + + {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} +

+ )} +
+ +
+ + +
+
+ + + + + 한맥가족 구성원 + + + 외부 기업 회원 + + + 개인 회원 + + + + +
+
+ + +
+
+ + +
+
-
- - - {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} -

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

- 소속별 직급/직무 -

-

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

-
- -
- - {additionalAppointments.map( - (appointment, index) => ( -
-
- -
- - {appointment.tenantSlug && ( - - { - appointment.tenantSlug - } - - )} - -
-
- -
-
- - - 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에서 테넌트를 선택하면 사용자 소속에 반영됩니다.", - )} - - -