1
0
forked from baron/baron-sso

org chart 연동기능 추가

This commit is contained in:
2026-04-29 21:00:51 +09:00
parent 438f844f2b
commit 01e7b15c46
25 changed files with 5141 additions and 2940 deletions

View File

@@ -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<File | null>(null);
const [result, setResult] = React.useState<ImportResult | null>(null);
const [progressId, setProgressId] = React.useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setFile(null);
setResult(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Upload size={14} />
{t("ui.admin.org.import_btn", "조직/사용자 통합 임포트")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.org.import_title", "조직/사용자 통합 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.org.import_description",
"CSV 또는 XLSX 파일을 업로드하여 조직 테넌트와 사용자를 함께 생성/업데이트하고 멤버십을 매핑합니다.",
)}
</DialogDescription>
</DialogHeader>
{!result ? (
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
disabled={mutation.isPending}
>
<Download size={14} />
{t("ui.admin.org.download_template", "템플릿 다운로드")}
</Button>
<input
type="file"
accept=".csv, .xlsx, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
disabled={mutation.isPending}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
size="sm"
disabled={mutation.isPending}
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20 flex flex-col gap-3">
<div className="flex items-center gap-3">
<FileText className="text-primary" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB
</div>
</div>
</div>
{mutation.isPending && progressId && (
<div className="w-full mt-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex justify-between text-xs mb-1 font-medium text-muted-foreground">
<span> ...</span>
<span>
{percent}% ({progressData?.current || 0} /{" "}
{progressData?.total || 0})
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden relative">
<div
className="bg-primary h-full rounded-full transition-all duration-300 ease-out absolute top-0 left-0"
style={{ width: `${Math.max(5, percent)}%` }}
/>
</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-primary/5 border border-primary/10">
<div className="text-sm font-medium text-muted-foreground">
</div>
<div className="text-2xl font-bold">{result.totalRows}</div>
</div>
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/10">
<div className="text-sm font-medium text-muted-foreground">
</div>
<div className="text-2xl font-bold text-green-600">
{result.processed}
</div>
</div>
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/10">
<div className="text-sm font-medium text-muted-foreground">
/
</div>
<div className="text-xl font-bold text-blue-600">
{result.userCreated} / {result.userUpdated}
</div>
</div>
<div className="p-4 rounded-lg bg-orange-500/5 border border-orange-500/10">
<div className="text-sm font-medium text-muted-foreground">
()
</div>
<div className="text-2xl font-bold text-orange-600">
{result.tenantCreated}
</div>
</div>
</div>
{result.errors.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
<AlertCircle size={16} />
({result.errors.length})
</div>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 bg-destructive/5 text-xs font-mono space-y-1">
{result.errors.map((err, idx) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: Errors are a static list returned from the server.
key={idx}
className="text-destructive border-b border-destructive/10 pb-1 last:border-0"
>
{" "}
{err}
</div>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter>
{!result ? (
<Button
onClick={handleUpload}
disabled={!file || mutation.isPending}
className="w-full sm:w-auto relative"
>
{mutation.isPending ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
({percent}%)
</>
) : (
t("ui.admin.org.start_import", "임포트 시작")
)}
</Button>
) : (
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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() {
</CardDescription>
</div>
<div className="flex items-center gap-2">
<OrgChartUploadModal
tenantId={tenantId}
onSuccess={() => groupsQuery.refetch()}
/>
<Button
variant="ghost"
size="sm"

View File

@@ -1,12 +1,14 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
CornerDownRight,
Download,
FileSpreadsheet,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
@@ -21,6 +23,14 @@ import {
CardTitle,
} from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
@@ -34,16 +44,35 @@ import {
type TenantSummary,
deleteTenant,
deleteTenantsBulk,
exportTenantsCSV,
fetchMe,
fetchTenants,
importTenantsCSV,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
import {
type TenantImportPreviewRow,
buildTenantImportPreview,
parseTenantCSV,
serializeTenantImportCSV,
} from "../utils/tenantCsvImport";
const tenantCSVTemplate =
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n";
function TenantListPage() {
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [importMessage, setImportMessage] = React.useState("");
const [previewRows, setPreviewRows] = React.useState<
TenantImportPreviewRow[]
>([]);
const [selectedMatches, setSelectedMatches] = React.useState<
Record<number, string>
>({});
const [previewOpen, setPreviewOpen] = React.useState(false);
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -87,6 +116,50 @@ function TenantListPage() {
},
});
const exportMutation = useMutation({
mutationFn: exportTenantsCSV,
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
});
const importMutation = useMutation({
mutationFn: (file: File) => importTenantsCSV(file),
onSuccess: (result) => {
setImportMessage(
t(
"msg.admin.tenants.import_result",
"생성 {{created}}, 갱신 {{updated}}, 실패 {{failed}}",
{
created: result.created,
updated: result.updated,
failed: result.failed,
},
),
);
setPreviewOpen(false);
setPreviewRows([]);
setSelectedMatches({});
query.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
setImportMessage(
error.response?.data?.error ??
t(
"msg.admin.tenants.import_error",
"테넌트 가져오기에 실패했습니다.",
),
);
},
});
if (
profile &&
profile.role !== "super_admin" &&
@@ -161,8 +234,50 @@ function TenantListPage() {
deleteBulkMutation.mutate(selectedIds);
};
const rootTenant =
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
const handleTemplateDownload = () => {
const blob = new Blob([tenantCSVTemplate], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "tenant-import-template.csv";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
};
const handleImportFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setImportMessage("");
const text = await file.text();
const rows = parseTenantCSV(text);
if (rows.length === 0) {
setImportMessage(
t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."),
);
return;
}
const preview = buildTenantImportPreview(rows, allTenants);
setPreviewRows(preview);
setSelectedMatches(
Object.fromEntries(
preview
.filter((row) => row.defaultTenantId)
.map((row) => [row.row.rowNumber, row.defaultTenantId]),
),
);
setPreviewOpen(true);
};
const handleImportConfirm = () => {
const csv = serializeTenantImportCSV(previewRows, selectedMatches);
const file = new File([csv], "tenants.csv", { type: "text/csv" });
importMutation.mutate(file);
};
const handleDelete = (tenantId: string, tenantName: string) => {
if (
@@ -210,10 +325,40 @@ function TenantListPage() {
</RoleGuard>
<RoleGuard roles={["super_admin"]}>
<OrgChartUploadModal
tenantId={rootTenant?.id || "root"}
onSuccess={() => query.refetch()}
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
data-testid="tenant-import-input"
onChange={handleImportFile}
/>
<Button
variant="outline"
onClick={handleTemplateDownload}
data-testid="tenant-template-btn"
>
<FileSpreadsheet size={16} />
{t("ui.admin.tenants.csv_template", "템플릿")}
</Button>
<Button
variant="outline"
onClick={() => exportMutation.mutate()}
disabled={exportMutation.isPending}
data-testid="tenant-export-btn"
>
<Download size={16} />
{t("ui.admin.tenants.export", "내보내기")}
</Button>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
data-testid="tenant-import-btn"
>
<Upload size={16} />
{t("ui.admin.tenants.import", "가져오기")}
</Button>
</RoleGuard>
<Button
@@ -233,6 +378,14 @@ function TenantListPage() {
</Button>
</RoleGuard>
</div>
{importMessage && (
<div
className="basis-full rounded-md border border-border bg-secondary px-3 py-2 text-sm"
data-testid="tenant-import-result"
>
{importMessage}
</div>
)}
</header>
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -247,7 +400,7 @@ function TenantListPage() {
})}
</CardDescription>
</div>
</CardHeader>{" "}
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-[240px] max-w-sm">
@@ -410,6 +563,139 @@ function TenantListPage() {
</div>
</CardContent>
</Card>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.import_preview.title", "CSV 가져오기 확인")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.import_preview.description",
"tenant_id가 없는 행은 기존 테넌트 후보와 비교한 뒤 신규 생성 또는 기존 테넌트 갱신으로 처리합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto rounded-md border">
<Table>
<TableHeader className="sticky top-0 bg-secondary">
<TableRow>
<TableHead className="w-[72px]">
{t("ui.common.row", "행")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.import_preview.match", "매칭")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.import_preview.candidates", "후보")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewRows.map((preview) => (
<TableRow
key={preview.row.rowNumber}
data-testid={`tenant-import-preview-row-${preview.row.rowNumber}`}
>
<TableCell className="font-mono text-xs">
{preview.row.rowNumber}
</TableCell>
<TableCell className="font-medium">
{preview.row.name}
</TableCell>
<TableCell className="font-mono text-xs">
{preview.row.slug}
</TableCell>
<TableCell>
{preview.row.tenantId ? (
<Badge variant="outline">
{t(
"ui.admin.tenants.import_preview.fixed_id",
"ID 지정됨",
)}
</Badge>
) : (
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={selectedMatches[preview.row.rowNumber] ?? ""}
data-testid={`tenant-import-match-select-${preview.row.rowNumber}`}
onChange={(event) =>
setSelectedMatches((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
>
<option value="">
{t(
"ui.admin.tenants.import_preview.create_new",
"신규 생성",
)}
</option>
{preview.candidates.map((candidate) => (
<option
key={candidate.tenantId}
value={candidate.tenantId}
>
{candidate.name} ({candidate.slug})
</option>
))}
</select>
)}
</TableCell>
<TableCell>
{preview.candidates.length > 0 ? (
<div className="flex flex-wrap gap-1">
{preview.candidates.map((candidate) => (
<Badge
key={candidate.tenantId}
variant={
candidate.score >= 0.95 ? "default" : "outline"
}
data-testid="tenant-import-candidate"
>
{candidate.name}{" "}
{Math.round(candidate.score * 100)}%
</Badge>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">
{t(
"ui.admin.tenants.import_preview.no_candidates",
"후보 없음",
)}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleImportConfirm}
disabled={importMutation.isPending}
data-testid="tenant-import-confirm-btn"
>
{t("ui.admin.tenants.import_preview.confirm", "가져오기 실행")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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",
);
});
});

View File

@@ -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<string, keyof TenantCSVRow> = {
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<keyof TenantCSVRow, number>();
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<number, string>,
) {
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];
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@ import {
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -33,6 +32,7 @@ import {
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Switch } from "../../components/ui/switch";
import {
Table,
TableBody,
@@ -46,14 +46,14 @@ import {
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
exportUsersCSVUrl,
exportUsersCSV,
fetchMe,
fetchTenant,
fetchTenants,
fetchUsers,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
type UserSchemaField = {
@@ -144,6 +144,41 @@ function UserListPage() {
},
});
const exportMutation = useMutation({
mutationFn: () => exportUsersCSV(search, selectedCompany),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () => {
toast.error(
t(
"msg.admin.users.export_error",
"사용자 내보내기에 실패했습니다.",
),
);
},
});
const statusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: string; status: string }) =>
updateUser(userId, { status }),
onSuccess: () => {
query.refetch();
},
onError: () => {
toast.error(
t("msg.admin.users.status_error", "사용자 상태 변경에 실패했습니다."),
);
},
});
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
@@ -156,8 +191,7 @@ function UserListPage() {
};
const handleExport = () => {
const url = exportUsersCSVUrl(search, selectedCompany);
window.open(url, "_blank");
exportMutation.mutate();
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
@@ -275,7 +309,12 @@ function UserListPage() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button variant="outline" onClick={handleExport} className="gap-2">
<Button
variant="outline"
onClick={handleExport}
className="gap-2"
disabled={exportMutation.isPending}
>
<FileDown size={16} />
{t("ui.common.export", "내보내기")}
</Button>
@@ -428,21 +467,12 @@ function UserListPage() {
<TableHead className="min-w-[200px]">
{t(
"ui.admin.users.list.table.name_email",
"NAME / EMAIL",
"이름 / 이메일 / 전화번호",
)}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.status", "STATUS")}
</TableHead>
<TableHead>
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
@@ -464,7 +494,7 @@ function UserListPage() {
{query.isLoading && (
<TableRow>
<TableCell
colSpan={7 + userSchema.length}
colSpan={5 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
@@ -474,7 +504,7 @@ function UserListPage() {
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={7 + userSchema.length}
colSpan={5 + userSchema.length}
className="h-24 text-center"
>
{t(
@@ -513,35 +543,47 @@ function UserListPage() {
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<div
className="truncate text-sm"
data-testid={`user-contact-${user.id}`}
>
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground">
{" "}
{user.email}
</span>
{user.phone && (
<span className="text-muted-foreground">
{" "}
{user.phone}
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{t(`ui.admin.role.${user.role}`, user.role)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium text-blue-600">
{user.tenant?.name || user.tenantSlug || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) =>
statusMutation.mutate({
userId: user.id,
status: checked ? "active" : "inactive",
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
aria-label={t(
"ui.admin.users.list.toggle_status",
"{{name}} 활성 상태",
{ name: user.name },
)}
data-testid={`user-status-toggle-${user.id}`}
/>
<span className="text-sm text-muted-foreground">
{t(`ui.common.status.${user.status}`, user.status)}
</span>
</div>
</TableCell>
@@ -625,16 +667,6 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<UserBulkMoveGroupModal
userIds={selectedUserIds}
selectedUsers={items.filter((u) =>
selectedUserIds.includes(u.id),
)}
onSuccess={() => {
query.refetch();
setSelectedUserIds([]);
}}
/>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
buildOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
describe("orgChartPicker", () => {
it("builds the tenant picker embed URL from VITE_ORGCHART_URL", () => {
expect(buildOrgChartTenantPickerUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600",
);
});
it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => {
expect(
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
tenantId: "hanmac-family-id",
}),
).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&tenantId=hanmac-family-id",
);
});
it("wraps the picker URL with the org-chart auto login entry", () => {
expect(
buildAuthenticatedOrgChartTenantPickerUrl(
"https://orgchart.example.com",
{
tenantId: "hanmac-family-id",
},
),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id",
);
});
it("parses the first tenant id and name from orgfront confirm messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [
{
type: "tenant",
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
},
],
},
}),
).toEqual({
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
});
});
it("ignores non-tenant or malformed picker messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [{ type: "user", id: "u-1", name: "User" }],
},
}),
).toBeNull();
expect(parseOrgChartTenantSelection({ type: "other" })).toBeNull();
});
it("filters Hanmac family subtree and system tenants from non-family tenant choices", () => {
const visibleTenants = filterNonHanmacFamilyTenants(
[
{
id: "system-id",
slug: "system",
name: "System",
type: "SYSTEM",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
type: "USER_GROUP",
parentId: "hanmac-company-id",
},
],
"hanmac-family-id",
);
expect(visibleTenants.map((tenant) => tenant.slug)).toEqual(["external"]);
});
});

View File

@@ -0,0 +1,142 @@
export type OrgChartTenantSelection = {
id: string;
name: string;
};
export type TenantFilterTarget = {
id?: string;
slug?: string;
type?: string;
parentId?: string | null;
};
type OrgChartPickerMessage = {
type?: unknown;
payload?: {
selections?: Array<{
type?: unknown;
id?: unknown;
name?: unknown;
}>;
};
};
type OrgChartTenantPickerOptions = {
tenantId?: string;
};
function isSystemTenant(tenant: TenantFilterTarget) {
const slug = tenant.slug?.trim().toLowerCase();
const type = tenant.type?.trim().toUpperCase();
return (
!tenant.id?.trim() ||
!tenant.slug?.trim() ||
type === "SYSTEM" ||
slug === "system" ||
slug === "global"
);
}
function isInTenantSubtree<T extends TenantFilterTarget>(
tenant: T,
rootTenantId: string,
tenantById: Map<string, T>,
) {
if (!rootTenantId) {
return false;
}
if (tenant.id === rootTenantId) {
return true;
}
const visitedTenantIds = new Set<string>();
let parentId = tenant.parentId ?? "";
while (parentId) {
if (parentId === rootTenantId) {
return true;
}
if (visitedTenantIds.has(parentId)) {
return false;
}
visitedTenantIds.add(parentId);
parentId = tenantById.get(parentId)?.parentId ?? "";
}
return false;
}
export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const rootTenantId = hanmacFamilyTenantId?.trim() ?? "";
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
return tenants.filter(
(tenant) =>
!isSystemTenant(tenant) &&
!isInTenantSubtree(tenant, rootTenantId, tenantById),
);
}
export function buildOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const params = new URLSearchParams({
mode: "single",
select: "tenant",
width: "400",
height: "600",
});
const tenantId = options.tenantId?.trim();
if (tenantId) {
params.set("tenantId", tenantId);
}
return `${normalizedBase}/embed/picker?${params.toString()}`;
}
export function buildAuthenticatedOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
const params = new URLSearchParams({
auto: "1",
returnTo: pickerUrl,
});
return `${normalizedBase}/login?${params.toString()}`;
}
export function parseOrgChartTenantSelection(
message: unknown,
): OrgChartTenantSelection | null {
const data = message as OrgChartPickerMessage;
if (data?.type !== "orgfront:picker:confirm") {
return null;
}
const selection = data.payload?.selections?.[0];
if (
selection?.type !== "tenant" ||
typeof selection.id !== "string" ||
typeof selection.name !== "string" ||
selection.id.trim() === ""
) {
return null;
}
return {
id: selection.id,
name: selection.name,
};
}