diff --git a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx deleted file mode 100644 index 81587b08..00000000 --- a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import type { AxiosError } from "axios"; -import { - AlertCircle, - CheckCircle2, - Download, - FileText, - Loader2, - Upload, -} from "lucide-react"; -import * as React from "react"; -import { Button } from "../../../components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../../../components/ui/dialog"; -import { toast } from "../../../components/ui/use-toast"; -import { - type ImportResult, - fetchImportProgress, - importOrgChart, -} from "../../../lib/adminApi"; -import { t } from "../../../lib/i18n"; - -interface OrgChartUploadModalProps { - tenantId: string; - onSuccess?: () => void; -} - -export function OrgChartUploadModal({ - tenantId, - onSuccess, -}: OrgChartUploadModalProps) { - const [open, setOpen] = React.useState(false); - const [file, setFile] = React.useState(null); - const [result, setResult] = React.useState(null); - const [progressId, setProgressId] = React.useState(null); - const fileInputRef = React.useRef(null); - - const mutation = useMutation({ - mutationFn: ({ file, pid }: { file: File; pid: string }) => - importOrgChart(tenantId, file, pid), - onSuccess: (data) => { - setResult(data); - setProgressId(null); - if (data.errors.length === 0) { - toast.success( - t( - "msg.admin.org.import_success", - "조직도가 성공적으로 업로드되었습니다.", - ), - ); - } else { - toast.error( - t( - "msg.admin.org.import_partial_success", - "일부 데이터 업로드 중 오류가 발생했습니다.", - ), - ); - } - onSuccess?.(); - }, - onError: (error: AxiosError<{ error?: string }>) => { - setProgressId(null); - toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), { - description: error.response?.data?.error || error.message, - }); - }, - }); - - const { data: progressData } = useQuery({ - queryKey: ["importProgress", progressId], - queryFn: () => - progressId ? fetchImportProgress(tenantId, progressId) : null, - enabled: !!progressId && mutation.isPending, - refetchInterval: 500, - }); - const percent = - progressData && progressData.total > 0 - ? Math.round((progressData.current / progressData.total) * 100) - : 0; - - const handleFileChange = (e: React.ChangeEvent) => { - const selectedFile = e.target.files?.[0]; - if (selectedFile) { - setFile(selectedFile); - setResult(null); - } - }; - - const handleUpload = () => { - if (file) { - const pid = Math.random().toString(36).substring(2, 15); - setProgressId(pid); - mutation.mutate({ file, pid }); - } - }; - - const downloadTemplate = () => { - const headers = "이메일,이름,소속,직급,직무,구분,그룹,디비젼,팀,셀"; - const example = `test1@example.com,홍길동,한맥,수석,기획,팀장,전략그룹,기획실,인사팀,- -test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실,개발1팀,A셀`; - const blob = new Blob([`\uFEFF${headers}\n${example}`], { - type: "text/csv;charset=utf-8", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "org_user_import_template.csv"; - a.click(); - URL.revokeObjectURL(url); - }; - - return ( - { - setOpen(val); - if (!val) { - setFile(null); - setResult(null); - } - }} - > - - - - - - - {t("ui.admin.org.import_title", "조직/사용자 통합 일괄 등록")} - - - {t( - "msg.admin.org.import_description", - "CSV 또는 XLSX 파일을 업로드하여 조직 테넌트와 사용자를 함께 생성/업데이트하고 멤버십을 매핑합니다.", - )} - - - - {!result ? ( -
-
- - - -
- {file && ( -
-
- -
-
{file.name}
-
- {(file.size / 1024).toFixed(1)} KB -
-
-
- {mutation.isPending && progressId && ( -
-
- 데이터 처리 중... - - {percent}% ({progressData?.current || 0} /{" "} - {progressData?.total || 0}) - -
-
-
-
-
- )} -
- )} -
- ) : ( -
-
-
-
- 전체 행 -
-
{result.totalRows}
-
-
-
- 처리 완료 -
-
- {result.processed} -
-
-
-
- 사용자 생성/업데이트 -
-
- {result.userCreated} / {result.userUpdated} -
-
-
-
- 조직(테넌트) 생성 -
-
- {result.tenantCreated} -
-
-
- - {result.errors.length > 0 && ( -
-
- - 오류 목록 ({result.errors.length}) -
-
- {result.errors.map((err, idx) => ( -
- {" "} - {err} -
- ))} -
-
- )} -
- )} - - - {!result ? ( - - ) : ( - - )} - - -
- ); -} diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index 2778c8e8..bc2c22fa 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -47,7 +47,6 @@ import { removeGroupMember, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -import { OrgChartUploadModal } from "../components/OrgChartUploadModal"; type UserGroupNode = GroupSummary & { children: UserGroupNode[]; @@ -445,10 +444,6 @@ function TenantGroupsPage() {
- groupsQuery.refetch()} - /> + +
+ {importMessage && ( +
+ {importMessage} +
+ )} @@ -247,7 +400,7 @@ function TenantListPage() { })} - {" "} +
@@ -410,6 +563,139 @@ function TenantListPage() {
+ + + + + + {t("ui.admin.tenants.import_preview.title", "CSV 가져오기 확인")} + + + {t( + "msg.admin.tenants.import_preview.description", + "tenant_id가 없는 행은 기존 테넌트 후보와 비교한 뒤 신규 생성 또는 기존 테넌트 갱신으로 처리합니다.", + )} + + + +
+ + + + + {t("ui.common.row", "행")} + + + {t("ui.admin.tenants.table.name", "NAME")} + + + {t("ui.admin.tenants.table.slug", "SLUG")} + + + {t("ui.admin.tenants.import_preview.match", "매칭")} + + + {t("ui.admin.tenants.import_preview.candidates", "후보")} + + + + + {previewRows.map((preview) => ( + + + {preview.row.rowNumber} + + + {preview.row.name} + + + {preview.row.slug} + + + {preview.row.tenantId ? ( + + {t( + "ui.admin.tenants.import_preview.fixed_id", + "ID 지정됨", + )} + + ) : ( + + )} + + + {preview.candidates.length > 0 ? ( +
+ {preview.candidates.map((candidate) => ( + = 0.95 ? "default" : "outline" + } + data-testid="tenant-import-candidate" + > + {candidate.name}{" "} + {Math.round(candidate.score * 100)}% + + ))} +
+ ) : ( + + {t( + "ui.admin.tenants.import_preview.no_candidates", + "후보 없음", + )} + + )} +
+
+ ))} +
+
+
+ + + + + +
+
); } diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts new file mode 100644 index 00000000..4c7315a1 --- /dev/null +++ b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { TenantSummary } from "../../../lib/adminApi"; +import { + buildTenantImportPreview, + parseTenantCSV, + serializeTenantImportCSV, +} from "./tenantCsvImport"; + +const tenants: TenantSummary[] = [ + { + id: "tenant-1", + type: "COMPANY", + name: "Hanmac Technology", + slug: "hanmac", + description: "", + status: "active", + domains: ["hanmac.example.com"], + memberCount: 0, + createdAt: "", + updatedAt: "", + }, + { + id: "tenant-2", + type: "COMPANY", + name: "Saman Engineering", + slug: "saman", + description: "", + status: "active", + domains: [], + memberCount: 0, + createdAt: "", + updatedAt: "", + }, +]; + +describe("tenantCsvImport", () => { + it("parses tenant CSV rows with the supported import columns", () => { + const rows = parseTenantCSV( + "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n", + ); + + expect(rows).toEqual([ + { + rowNumber: 2, + tenantId: "", + name: "Hanmac Tech", + type: "COMPANY", + parentTenantId: "", + slug: "hanmac-tech", + memo: "Memo", + emailDomain: "hanmac-tech.example.com", + }, + ]); + }); + + it("puts tenant_id-less rows with exact or similar matches first", () => { + const rows = parseTenantCSV( + "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,New Tenant,COMPANY,,new-tenant,,\n,Hanmac Tech,COMPANY,,hanmac-tech,,\n,Saman Engineering,COMPANY,,saman-copy,,\n", + ); + + const preview = buildTenantImportPreview(rows, tenants); + + expect(preview.map((row) => row.row.name)).toEqual([ + "Saman Engineering", + "Hanmac Tech", + "New Tenant", + ]); + expect(preview[0].candidates[0]).toMatchObject({ + tenantId: "tenant-2", + reason: "exact_name", + }); + expect(preview[1].candidates[0]).toMatchObject({ + tenantId: "tenant-1", + reason: "similar_name", + }); + expect(preview[2].candidates).toEqual([]); + }); + + it("serializes selected matches by filling tenant_id before upload", () => { + const rows = parseTenantCSV( + "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n", + ); + const preview = buildTenantImportPreview(rows, tenants); + const csv = serializeTenantImportCSV(preview, { + 2: "tenant-1", + }); + + expect(csv).toContain( + "tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com", + ); + }); +}); diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.ts new file mode 100644 index 00000000..25d06853 --- /dev/null +++ b/adminfront/src/features/tenants/utils/tenantCsvImport.ts @@ -0,0 +1,264 @@ +import type { TenantSummary } from "../../../lib/adminApi"; + +export type TenantCSVRow = { + rowNumber: number; + tenantId: string; + name: string; + type: string; + parentTenantId: string; + slug: string; + memo: string; + emailDomain: string; +}; + +export type TenantImportCandidate = { + tenantId: string; + name: string; + slug: string; + score: number; + reason: "exact_name" | "exact_slug" | "similar_name"; +}; + +export type TenantImportPreviewRow = { + row: TenantCSVRow; + candidates: TenantImportCandidate[]; + defaultTenantId: string; +}; + +const importHeaders = [ + "tenant_id", + "name", + "type", + "parent_tenant_id", + "slug", + "memo", + "email_domain", +]; + +const headerAliases: Record = { + id: "tenantId", + tenantid: "tenantId", + tenant_id: "tenantId", + name: "name", + type: "type", + parentid: "parentTenantId", + parent_id: "parentTenantId", + parenttenantid: "parentTenantId", + parent_tenant_id: "parentTenantId", + slug: "slug", + memo: "memo", + description: "memo", + "email-domain": "emailDomain", + emaildomain: "emailDomain", + email_domain: "emailDomain", + domain: "emailDomain", + domains: "emailDomain", +}; + +export function parseTenantCSV(text: string): TenantCSVRow[] { + const records = parseCSV(text.replace(/^\uFEFF/, "")); + if (records.length === 0) return []; + + const header = new Map(); + records[0].forEach((column, index) => { + const normalized = normalizeHeader(column); + const key = headerAliases[normalized]; + if (key) header.set(key, index); + }); + + return records.slice(1).flatMap((record, index) => { + if (record.every((value) => value.trim() === "")) return []; + const value = (key: keyof TenantCSVRow) => { + const columnIndex = header.get(key); + if (columnIndex === undefined) return ""; + return (record[columnIndex] ?? "").trim(); + }; + + return { + rowNumber: index + 2, + tenantId: value("tenantId"), + name: value("name"), + type: value("type"), + parentTenantId: value("parentTenantId"), + slug: value("slug"), + memo: value("memo"), + emailDomain: value("emailDomain"), + }; + }); +} + +export function buildTenantImportPreview( + rows: TenantCSVRow[], + tenants: TenantSummary[], +): TenantImportPreviewRow[] { + return rows + .map((row) => { + const candidates = row.tenantId ? [] : findTenantCandidates(row, tenants); + return { + row, + candidates, + defaultTenantId: + candidates[0] && candidates[0].score >= 0.95 + ? candidates[0].tenantId + : "", + }; + }) + .sort((a, b) => { + const aScore = a.candidates[0]?.score ?? 0; + const bScore = b.candidates[0]?.score ?? 0; + if (bScore !== aScore) return bScore - aScore; + return a.row.rowNumber - b.row.rowNumber; + }); +} + +export function serializeTenantImportCSV( + previewRows: TenantImportPreviewRow[], + selectedTenantIds: Record, +) { + const lines = [importHeaders]; + for (const preview of [...previewRows].sort( + (a, b) => a.row.rowNumber - b.row.rowNumber, + )) { + const selectedTenantId = selectedTenantIds[preview.row.rowNumber] ?? ""; + lines.push([ + preview.row.tenantId || selectedTenantId, + preview.row.name, + preview.row.type, + preview.row.parentTenantId, + preview.row.slug, + preview.row.memo, + preview.row.emailDomain, + ]); + } + return lines.map(formatCSVRecord).join("\n") + "\n"; +} + +function findTenantCandidates( + row: TenantCSVRow, + tenants: TenantSummary[], +): TenantImportCandidate[] { + return tenants + .map((tenant) => { + const nameScore = similarity(row.name, tenant.name); + const slugScore = + normalizeToken(row.slug) && + normalizeToken(row.slug) === normalizeToken(tenant.slug) + ? 0.98 + : 0; + const exactName = + normalizeToken(row.name) === normalizeToken(tenant.name); + const score = exactName ? 1 : Math.max(slugScore, nameScore); + const reason: TenantImportCandidate["reason"] = exactName + ? "exact_name" + : slugScore >= 0.98 + ? "exact_slug" + : "similar_name"; + return { + tenantId: tenant.id, + name: tenant.name, + slug: tenant.slug, + score, + reason, + }; + }) + .filter((candidate) => candidate.score >= 0.45) + .sort((a, b) => b.score - a.score) + .slice(0, 3); +} + +function parseCSV(text: string): string[][] { + const rows: string[][] = []; + let current = ""; + let row: string[] = []; + let quoted = false; + + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + const next = text[i + 1]; + + if (char === '"' && quoted && next === '"') { + current += '"'; + i += 1; + continue; + } + if (char === '"') { + quoted = !quoted; + continue; + } + if (char === "," && !quoted) { + row.push(current); + current = ""; + continue; + } + if ((char === "\n" || char === "\r") && !quoted) { + if (char === "\r" && next === "\n") i += 1; + row.push(current); + rows.push(row); + row = []; + current = ""; + continue; + } + current += char; + } + + if (current !== "" || row.length > 0) { + row.push(current); + rows.push(row); + } + + return rows; +} + +function formatCSVRecord(record: string[]) { + return record + .map((value) => { + if (!/[",\r\n]/.test(value)) return value; + return `"${value.replaceAll('"', '""')}"`; + }) + .join(","); +} + +function normalizeHeader(value: string) { + return value.trim().toLowerCase().replaceAll(" ", "_"); +} + +function normalizeToken(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, "") + .replace(/[^\p{L}\p{N}]/gu, ""); +} + +function similarity(left: string, right: string) { + const a = normalizeToken(left); + const b = normalizeToken(right); + if (!a || !b) return 0; + if (a === b) return 1; + if (a.includes(b) || b.includes(a)) { + return Math.min(a.length, b.length) / Math.max(a.length, b.length); + } + + const distance = levenshtein(a, b); + return 1 - distance / Math.max(a.length, b.length); +} + +function levenshtein(left: string, right: string) { + const previous = Array.from({ length: right.length + 1 }, (_, i) => i); + const current = Array.from({ length: right.length + 1 }, () => 0); + + for (let i = 1; i <= left.length; i += 1) { + current[0] = i; + for (let j = 1; j <= right.length; j += 1) { + const cost = left[i - 1] === right[j - 1] ? 0 : 1; + current[j] = Math.min( + current[j - 1] + 1, + previous[j] + 1, + previous[j - 1] + cost, + ); + } + previous.splice(0, previous.length, ...current); + } + + return previous[right.length]; +} diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index aafa0f92..c634cb35 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -1,548 +1,1068 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react"; +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 } 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, +} from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { - type UserCreateRequest, - type UserCreateResponse, - createUser, - fetchMe, - fetchTenant, - fetchTenants, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "../../components/ui/tabs"; +import { + 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, +} from "./orgChartPicker"; type UserSchemaField = { - key: string; - label?: string; - type?: "text" | "number" | "boolean" | "date"; - required?: boolean; - adminOnly?: boolean; - validation?: string; - isLoginId?: boolean; + key: string; + label?: string; + type?: "text" | "number" | "boolean" | "date"; + required?: boolean; + adminOnly?: boolean; + validation?: string; + isLoginId?: boolean; }; type UserFormValues = UserCreateRequest & { metadata: Record }; +type UserType = "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, + 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 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 { - register, - handleSubmit, - watch, - setValue, - formState: { errors }, - } = useForm({ - defaultValues: { - email: "", - password: "", - name: "", - phone: "", - role: "user", - 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]); - - const selectedTenantSlug = watch("tenantSlug"); - const selectedTenant = tenants.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 { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, }); - 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 { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: { + email: "", + password: "", + name: "", + phone: "", + tenantSlug: "", + department: "", + position: "", + jobTitle: "", + metadata: {}, + }, + }); - const onSubmit = (data: UserFormValues) => { - setError(null); - setGeneratedPassword(null); - setCreatedEmail(null); + // Lock company for tenant_admin + React.useEffect(() => { + if (profile?.role === "tenant_admin" && profile.tenantSlug) { + setValue("tenantSlug", profile.tenantSlug); + } + }, [profile, setValue]); - const payload = { ...data }; + 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], + ); - if (autoPassword) { - payload.password = ""; - } else if (!data.password) { - setError( - t( - "msg.admin.users.create.password_required", - "비밀번호를 입력하거나 자동 생성을 사용해 주세요.", - ), - ); - return; - } + 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); - mutation.mutate(payload); - }; + const selectedTenantId = selectedTenant?.id ?? ""; - const onCopyPassword = async () => { - if (!generatedPassword) return; - try { - await navigator.clipboard.writeText(generatedPassword); - } catch (_) { - // ignore - } - }; + const { data: tenantDetail } = useQuery({ + queryKey: ["tenant", selectedTenantId], + queryFn: () => fetchTenant(selectedTenantId), + enabled: selectedTenantId.length > 0, + }); - return ( -
-
-
-

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

-
- -
+ const userSchema: UserSchemaField[] = Array.isArray( + tenantDetail?.config?.userSchema, + ) + ? (tenantDetail?.config?.userSchema as UserSchemaField[]) + : []; - {generatedPassword && ( - - - - {t( - "ui.admin.users.create.password_generated.title", - "초기 비밀번호 생성 완료", - )} - - - {createdEmail + const registerMetadata = (field: UserSchemaField) => + register(`metadata.${field.key}` as `metadata.${string}`, { + required: field.required ? t( - "msg.admin.users.create.password_generated.with_email", - "{{email}} 계정의 초기 비밀번호입니다.", - { email: createdEmail }, + "msg.admin.users.create.form.field_required", + "{{label}}은(는) 필수입니다.", + { + label: field.label || field.key, + }, ) - : t( - "msg.admin.users.create.password_generated.default", - "초기 비밀번호가 생성되었습니다.", - )} - - - -
- {generatedPassword} - -
-
- -
-
-
- )} + : 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("ui.admin.users.create.account.title", "계정 정보")} - - - {t( - "msg.admin.users.create.account.subtitle", - "새로운 사용자를 시스템에 등록합니다.", - )} - - - -
- {error && ( -
- {error} -
- )} + const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( + import.meta.env.VITE_ORGCHART_URL, + { + tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined, + }, + ); -
- - - {errors.email && ( -

- {errors.email.message} -

- )} -
- -
-
- - -
- -

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

-
- -
-
- - { + 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, ), - })} - /> - {errors.name && ( -

- {errors.name.message} -

- )} -
+ ); + 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); + }; -
- + 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", "사용자 추가")} +

-
+ + -
- - - -
-
- -
-
- - - -
- -
- - - -
-
- - {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 - } -

- )} -
- ))} -
-
+ + + {createdEmail + ? t( + "msg.admin.users.create.password_generated.with_email", + "{{email}} 계정의 초기 비밀번호입니다.", + { email: createdEmail }, + ) + : t( + "msg.admin.users.create.password_generated.default", + "초기 비밀번호가 생성되었습니다.", + )} + + + +
+ + {generatedPassword} + + +
+
+ +
+
+ )} -
- -
- -
-

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

-
+ + + + {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} +

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

+ 소속별 직급/직무 +

+

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

+
+ +
+ + {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에서 테넌트를 선택하면 사용자 소속에 반영됩니다.", + )} + + +