forked from baron/baron-sso
feat(org): 조직도 렌더링 및 동기화 로직 대폭 개선, 역할(Role) 강제화, 탭 정리
- 조직도 렌더링 시 너비 동적 계산 및 스크롤 문제 해결 - 하위 조직(Leaf)을 부모 박스 내부에 임베딩하여 2열로 깔끔하게 표시되도록 조직도 UI 전면 개편 - 사용자 생성/수정 및 CSV 업로드 시 직급(Position)과 직무(JobTitle)가 정상적으로 Kratos 및 로컬 DB에 동기화되도록 백엔드 API 수정 - CSV 조직도 업로드 시 계층 구분을 '/' 대신 ' > '로 변경하여 이름에 '/'가 포함된 부서(예: 평면/셀)가 분리되지 않도록 보호 - 잘못 입력된 과거 직책 데이터(팀장, 그룹장 등)를 'user' 권한으로 일괄 초기화하고, 이후 'role' 필드에 시스템 권한(user, tenant_admin, super_admin) 외의 값이 들어오지 않도록 백엔드 정규화 로직 강화 - 사용자 목록 페이지의 페이지네이션 제한을 50명에서 1000명으로 상향 조정 - 테넌트 목록 페이지에 이름/슬러그 기반 검색 기능 추가 - 관리자 UI 전반에서 불필요한 배지(Admin only, System 등) 제거 및 테넌트 상세 페이지의 미사용 '외부 연동' 탭 삭제
This commit is contained in:
@@ -98,17 +98,16 @@ function ApiKeyListPage() {
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")}
|
||||
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.registry.count",
|
||||
"총 {{count}}개 API 키",
|
||||
{ count: query.data?.total ?? 0 },
|
||||
"msg.admin.apikeys.registry.count",
|
||||
"총 {{count}}개의 활성 키가 등록되어 있습니다.",
|
||||
{ count: query.data?.items?.length ?? 0 },
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
{(errorMsg || fallbackError) && (
|
||||
|
||||
@@ -191,17 +191,14 @@ function AuditLogsPage() {
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.audit.registry.title", "Audit registry")}
|
||||
{t("ui.admin.audit.registry.title", "Log Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.audit.registry.count", "로드된 로그 {{count}}건", {
|
||||
count: logs.length,
|
||||
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: data?.pages[0]?.total ?? 0,
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.badge.command_only", "Command only")}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
|
||||
@@ -59,20 +59,17 @@ function TenantCreatePage() {
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.tenants.create.title", "테넌트 추가")}
|
||||
{t("ui.admin.tenants.create.title", "테넌트 생성")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.tenants.create.subtitle",
|
||||
"글로벌 운영 기준의 신규 테넌트를 등록합니다.",
|
||||
"새로운 테넌트를 시스템에 등록합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.badge.admin_only", "Admin only")}
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ function TenantDetailPage() {
|
||||
const canAccessSchema =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
|
||||
const isFederationTab = location.pathname.includes("/federation");
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
|
||||
@@ -43,9 +42,6 @@ function TenantDetailPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.admin_only", "관리자 전용")}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -53,7 +49,6 @@ function TenantDetailPage() {
|
||||
<Link
|
||||
to={`/tenants/${tenantId}`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
!isFederationTab &&
|
||||
!isPermissionsTab &&
|
||||
!location.pathname.includes("/schema") &&
|
||||
!isOrganizationTab
|
||||
@@ -63,16 +58,6 @@ function TenantDetailPage() {
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_profile", "프로필")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/federation`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
isFederationTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_federation", "외부 연동")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/permissions`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
CornerDownRight,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
@@ -14,6 +21,7 @@ import {
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -35,6 +43,7 @@ import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
@@ -109,7 +118,16 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenants = query.data?.items ?? [];
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const tenants = React.useMemo(() => {
|
||||
if (!search.trim()) return allTenants;
|
||||
const term = search.toLowerCase();
|
||||
return allTenants.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(term) ||
|
||||
t.slug.toLowerCase().includes(term),
|
||||
);
|
||||
}, [allTenants, search]);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
@@ -235,11 +253,23 @@ function TenantListPage() {
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.badge.admin_only", "Admin only")}
|
||||
</Badge>
|
||||
</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">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
)}
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
|
||||
{errorMsg ?? fallbackError}
|
||||
|
||||
@@ -42,10 +42,14 @@ export function TenantOrgChartPage() {
|
||||
return query.data.items
|
||||
.filter((u) => u.status === "active")
|
||||
.map((u) => {
|
||||
const parts = (u.department || "").split("/").filter(Boolean);
|
||||
const deptStr = u.department || "";
|
||||
const parts = deptStr.includes(" > ")
|
||||
? deptStr.split(" > ")
|
||||
: deptStr.split("/");
|
||||
|
||||
return {
|
||||
...u,
|
||||
_path: parts.map((name, i) => ({ level: i, name })),
|
||||
_path: parts.map((name, i) => ({ level: i, name: name.trim() })).filter(p => p.name),
|
||||
};
|
||||
});
|
||||
}, [query.data]);
|
||||
@@ -124,7 +128,10 @@ export function TenantOrgChartPage() {
|
||||
|
||||
if (pRect.width === 0 || cRect.width === 0) continue;
|
||||
|
||||
const parentLevel = Number.parseInt(parent.getAttribute("data-level") || "0", 10);
|
||||
const parentLevel = Number.parseInt(
|
||||
parent.getAttribute("data-level") || "0",
|
||||
10,
|
||||
);
|
||||
|
||||
if (parentLevel === 0) {
|
||||
// Horizontal fork for Level 0 -> Level 1
|
||||
@@ -136,7 +143,10 @@ export function TenantOrgChartPage() {
|
||||
|
||||
newLines.push({
|
||||
key: `${parentId}->${box.id}`,
|
||||
x1: px, y1: py, x2: cx, y2: cy,
|
||||
x1: px,
|
||||
y1: py,
|
||||
x2: cx,
|
||||
y2: cy,
|
||||
path: `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`,
|
||||
});
|
||||
} else {
|
||||
@@ -145,10 +155,13 @@ export function TenantOrgChartPage() {
|
||||
const py = pRect.bottom - rect.top + scrollTop;
|
||||
const cx = cRect.left - rect.left + scrollLeft; // Child's left edge
|
||||
const cy = cRect.top + 24 - rect.top + scrollTop; // Middle of child's header
|
||||
|
||||
|
||||
newLines.push({
|
||||
key: `${parentId}->${box.id}`,
|
||||
x1: spineX, y1: py, x2: cx, y2: cy,
|
||||
x1: spineX,
|
||||
y1: py,
|
||||
x2: cx,
|
||||
y2: cy,
|
||||
path: `M ${spineX} ${py} L ${spineX} ${cy} L ${cx} ${cy}`,
|
||||
});
|
||||
}
|
||||
@@ -228,9 +241,9 @@ export function TenantOrgChartPage() {
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 left-0 pointer-events-none z-0"
|
||||
style={{
|
||||
width: svgSize.width ? `${svgSize.width}px` : "100%",
|
||||
height: svgSize.height ? `${svgSize.height}px` : "100%"
|
||||
style={{
|
||||
width: svgSize.width ? `${svgSize.width}px` : "100%",
|
||||
height: svgSize.height ? `${svgSize.height}px` : "100%",
|
||||
}}
|
||||
>
|
||||
{lines.map((l) => (
|
||||
@@ -322,8 +335,13 @@ function OrgNodeView({
|
||||
const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical
|
||||
const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent
|
||||
|
||||
// 하위 조직이 모두 말단(Leaf) 조직일 경우, 부모 박스 내부에 회색 그룹으로 묶어서(임베딩) 표시합니다.
|
||||
const embedChildren = node.children.length > 0 && node.children.every(c => c.children.length === 0);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-3`}>
|
||||
<div
|
||||
className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-3`}
|
||||
>
|
||||
<div
|
||||
id={myId}
|
||||
data-parent={parentId || undefined}
|
||||
@@ -357,9 +375,34 @@ function OrgNodeView({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && embedChildren && (
|
||||
<div className="flex flex-col gap-2 p-2 pt-0 w-full">
|
||||
{node.children.map((child) => {
|
||||
const childMembers = [...child.members].sort(
|
||||
(a, b) => getRankWeight(a) - getRankWeight(b),
|
||||
);
|
||||
return (
|
||||
<div key={child.name} className="bg-slate-50 border border-slate-200 rounded-lg p-1.5 flex flex-col gap-1.5 w-full">
|
||||
<div className="text-[11px] font-bold text-slate-600 flex justify-between px-1">
|
||||
<span>{child.name}</span>
|
||||
<span className="text-slate-400 font-normal">({child.totalCount})</span>
|
||||
</div>
|
||||
{childMembers.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1 w-full">
|
||||
{childMembers.map((m) => (
|
||||
<MemberCard key={m.id} member={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && node.children.length > 0 && (
|
||||
{!collapsed && !embedChildren && node.children.length > 0 && (
|
||||
<div
|
||||
className={`flex ${
|
||||
isVerticalChildren
|
||||
@@ -391,9 +434,12 @@ function MemberCard({ member }: { member: UserWithPath }) {
|
||||
return "bg-slate-600 text-white border-slate-700";
|
||||
})();
|
||||
|
||||
const roleBadge = member.jobTitle && member.jobTitle !== member.position
|
||||
? member.jobTitle
|
||||
: (member.position?.endsWith("장") ? member.position : null);
|
||||
const roleBadge =
|
||||
member.jobTitle && member.jobTitle !== member.position
|
||||
? member.jobTitle
|
||||
: member.position?.endsWith("장")
|
||||
? member.position
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -401,9 +447,13 @@ function MemberCard({ member }: { member: UserWithPath }) {
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0 w-full">
|
||||
<div className="flex items-baseline gap-1 truncate shrink-0">
|
||||
<span className="font-bold text-[11px] whitespace-nowrap">{member.name}</span>
|
||||
<span className="font-bold text-[11px] whitespace-nowrap">
|
||||
{member.name}
|
||||
</span>
|
||||
{member.position && member.position !== roleBadge && (
|
||||
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">{member.position}</span>
|
||||
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">
|
||||
{member.position}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{roleBadge && (
|
||||
|
||||
@@ -251,12 +251,9 @@ export function UserGroupDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className="font-normal">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
|
||||
</Badge>
|
||||
<Badge variant="muted" className="font-normal">
|
||||
ID: {id?.split("-")[0]}...
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -74,15 +74,13 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "email,name,phone,role,tenant,department,position,jobTitle,employee_id";
|
||||
const headers =
|
||||
"email,name,phone,role,tenant,department,position,jobTitle,employee_id";
|
||||
const example =
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
|
||||
const blob = new Blob(
|
||||
[
|
||||
`${headers}\n${example}`,
|
||||
],
|
||||
{ type: "text/csv;charset=utf-8;" },
|
||||
);
|
||||
const blob = new Blob([`${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
|
||||
@@ -275,29 +275,32 @@ export interface ImportResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export async function fetchImportProgress(tenantId: string, progressId: string) {
|
||||
export async function fetchImportProgress(
|
||||
tenantId: string,
|
||||
progressId: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<{ current: number; total: number }>(
|
||||
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`
|
||||
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function importOrgChart(tenantId: string, file: File, progressId?: string) {
|
||||
export async function importOrgChart(
|
||||
tenantId: string,
|
||||
file: File,
|
||||
progressId?: string,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const url = progressId
|
||||
const url = progressId
|
||||
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
|
||||
: `/v1/admin/tenants/${tenantId}/organization/import`;
|
||||
|
||||
const { data } = await apiClient.post<{ data: ImportResult }>(
|
||||
url,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
);
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -200,13 +200,13 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
orgParts = append(orgParts, val)
|
||||
}
|
||||
}
|
||||
orgPath := strings.Join(orgParts, "/")
|
||||
orgPath := strings.Join(orgParts, " > ")
|
||||
|
||||
leafID := companyTenantID
|
||||
if orgPath != "" && orgPath != "-" {
|
||||
if len(orgParts) > 0 {
|
||||
// [Matrix Fix] Build hierarchy under the Root Tenant (tenantID), NOT the individual company.
|
||||
// This allows departments like '총괄기획실' to be shared across multiple companies without duplication.
|
||||
leafID, err = s.ensureOrgPath(ctx, tenantID, orgPath, pathCache, result)
|
||||
leafID, err = s.ensureOrgPath(ctx, tenantID, orgParts, pathCache, result)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Hierarchy fail: %v", rowIdx+2, err))
|
||||
continue
|
||||
@@ -435,8 +435,7 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name,
|
||||
return tenant.ID, nil
|
||||
}
|
||||
|
||||
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID, path string, cache map[string]string, res *ImportResult) (string, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, parts []string, cache map[string]string, res *ImportResult) (string, error) {
|
||||
currentParentID := rootTenantID
|
||||
currentPath := ""
|
||||
for i, part := range parts {
|
||||
|
||||
Reference in New Issue
Block a user