From 5211842d47023e44f593bcd8657d4cb560953f1f Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 10 Apr 2026 11:38:47 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A1=B0=EC=A7=81=EB=8F=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 2 + adminfront/src/components/ui/checkbox.tsx | 31 + .../components/OrgChartUploadModal.tsx | 281 +++++--- .../tenants/routes/TenantListPage.tsx | 105 ++- .../tenants/routes/TenantOrgChartPage.tsx | 417 ++++++++++++ .../src/features/users/UserListPage.tsx | 33 +- .../users/components/UserBulkUploadModal.tsx | 9 +- .../src/features/users/utils/csvParser.ts | 4 + adminfront/src/lib/adminApi.ts | 35 +- adminfront/src/locales/ko.toml | 5 +- backend/cmd/fix_kratos_roles.go | 53 ++ backend/cmd/server/main.go | 7 +- backend/go.mod | 26 +- backend/go.sum | 71 ++- backend/internal/domain/auth_models.go | 1 + backend/internal/domain/user.go | 17 +- backend/internal/handler/auth_handler.go | 15 +- backend/internal/handler/org_chart_handler.go | 30 +- backend/internal/handler/tenant_handler.go | 30 + backend/internal/handler/user_handler.go | 102 ++- .../internal/repository/tenant_repository.go | 29 + .../internal/repository/user_repository.go | 38 +- backend/internal/service/mock_common_test.go | 14 +- backend/internal/service/org_chart_service.go | 599 ++++++++++-------- .../service/org_chart_service_test.go | 239 +++++++ backend/internal/service/tenant_service.go | 90 ++- .../internal/service/tenant_service_test.go | 5 + .../service/user_group_service_test.go | 4 + 28 files changed, 1845 insertions(+), 447 deletions(-) create mode 100644 adminfront/src/components/ui/checkbox.tsx create mode 100644 adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx create mode 100644 backend/cmd/fix_kratos_roles.go create mode 100644 backend/internal/service/org_chart_service_test.go diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 4422e908..b00da29f 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -11,6 +11,7 @@ import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdmin import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; +import { TenantOrgChartPage } from "../features/tenants/routes/TenantOrgChartPage"; import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"; import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage"; import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab"; @@ -40,6 +41,7 @@ export const router = createBrowserRouter( { path: "users/new", element: }, { path: "users/:id", element: }, { path: "tenants", element: }, + { path: "tenants/org-chart", element: }, { path: "tenants/new", element: }, { path: "tenants/:tenantId", diff --git a/adminfront/src/components/ui/checkbox.tsx b/adminfront/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..8b489fee --- /dev/null +++ b/adminfront/src/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { cn } from "../../lib/utils"; + +export interface CheckboxProps + extends Omit, "onChange"> { + onCheckedChange?: (checked: boolean | "indeterminate") => void; +} + +const Checkbox = React.forwardRef( + ({ className, onCheckedChange, ...props }, ref) => { + const handleChange = (e: React.ChangeEvent) => { + onCheckedChange?.(e.target.checked); + }; + + return ( + + ); + }, +); +Checkbox.displayName = "Checkbox"; + +export { Checkbox }; diff --git a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx index 551c13dc..a11fca20 100644 --- a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx +++ b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx @@ -1,6 +1,13 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { Download, FileText, Loader2, Upload } from "lucide-react"; +import { + AlertCircle, + CheckCircle2, + Download, + FileText, + Loader2, + Upload, +} from "lucide-react"; import * as React from "react"; import { Button } from "../../../components/ui/button"; import { @@ -13,7 +20,11 @@ import { DialogTrigger, } from "../../../components/ui/dialog"; import { toast } from "../../../components/ui/use-toast"; -import { importOrgChart } from "../../../lib/adminApi"; +import { + type ImportResult, + fetchImportProgress, + importOrgChart, +} from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; interface OrgChartUploadModalProps { @@ -27,52 +38,76 @@ export function OrgChartUploadModal({ }: 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: File) => importOrgChart(tenantId, file), - onSuccess: () => { - toast.success( - t( - "msg.admin.org.import_success", - "조직도가 성공적으로 업로드되었습니다.", - ), - ); - setOpen(false); + 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: () => fetchImportProgress(tenantId, progressId!), + 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) { - mutation.mutate(file); + const pid = Math.random().toString(36).substring(2, 15); + setProgressId(pid); + mutation.mutate({ file, pid }); } }; const downloadTemplate = () => { - const headers = "email,name,organization,position,jobtitle,is_owner"; - const example = `ceo@example.com,홍길동,경영진,대표이사,경영총괄,true -cto@example.com,이몽룡,기술부문,이사,기술총괄,true -user1@example.com,성춘향,기술부문/개발팀,팀원,프론트엔드 개발,false`; - const blob = new Blob( - [ - `${headers} -${example}`, - ], - { type: "text/csv" }, - ); + 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; @@ -82,14 +117,23 @@ ${example}`, }; return ( - + { + setOpen(val); + if (!val) { + setFile(null); + setResult(null); + } + }} + > - + {t("ui.admin.org.import_title", "조직도 일괄 등록")} @@ -97,64 +141,151 @@ ${example}`, {t( "msg.admin.org.import_description", - "CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.", + "CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.", )} -
-
- - - + {!result ? ( +
+
+ + + +
+ {file && ( +
+
+ +
+
{file.name}
+
+ {(file.size / 1024).toFixed(1)} KB +
+
+
+ {mutation.isPending && progressId && ( +
+
+ 데이터 처리 중... + + {percent}% ({progressData?.current || 0} /{" "} + {progressData?.total || 0}) + +
+
+
+
+
+ )} +
+ )}
- - {file && ( -
- -
-
{file.name}
-
- {(file.size / 1024).toFixed(1)} KB + ) : ( +
+
+
+
+ 전체 행 +
+
{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/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 4b7b712c..e23ffe1c 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -13,7 +13,7 @@ import { CardHeader, CardTitle, } from "../../../components/ui/card"; -import { OrgChartUploadModal } from "../components/OrgChartUploadModal"; +import { Checkbox } from "../../../components/ui/checkbox"; import { Table, TableBody, @@ -25,13 +25,17 @@ import { import { type TenantSummary, deleteTenant, + deleteTenantsBulk, fetchMe, fetchTenants, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { OrgChartUploadModal } from "../components/OrgChartUploadModal"; function TenantListPage() { const navigate = useNavigate(); + const [selectedIds, setSelectedIds] = React.useState([]); + const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, @@ -41,7 +45,6 @@ function TenantListPage() { React.useEffect(() => { if (profile?.role === "tenant_admin") { const manageableCount = profile.manageableTenants?.length ?? 0; - // If only 1 in array, OR array is empty but we have a primary tenantId if ( (manageableCount === 1 || manageableCount === 0) && profile.tenantId @@ -67,6 +70,14 @@ function TenantListPage() { }, }); + const deleteBulkMutation = useMutation({ + mutationFn: (ids: string[]) => deleteTenantsBulk(ids), + onSuccess: () => { + setSelectedIds([]); + query.refetch(); + }, + }); + if ( profile && profile.role !== "super_admin" && @@ -84,7 +95,6 @@ function TenantListPage() { ); } - // While redirecting (only if exactly one manageable tenant) if ( profile?.role === "tenant_admin" && (profile.manageableTenants?.length ?? 0) <= 1 @@ -101,8 +111,40 @@ function TenantListPage() { const tenants = query.data?.items ?? []; - // [New] Find a primary COMPANY_GROUP tenant to act as the root for matrix org charts - const rootTenant = tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0]; + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedIds(tenants.map((t) => t.id)); + } else { + setSelectedIds([]); + } + }; + + const handleSelect = (id: string, checked: boolean) => { + if (checked) { + setSelectedIds((prev) => [...prev, id]); + } else { + setSelectedIds((prev) => prev.filter((i) => i !== id)); + } + }; + + const handleDeleteBulk = () => { + if (selectedIds.length === 0) return; + if ( + !window.confirm( + t( + "msg.admin.tenants.delete_bulk_confirm", + "선택한 {{count}}개 테넌트를 삭제할까요?", + { count: selectedIds.length }, + ), + ) + ) { + return; + } + deleteBulkMutation.mutate(selectedIds); + }; + + const rootTenant = + tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0]; const handleDelete = (tenantId: string, tenantName: string) => { if ( @@ -134,16 +176,34 @@ function TenantListPage() {

- {/* [New] Add Upload Modal to global list page, visible to Super Admin */} - {rootTenant && ( - query.refetch()} - /> + {selectedIds.length > 0 && ( + )} + + query.refetch()} + /> + + + +