forked from baron/baron-sso
Merge pull request 'adminfront 테스트 실패 해결 및 UI/Lint 수정' (#724) from feature/multi-tenant-and-ui-improvements into dev
Reviewed-on: baron/baron-sso#724
This commit is contained in:
@@ -18,13 +18,13 @@ import * as React from "react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||||
import { fetchMe } from "../../lib/adminApi";
|
import { fetchMe } from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import {
|
import {
|
||||||
shouldAttemptSlidingSessionRenew,
|
shouldAttemptSlidingSessionRenew,
|
||||||
shouldAttemptUnlimitedSessionRenew,
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
} from "../../lib/sessionSliding";
|
} from "../../lib/sessionSliding";
|
||||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,9 @@ function TenantGroupsPage() {
|
|||||||
// Modal States
|
// Modal States
|
||||||
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
||||||
const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false);
|
const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false);
|
||||||
const [memberActionTargetUserId, setMemberActionTargetUserId] = useState<string | null>(null);
|
const [memberActionTargetUserId, setMemberActionTargetUserId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||||
const [groupSearchTerm, setGroupSearchTerm] = useState("");
|
const [groupSearchTerm, setGroupSearchTerm] = useState("");
|
||||||
|
|
||||||
@@ -398,496 +400,504 @@ function TenantGroupsPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||||
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
||||||
{/* 그룹 생성 폼 */}
|
{/* 그룹 생성 폼 */}
|
||||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||||
<CardHeader className="flex-shrink-0">
|
<CardHeader className="flex-shrink-0">
|
||||||
<CardTitle className="text-sm flex items-center gap-2">
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
<Plus size={16} />{" "}
|
<Plus size={16} />{" "}
|
||||||
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t(
|
|
||||||
"ui.admin.groups.create.description",
|
|
||||||
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 flex-1 overflow-auto">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="name">
|
|
||||||
{t("ui.admin.groups.form.name_label", "그룹 이름")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={newGroupName}
|
|
||||||
onChange={(e) => setNewGroupName(e.target.value)}
|
|
||||||
placeholder={t(
|
|
||||||
"ui.admin.groups.form.name_placeholder",
|
|
||||||
"예: 개발팀, 인사팀",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="unitType">
|
|
||||||
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="unitType"
|
|
||||||
value={newGroupUnitType}
|
|
||||||
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
|
||||||
placeholder={t(
|
|
||||||
"ui.admin.groups.form.unit_level_placeholder",
|
|
||||||
"예: 본부, 팀, 셀",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="parentId">
|
|
||||||
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="parentId"
|
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
value={newGroupParentId || ""}
|
|
||||||
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
|
||||||
>
|
|
||||||
<option value="">{t("ui.common.none", "없음")}</option>
|
|
||||||
{groupsQuery.data?.map((group) => (
|
|
||||||
<option key={group.id} value={group.id}>
|
|
||||||
{group.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="desc">
|
|
||||||
{t("ui.admin.groups.form.desc_label", "설명")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="desc"
|
|
||||||
value={newGroupDesc}
|
|
||||||
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
|
||||||
placeholder={t(
|
|
||||||
"ui.admin.groups.form.desc_placeholder",
|
|
||||||
"그룹 용도 설명",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => createMutation.mutate()}
|
|
||||||
disabled={!newGroupName || createMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("ui.admin.groups.form.submit", "생성하기")}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 그룹 목록 (트리 뷰) */}
|
|
||||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
|
||||||
<div>
|
|
||||||
<CardTitle>
|
|
||||||
{t("ui.admin.groups.list.title", "User Groups")}
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.groups.list.subtitle",
|
"ui.admin.groups.create.description",
|
||||||
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
|
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardContent className="space-y-4 flex-1 overflow-auto">
|
||||||
<Button
|
<div className="space-y-1">
|
||||||
variant="ghost"
|
<Label htmlFor="name">
|
||||||
size="sm"
|
{t("ui.admin.groups.form.name_label", "그룹 이름")}
|
||||||
onClick={() => groupsQuery.refetch()}
|
</Label>
|
||||||
>
|
<Input
|
||||||
<RefreshCw size={14} />
|
id="name"
|
||||||
</Button>
|
value={newGroupName}
|
||||||
</div>
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
</CardHeader>
|
placeholder={t(
|
||||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
"ui.admin.groups.form.name_placeholder",
|
||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
"예: 개발팀, 인사팀",
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
)}
|
||||||
<Table>
|
/>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.groups.table.name", "NAME")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.groups.table.members", "MEMBERS")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{groupsQuery.isLoading && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={3}>
|
|
||||||
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={3}
|
|
||||||
className="text-center py-8 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"msg.admin.groups.list.empty",
|
|
||||||
"아직 등록된 그룹이 없습니다.",
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{groupTree.map((node) => (
|
|
||||||
<UserGroupTreeNode
|
|
||||||
key={node.id}
|
|
||||||
node={node}
|
|
||||||
level={0}
|
|
||||||
onSelect={setSelectedGroupId}
|
|
||||||
selectedGroupId={selectedGroupId}
|
|
||||||
onDelete={(id) => {
|
|
||||||
if (
|
|
||||||
window.confirm(
|
|
||||||
t(
|
|
||||||
"msg.admin.groups.list.delete_confirm",
|
|
||||||
"그룹을 삭제하시겠습니까?",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
deleteMutation.mutate(id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onAddSubGroup={handleAddSubGroup}
|
|
||||||
addMemberMutation={addMemberMutation}
|
|
||||||
removeMemberMutation={removeMemberMutation}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
</CardContent>
|
<Label htmlFor="unitType">
|
||||||
</Card>
|
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="unitType"
|
||||||
|
value={newGroupUnitType}
|
||||||
|
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.groups.form.unit_level_placeholder",
|
||||||
|
"예: 본부, 팀, 셀",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="parentId">
|
||||||
|
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="parentId"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
value={newGroupParentId || ""}
|
||||||
|
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">{t("ui.common.none", "없음")}</option>
|
||||||
|
{groupsQuery.data?.map((group) => (
|
||||||
|
<option key={group.id} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="desc">
|
||||||
|
{t("ui.admin.groups.form.desc_label", "설명")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="desc"
|
||||||
|
value={newGroupDesc}
|
||||||
|
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.groups.form.desc_placeholder",
|
||||||
|
"그룹 용도 설명",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={!newGroupName || createMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("ui.admin.groups.form.submit", "생성하기")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 그룹 목록 (트리 뷰) */}
|
||||||
|
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle>
|
||||||
|
{t("ui.admin.groups.list.title", "User Groups")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.admin.groups.list.subtitle",
|
||||||
|
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => groupsQuery.refetch()}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||||
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.groups.table.name", "NAME")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.groups.table.members", "MEMBERS")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupsQuery.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3}>
|
||||||
|
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"msg.admin.groups.list.empty",
|
||||||
|
"아직 등록된 그룹이 없습니다.",
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{groupTree.map((node) => (
|
||||||
|
<UserGroupTreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
level={0}
|
||||||
|
onSelect={setSelectedGroupId}
|
||||||
|
selectedGroupId={selectedGroupId}
|
||||||
|
onDelete={(id) => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.admin.groups.list.delete_confirm",
|
||||||
|
"그룹을 삭제하시겠습니까?",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
deleteMutation.mutate(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAddSubGroup={handleAddSubGroup}
|
||||||
|
addMemberMutation={addMemberMutation}
|
||||||
|
removeMemberMutation={removeMemberMutation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
|
||||||
|
{currentGroup && (
|
||||||
|
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||||
|
<CardHeader className="flex-shrink-0">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield size={18} className="text-primary" />
|
||||||
|
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
|
||||||
|
name: currentGroup.name,
|
||||||
|
})}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"ui.admin.groups.detail.members_subtitle",
|
||||||
|
"그룹에 속한 멤버들을 확인하고 관리합니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||||
|
<div className="flex justify-end mb-4 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsAddMemberModalOpen(true)}
|
||||||
|
disabled={addMemberMutation.isPending}
|
||||||
|
>
|
||||||
|
<UserPlus size={14} className="mr-1" />
|
||||||
|
{t("ui.common.add", "멤버 추가")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.groups.members.table.name", "이름")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("ui.admin.groups.members.table.actions", "관리")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{currentGroup.members?.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="text-center py-4 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"msg.admin.groups.members.empty",
|
||||||
|
"멤버가 없습니다.",
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{currentGroup.members?.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{user.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setMemberActionTargetUserId(user.id);
|
||||||
|
setIsMoveMemberModalOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={moveMemberMutation.isPending}
|
||||||
|
title={t("ui.common.move", "이동")}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft
|
||||||
|
size={14}
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.admin.groups.members.remove_confirm",
|
||||||
|
"'{{name}}' 님을 이 그룹에서 제외하시겠습니까?",
|
||||||
|
{ name: user.name },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
removeMemberMutation.mutate({
|
||||||
|
groupId: currentGroup.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={removeMemberMutation.isPending}
|
||||||
|
title={t("ui.common.remove", "제거")}
|
||||||
|
>
|
||||||
|
<UserMinus
|
||||||
|
size={14}
|
||||||
|
className="text-destructive"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
|
{/* Add Member Modal */}
|
||||||
{currentGroup && (
|
<Dialog
|
||||||
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
|
open={isAddMemberModalOpen}
|
||||||
<CardHeader className="flex-shrink-0">
|
onOpenChange={(val) => {
|
||||||
<CardTitle className="flex items-center gap-2">
|
setIsAddMemberModalOpen(val);
|
||||||
<Shield size={18} className="text-primary" />
|
if (!val) setUserSearchTerm("");
|
||||||
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
|
}}
|
||||||
name: currentGroup.name,
|
>
|
||||||
})}
|
<DialogContent className="max-w-md">
|
||||||
</CardTitle>
|
<DialogHeader>
|
||||||
<CardDescription>
|
|
||||||
{t(
|
|
||||||
"ui.admin.groups.detail.members_subtitle",
|
|
||||||
"그룹에 속한 멤버들을 확인하고 관리합니다.",
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
|
||||||
<div className="flex justify-end mb-4 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsAddMemberModalOpen(true)}
|
|
||||||
disabled={addMemberMutation.isPending}
|
|
||||||
>
|
|
||||||
<UserPlus size={14} className="mr-1" />
|
|
||||||
{t("ui.common.add", "멤버 추가")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
|
||||||
<Table>
|
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.groups.members.table.name", "이름")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.groups.members.table.email", "이메일")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("ui.admin.groups.members.table.actions", "관리")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{currentGroup.members?.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={3}
|
|
||||||
className="text-center py-4 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"msg.admin.groups.members.empty",
|
|
||||||
"멤버가 없습니다.",
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{currentGroup.members?.map((user) => (
|
|
||||||
<TableRow key={user.id}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{user.name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{user.email}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setMemberActionTargetUserId(user.id);
|
|
||||||
setIsMoveMemberModalOpen(true);
|
|
||||||
}}
|
|
||||||
disabled={moveMemberMutation.isPending}
|
|
||||||
title={t("ui.common.move", "이동")}
|
|
||||||
>
|
|
||||||
<ArrowRightLeft size={14} className="text-primary" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
window.confirm(
|
|
||||||
t(
|
|
||||||
"msg.admin.groups.members.remove_confirm",
|
|
||||||
"'{{name}}' 님을 이 그룹에서 제외하시겠습니까?",
|
|
||||||
{ name: user.name },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
removeMemberMutation.mutate({
|
|
||||||
groupId: currentGroup.id,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={removeMemberMutation.isPending}
|
|
||||||
title={t("ui.common.remove", "제거")}
|
|
||||||
>
|
|
||||||
<UserMinus size={14} className="text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Member Modal */}
|
|
||||||
<Dialog
|
|
||||||
open={isAddMemberModalOpen}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setIsAddMemberModalOpen(val);
|
|
||||||
if (!val) setUserSearchTerm("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
|
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.groups.members.add_modal_desc",
|
"msg.admin.groups.members.add_modal_desc",
|
||||||
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
|
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("ui.common.search", "검색...")}
|
placeholder={t("ui.common.search", "검색...")}
|
||||||
className="pl-9 h-9"
|
className="pl-9 h-9"
|
||||||
value={userSearchTerm}
|
value={userSearchTerm}
|
||||||
onChange={(e) => setUserSearchTerm(e.target.value)}
|
onChange={(e) => setUserSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="h-[250px] rounded-md border p-2">
|
<ScrollArea className="h-[250px] rounded-md border p-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{usersQuery.isLoading ? (
|
{usersQuery.isLoading ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
||||||
{t("ui.common.loading", "로딩 중...")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
users
|
|
||||||
.filter((u) => {
|
|
||||||
const term = userSearchTerm.toLowerCase();
|
|
||||||
return (
|
|
||||||
u.name.toLowerCase().includes(term) ||
|
|
||||||
u.email.toLowerCase().includes(term)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
(u) =>
|
|
||||||
!currentGroup?.members?.some((m) => m.id === u.id),
|
|
||||||
) // Exclude existing members
|
|
||||||
.map((user) => (
|
|
||||||
<div
|
|
||||||
key={user.id}
|
|
||||||
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{user.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{user.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
if (currentGroup) {
|
|
||||||
addMemberMutation.mutate({
|
|
||||||
groupId: currentGroup.id,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={addMemberMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("ui.common.add", "추가")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{users.length > 0 &&
|
|
||||||
users.filter(
|
|
||||||
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
|
|
||||||
).length === 0 && (
|
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
{t("msg.admin.groups.members.all_added", "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.")}
|
{t("ui.common.loading", "로딩 중...")}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
users
|
||||||
|
.filter((u) => {
|
||||||
|
const term = userSearchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
u.name.toLowerCase().includes(term) ||
|
||||||
|
u.email.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
|
||||||
|
) // Exclude existing members
|
||||||
|
.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentGroup) {
|
||||||
|
addMemberMutation.mutate({
|
||||||
|
groupId: currentGroup.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={addMemberMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("ui.common.add", "추가")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
{users.length > 0 &&
|
||||||
|
users.filter(
|
||||||
|
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
|
||||||
|
).length === 0 && (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.groups.members.all_added",
|
||||||
|
"모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsAddMemberModalOpen(false)}
|
onClick={() => setIsAddMemberModalOpen(false)}
|
||||||
>
|
>
|
||||||
{t("ui.common.close", "닫기")}
|
{t("ui.common.close", "닫기")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Move Member Modal */}
|
{/* Move Member Modal */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isMoveMemberModalOpen}
|
open={isMoveMemberModalOpen}
|
||||||
onOpenChange={(val) => {
|
onOpenChange={(val) => {
|
||||||
setIsMoveMemberModalOpen(val);
|
setIsMoveMemberModalOpen(val);
|
||||||
if (!val) {
|
if (!val) {
|
||||||
setMemberActionTargetUserId(null);
|
setMemberActionTargetUserId(null);
|
||||||
setGroupSearchTerm("");
|
setGroupSearchTerm("");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
|
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.groups.members.move_modal_desc",
|
"msg.admin.groups.members.move_modal_desc",
|
||||||
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
|
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("ui.common.search_group", "그룹 검색...")}
|
placeholder={t("ui.common.search_group", "그룹 검색...")}
|
||||||
className="pl-9 h-9"
|
className="pl-9 h-9"
|
||||||
value={groupSearchTerm}
|
value={groupSearchTerm}
|
||||||
onChange={(e) => setGroupSearchTerm(e.target.value)}
|
onChange={(e) => setGroupSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="h-[250px] rounded-md border p-2">
|
<ScrollArea className="h-[250px] rounded-md border p-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{groupsQuery.isLoading ? (
|
{groupsQuery.isLoading ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
{t("ui.common.loading", "로딩 중...")}
|
{t("ui.common.loading", "로딩 중...")}
|
||||||
</div>
|
</div>
|
||||||
) : groupsQuery.data && groupsQuery.data.length > 0 ? (
|
) : groupsQuery.data && groupsQuery.data.length > 0 ? (
|
||||||
groupsQuery.data
|
groupsQuery.data
|
||||||
.filter((g) =>
|
.filter((g) =>
|
||||||
g.name
|
g.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(groupSearchTerm.toLowerCase()),
|
.includes(groupSearchTerm.toLowerCase()),
|
||||||
)
|
)
|
||||||
.filter((g) => g.id !== currentGroup?.id) // Exclude current group
|
.filter((g) => g.id !== currentGroup?.id) // Exclude current group
|
||||||
.map((group) => (
|
.map((group) => (
|
||||||
<div
|
<div
|
||||||
key={group.id}
|
key={group.id}
|
||||||
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users size={14} className="text-muted-foreground" />
|
|
||||||
<span className="font-medium">{group.name}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
if (currentGroup && memberActionTargetUserId) {
|
|
||||||
moveMemberMutation.mutate({
|
|
||||||
sourceGroupId: currentGroup.id,
|
|
||||||
targetGroupId: group.id,
|
|
||||||
userId: memberActionTargetUserId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={moveMemberMutation.isPending}
|
|
||||||
>
|
>
|
||||||
{t("ui.common.move", "이동")}
|
<div className="flex items-center gap-2">
|
||||||
</Button>
|
<Users size={14} className="text-muted-foreground" />
|
||||||
</div>
|
<span className="font-medium">{group.name}</span>
|
||||||
))
|
</div>
|
||||||
) : (
|
<Button
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
size="sm"
|
||||||
{t("msg.admin.groups.list.no_results", "그룹이 없습니다.")}
|
variant="outline"
|
||||||
</div>
|
onClick={() => {
|
||||||
)}
|
if (currentGroup && memberActionTargetUserId) {
|
||||||
</div>
|
moveMemberMutation.mutate({
|
||||||
|
sourceGroupId: currentGroup.id,
|
||||||
|
targetGroupId: group.id,
|
||||||
|
userId: memberActionTargetUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={moveMemberMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("ui.common.move", "이동")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("msg.admin.groups.list.no_results", "그룹이 없습니다.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsMoveMemberModalOpen(false)}
|
onClick={() => setIsMoveMemberModalOpen(false)}
|
||||||
>
|
>
|
||||||
{t("ui.common.cancel", "취소")}
|
{t("ui.common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TenantGroupsPage;
|
export default TenantGroupsPage;
|
||||||
|
|||||||
@@ -633,7 +633,7 @@ function TenantListPage() {
|
|||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
|
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||||
onClick={() => requestSort("id")}
|
onClick={() => requestSort("id")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -642,7 +642,7 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||||
onClick={() => requestSort("name")}
|
onClick={() => requestSort("name")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -651,7 +651,7 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||||
onClick={() => requestSort("type")}
|
onClick={() => requestSort("type")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -660,7 +660,7 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||||
onClick={() => requestSort("slug")}
|
onClick={() => requestSort("slug")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -669,7 +669,7 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||||
onClick={() => requestSort("status")}
|
onClick={() => requestSort("status")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -678,7 +678,7 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||||
onClick={() => requestSort("recursiveMemberCount")}
|
onClick={() => requestSort("recursiveMemberCount")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -687,7 +687,7 @@ function TenantListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
className="cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap"
|
||||||
onClick={() => requestSort("updatedAt")}
|
onClick={() => requestSort("updatedAt")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -695,6 +695,9 @@ function TenantListPage() {
|
|||||||
{getSortIcon("updatedAt")}
|
{getSortIcon("updatedAt")}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">
|
||||||
|
{t("ui.common.actions", "액션")}
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -796,8 +799,24 @@ function TenantListPage() {
|
|||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
isSeedTenant(tenant) || deleteMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleDelete(tenant.id, tenant.name)
|
||||||
|
}
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} className="mr-2" />
|
||||||
|
{t("ui.common.delete", "삭제")}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}{" "}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Mail, MoreHorizontal, Plus, User, UserPlus, UserMinus, Loader2 } from "lucide-react";
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
MoreHorizontal,
|
||||||
|
Plus,
|
||||||
|
User,
|
||||||
|
UserMinus,
|
||||||
|
UserPlus,
|
||||||
|
} from "lucide-react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -52,18 +61,34 @@ function TenantUsersPage() {
|
|||||||
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
|
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
|
||||||
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
|
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t("msg.admin.tenants.members.remove_success", "조직에서 제외되었습니다."));
|
toast.success(
|
||||||
|
t(
|
||||||
|
"msg.admin.tenants.members.remove_success",
|
||||||
|
"조직에서 제외되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
usersQuery.refetch();
|
usersQuery.refetch();
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
toast.error(err.response?.data?.error || t("msg.admin.tenants.members.remove_error", "제외 실패"));
|
toast.error(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t("msg.admin.tenants.members.remove_error", "제외 실패"),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRemoveMember = (userId: string, userName: string) => {
|
const handleRemoveMember = (userId: string, userName: string) => {
|
||||||
if (!tenantSlug) return;
|
if (!tenantSlug) return;
|
||||||
if (window.confirm(t("msg.admin.tenants.members.remove_confirm", "'{{name}}'님을 이 조직에서 제외하시겠습니까?", { name: userName }))) {
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.admin.tenants.members.remove_confirm",
|
||||||
|
"'{{name}}'님을 이 조직에서 제외하시겠습니까?",
|
||||||
|
{ name: userName },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
removeTenantMutation.mutate({ userId, slug: tenantSlug });
|
removeTenantMutation.mutate({ userId, slug: tenantSlug });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -122,8 +147,13 @@ function TenantUsersPage() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center py-20">
|
<TableCell colSpan={5} className="text-center py-20">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Loader2 className="animate-spin text-muted-foreground" size={24} />
|
<Loader2
|
||||||
<span className="text-sm text-muted-foreground">{t("ui.common.loading", "Loading...")}</span>
|
className="animate-spin text-muted-foreground"
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("ui.common.loading", "Loading...")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -142,7 +172,9 @@ function TenantUsersPage() {
|
|||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id}>
|
||||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
<TableCell className="font-semibold">
|
||||||
|
{user.name}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<Mail size={12} className="text-muted-foreground" />
|
<Mail size={12} className="text-muted-foreground" />
|
||||||
@@ -159,7 +191,9 @@ function TenantUsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={user.status === "active" ? "default" : "muted"}
|
variant={
|
||||||
|
user.status === "active" ? "default" : "muted"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t(`ui.common.status.${user.status}`, user.status)}
|
{t(`ui.common.status.${user.status}`, user.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -167,7 +201,11 @@ function TenantUsersPage() {
|
|||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
<MoreHorizontal size={16} />
|
<MoreHorizontal size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -175,16 +213,24 @@ function TenantUsersPage() {
|
|||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`/users/${user.id}`}>
|
<Link to={`/users/${user.id}`}>
|
||||||
<User size={14} className="mr-2" />
|
<User size={14} className="mr-2" />
|
||||||
{t("ui.admin.tenants.members.view_profile", "상세 정보")}
|
{t(
|
||||||
|
"ui.admin.tenants.members.view_profile",
|
||||||
|
"상세 정보",
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={() => handleRemoveMember(user.id, user.name)}
|
onClick={() =>
|
||||||
|
handleRemoveMember(user.id, user.name)
|
||||||
|
}
|
||||||
disabled={removeTenantMutation.isPending}
|
disabled={removeTenantMutation.isPending}
|
||||||
>
|
>
|
||||||
<UserMinus size={14} className="mr-2" />
|
<UserMinus size={14} className="mr-2" />
|
||||||
{t("ui.admin.tenants.members.remove", "조직에서 제외")}
|
{t(
|
||||||
|
"ui.admin.tenants.members.remove",
|
||||||
|
"조직에서 제외",
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildWorksmobilePasswordManageUrl,
|
buildWorksmobilePasswordManageUrl,
|
||||||
canOpenWorksmobilePasswordManage,
|
|
||||||
canCreateWorksmobileRow,
|
canCreateWorksmobileRow,
|
||||||
|
canOpenWorksmobilePasswordManage,
|
||||||
canSelectWorksmobileRow,
|
canSelectWorksmobileRow,
|
||||||
filterWorksmobileComparisonRows,
|
filterWorksmobileComparisonRows,
|
||||||
formatWorksmobileOrgDetails,
|
formatWorksmobileOrgDetails,
|
||||||
formatWorksmobilePersonName,
|
formatWorksmobilePersonName,
|
||||||
getDefaultWorksmobileComparisonColumns,
|
getDefaultWorksmobileComparisonColumns,
|
||||||
|
getWorksmobileComparisonStatusLabel,
|
||||||
getWorksmobileRowSelectionKey,
|
getWorksmobileRowSelectionKey,
|
||||||
getWorksmobileSelectedActionIds,
|
getWorksmobileSelectedActionIds,
|
||||||
getWorksmobileComparisonStatusLabel,
|
|
||||||
isImmutableWorksmobileAccount,
|
isImmutableWorksmobileAccount,
|
||||||
summarizeWorksmobileComparison,
|
summarizeWorksmobileComparison,
|
||||||
userFilterOptions,
|
userFilterOptions,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { TenantSummary } from "../../../lib/adminApi";
|
|
||||||
import { parseTenantCSV } from "./tenantCsvImport";
|
|
||||||
// Vite ?raw import는 seed CSV를 빌드 타임 상수로 번들합니다.
|
// Vite ?raw import는 seed CSV를 빌드 타임 상수로 번들합니다.
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
|
import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
|
||||||
|
import type { TenantSummary } from "../../../lib/adminApi";
|
||||||
|
import { parseTenantCSV } from "./tenantCsvImport";
|
||||||
|
|
||||||
const seedTenantSlugs = new Set(
|
const seedTenantSlugs = new Set(
|
||||||
parseTenantCSV(seedTenantCSVRaw)
|
parseTenantCSV(seedTenantCSVRaw)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
|
import { Switch } from "../../components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -289,9 +290,15 @@ function UserCreatePage() {
|
|||||||
patch: Partial<UserAppointment>,
|
patch: Partial<UserAppointment>,
|
||||||
) => {
|
) => {
|
||||||
setAdditionalAppointments((current) =>
|
setAdditionalAppointments((current) =>
|
||||||
current.map((appointment, currentIndex) =>
|
current.map((appointment, currentIndex) => {
|
||||||
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
if (currentIndex === index) {
|
||||||
),
|
return { ...appointment, ...patch };
|
||||||
|
}
|
||||||
|
if (patch.isOwner === true) {
|
||||||
|
return { ...appointment, isOwner: false };
|
||||||
|
}
|
||||||
|
return appointment;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -346,7 +353,7 @@ function UserCreatePage() {
|
|||||||
setGeneratedPassword(null);
|
setGeneratedPassword(null);
|
||||||
setCreatedEmail(null);
|
setCreatedEmail(null);
|
||||||
|
|
||||||
const metadata = {
|
const metadata: Record<string, unknown> = {
|
||||||
...(data.metadata ?? {}),
|
...(data.metadata ?? {}),
|
||||||
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
||||||
userType,
|
userType,
|
||||||
@@ -402,6 +409,7 @@ function UserCreatePage() {
|
|||||||
tenantId: appointment.tenantId,
|
tenantId: appointment.tenantId,
|
||||||
tenantSlug: appointment.tenantSlug,
|
tenantSlug: appointment.tenantSlug,
|
||||||
tenantName: appointment.tenantName,
|
tenantName: appointment.tenantName,
|
||||||
|
isPrimary: appointment.isOwner,
|
||||||
isOwner: appointment.isOwner,
|
isOwner: appointment.isOwner,
|
||||||
jobTitle: appointment.jobTitle,
|
jobTitle: appointment.jobTitle,
|
||||||
position: appointment.position,
|
position: appointment.position,
|
||||||
@@ -417,6 +425,14 @@ function UserCreatePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const primary = appointments.find((a) => a.isOwner);
|
||||||
|
if (primary) {
|
||||||
|
metadata.primaryTenantId = primary.tenantId;
|
||||||
|
metadata.primaryTenantSlug = primary.tenantSlug;
|
||||||
|
metadata.primaryTenantName = primary.tenantName;
|
||||||
|
metadata.primaryTenantIsOwner = true;
|
||||||
|
}
|
||||||
|
|
||||||
payload.additionalAppointments = appointments;
|
payload.additionalAppointments = appointments;
|
||||||
payload.metadata = {
|
payload.metadata = {
|
||||||
...metadata,
|
...metadata,
|
||||||
@@ -741,15 +757,22 @@ function UserCreatePage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-3 text-sm">
|
<label className="flex items-center gap-3 text-sm">
|
||||||
<Checkbox
|
<Switch
|
||||||
checked={appointment.isOwner}
|
checked={appointment.isOwner}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateAppointment(index, {
|
updateAppointment(index, {
|
||||||
isOwner: checked === true,
|
isOwner: checked === true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.form.appointment_owner",
|
||||||
|
"대표 조직",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
조직장
|
{t(
|
||||||
|
"ui.admin.users.detail.form.appointment_owner",
|
||||||
|
"대표 조직",
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -536,9 +536,15 @@ function UserDetailPage() {
|
|||||||
patch: Partial<UserAppointment>,
|
patch: Partial<UserAppointment>,
|
||||||
) => {
|
) => {
|
||||||
setAdditionalAppointments((current) =>
|
setAdditionalAppointments((current) =>
|
||||||
current.map((appointment, currentIndex) =>
|
current.map((appointment, currentIndex) => {
|
||||||
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
if (currentIndex === index) {
|
||||||
),
|
return { ...appointment, ...patch };
|
||||||
|
}
|
||||||
|
if (patch.isOwner === true) {
|
||||||
|
return { ...appointment, isOwner: false };
|
||||||
|
}
|
||||||
|
return appointment;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -748,12 +754,26 @@ function UserDetailPage() {
|
|||||||
tenantId: appointment.tenantId,
|
tenantId: appointment.tenantId,
|
||||||
tenantSlug: appointment.tenantSlug,
|
tenantSlug: appointment.tenantSlug,
|
||||||
tenantName: appointment.tenantName,
|
tenantName: appointment.tenantName,
|
||||||
|
isPrimary: appointment.isOwner,
|
||||||
isOwner: appointment.isOwner,
|
isOwner: appointment.isOwner,
|
||||||
jobTitle: appointment.jobTitle,
|
jobTitle: appointment.jobTitle,
|
||||||
position: appointment.position,
|
position: appointment.position,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
payload.tenantSlug = undefined;
|
const primary = appointments.find((a) => a.isOwner);
|
||||||
|
if (primary) {
|
||||||
|
payload.tenantSlug = primary.tenantSlug;
|
||||||
|
payload.primaryTenantId = primary.tenantId;
|
||||||
|
payload.primaryTenantName = primary.tenantName;
|
||||||
|
payload.primaryTenantIsOwner = true;
|
||||||
|
metadata.primaryTenantId = primary.tenantId;
|
||||||
|
metadata.primaryTenantSlug = primary.tenantSlug;
|
||||||
|
metadata.primaryTenantName = primary.tenantName;
|
||||||
|
metadata.primaryTenantIsOwner = true;
|
||||||
|
} else {
|
||||||
|
payload.tenantSlug = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
payload.department = undefined;
|
payload.department = undefined;
|
||||||
payload.position = undefined;
|
payload.position = undefined;
|
||||||
payload.jobTitle = undefined;
|
payload.jobTitle = undefined;
|
||||||
@@ -1161,17 +1181,22 @@ function UserDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-3 text-sm">
|
<label className="flex items-center gap-3 text-sm">
|
||||||
<Checkbox
|
<Switch
|
||||||
checked={appointment.isOwner}
|
checked={appointment.isOwner}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateAppointment(index, {
|
updateAppointment(index, {
|
||||||
isOwner: checked === true,
|
isOwner: checked === true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
disabled={appointment.isPrimary}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.form.appointment_owner",
|
||||||
|
"대표 조직",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.form.appointment_owner",
|
"ui.admin.users.detail.form.appointment_owner",
|
||||||
"조직장",
|
"대표 조직",
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ import {
|
|||||||
type TenantImportPreviewRow,
|
type TenantImportPreviewRow,
|
||||||
buildTenantImportPreview,
|
buildTenantImportPreview,
|
||||||
} from "../../tenants/utils/tenantCsvImport";
|
} from "../../tenants/utils/tenantCsvImport";
|
||||||
|
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
||||||
import { parseUserCSV } from "../utils/csvParser";
|
import { parseUserCSV } from "../utils/csvParser";
|
||||||
import {
|
import {
|
||||||
type HanmacImportEmailPreview,
|
type HanmacImportEmailPreview,
|
||||||
buildHanmacImportEmailPreview,
|
buildHanmacImportEmailPreview,
|
||||||
} from "../utils/hanmacImportEmail";
|
} from "../utils/hanmacImportEmail";
|
||||||
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
|
||||||
|
|
||||||
interface UserBulkUploadModalProps {
|
interface UserBulkUploadModalProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
|||||||
@@ -462,8 +462,7 @@ test.describe("User Management", () => {
|
|||||||
"John Doe john@test.com 010-1111-2222",
|
"John Doe john@test.com 010-1111-2222",
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.getByTestId("user-status-select-u-1").click();
|
await page.getByTestId("user-status-toggle-u-1").click();
|
||||||
await page.getByRole("option", { name: /비활성|inactive/i }).click();
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => updatePayload)
|
.poll(() => updatePayload)
|
||||||
.toMatchObject({ status: "inactive" });
|
.toMatchObject({ status: "inactive" });
|
||||||
@@ -567,7 +566,7 @@ test.describe("User Management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByText("기술기획")).toBeVisible();
|
await expect(page.getByText("기술기획")).toBeVisible();
|
||||||
await page.getByLabel(/조직장/i).check();
|
await page.getByRole("switch", { name: /대표 조직/i }).click();
|
||||||
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
|
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
|
||||||
await page.getByLabel(/^직급$/i).fill("책임");
|
await page.getByLabel(/^직급$/i).fill("책임");
|
||||||
|
|
||||||
@@ -767,7 +766,7 @@ test.describe("User Management", () => {
|
|||||||
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||||
tenantSlug: "tech-planning",
|
tenantSlug: "tech-planning",
|
||||||
tenantName: "기술기획",
|
tenantName: "기술기획",
|
||||||
isOwner: false,
|
isOwner: true,
|
||||||
jobTitle: "플랫폼 운영",
|
jobTitle: "플랫폼 운영",
|
||||||
position: "책임",
|
position: "책임",
|
||||||
},
|
},
|
||||||
@@ -775,7 +774,7 @@ test.describe("User Management", () => {
|
|||||||
tenantId: "hanmac-team-id",
|
tenantId: "hanmac-team-id",
|
||||||
tenantSlug: "hanmac-team",
|
tenantSlug: "hanmac-team",
|
||||||
tenantName: "한맥팀",
|
tenantName: "한맥팀",
|
||||||
isOwner: true,
|
isOwner: false,
|
||||||
jobTitle: "개발",
|
jobTitle: "개발",
|
||||||
position: "선임",
|
position: "선임",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
const liveE2E = process.env.LIVE_BACKEND_E2E === "1";
|
const liveE2E = process.env.LIVE_BACKEND_E2E === "1";
|
||||||
const oidcAuthority = "https://sso.hmac.kr/oidc";
|
const oidcAuthority = "https://sso.hmac.kr/oidc";
|
||||||
|
|||||||
76
docs/frontend-monorepo-masterplan.md
Normal file
76
docs/frontend-monorepo-masterplan.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# [Master Plan] 프론트엔드 모노레포 통합 및 공용 모듈 마이그레이션
|
||||||
|
|
||||||
|
## 1. 개요 (Overview)
|
||||||
|
현재 `adminfront`, `devfront`, `orgfront` 세 개의 프론트엔드 프로젝트는 동일한 기술 스택을 사용함에도 불구하고 코드가 각자 복제되어 관리되고 있습니다. 이를 **NPM Workspaces 기반의 모노레포** 구조로 개편하여 중복을 제거하고, 기술적 일관성을 확보하는 것이 본 설계의 목적입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 아키텍처 설계 (Architecture)
|
||||||
|
|
||||||
|
### 2.1. 디렉토리 구조
|
||||||
|
프로젝트 루트를 Workspace로 설정하고, 모든 앱과 공용 모듈을 `packages/` 하위로 통합 관리합니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
baron-sso/
|
||||||
|
├── package.json # [New] 루트 레벨 Workspace 및 의존성 관리
|
||||||
|
├── packages/ # 재사용 가능한 내부 패키지
|
||||||
|
│ ├── shared-ui/ # Shadcn UI, 제네릭 AppLayout, 브랜드 자산(로고/아이콘)
|
||||||
|
│ ├── shared-utils/ # apiClient 팩토리, i18n 엔진, 공용 로케일(사전 병합)
|
||||||
|
│ ├── shared-auth/ # OIDC 설정, AuthGuard 본체, 세션 관리(Sliding Session)
|
||||||
|
│ ├── shared-types/ # 도메인 모델 및 API TypeScript 타입
|
||||||
|
│ └── shared-config/ # Tailwind, Biome, TSConfig 공통 설정
|
||||||
|
├── adminfront/ # Feature 중심 유지 + Shared 패키지 참조
|
||||||
|
├── devfront/ # App Shell 공유 + Shared 패키지 참조
|
||||||
|
└── orgfront/ # App Shell 공유 + Shared 패키지 참조
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2. 모노레포 도입의 이유 (Why Workspaces?)
|
||||||
|
1. **깔끔한 임포트**: `../../common` 같은 상대 경로 대신 `@shared/ui`와 같은 패키지 이름으로 참조 가능.
|
||||||
|
2. **의존성 단일화**: 모든 앱이 동일한 라이브러리 버전을 사용하여 런타임 에러 방지.
|
||||||
|
3. **설정 자동화**: Vite와 TypeScript가 로컬 패키지를 자동으로 인식하여 HMR 및 타입 체킹 지원.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 핵심 기술 설계 (Technical Governance)
|
||||||
|
|
||||||
|
### 3.1. 의존성 관리 (Single Version Policy)
|
||||||
|
- `react`, `react-router-dom`, `tailwindcss`, `@tanstack/react-query` 등 핵심 라이브러리 버전을 루트 `package.json`에서 고정 관리합니다.
|
||||||
|
- 각 패키지는 필요한 경우에만 별도 의존성을 가지며, 가능한 루트 버전을 따릅니다.
|
||||||
|
|
||||||
|
### 3.2. i18n 및 에셋 전략
|
||||||
|
- **동적 병합(Dictionary Merge)**: 공용 문구(Shared)를 먼저 로드하고, 앱별 특화 문구를 그 위에 덮어쓰는(Merge) 로직을 `shared-utils`에서 제공합니다.
|
||||||
|
- **자산 중앙화**: 브랜드 로고, 파비콘 등 공통 정적 자산은 `shared-ui/assets`에서 관리합니다.
|
||||||
|
|
||||||
|
### 3.3. 제네릭 AppLayout 엔진
|
||||||
|
- `AppLayout.tsx`는 더 이상 하드코딩된 메뉴를 갖지 않습니다.
|
||||||
|
- 메뉴(`navItems`), 브랜드 로고, 역할 스위처 표시 여부 등을 **설정 객체(Config)**로 주입받아 렌더링하는 범용 엔진으로 리팩토링합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 실행 로드맵 (Execution Roadmap)
|
||||||
|
|
||||||
|
### **[1단계] 인프라 구축 (Infrastructure)**
|
||||||
|
- 루트 `package.json` Workspace 설정.
|
||||||
|
- `shared-config` 패키지 생성 및 Tailwind/Biome 설정 중앙화.
|
||||||
|
- 각 앱에서 `@shared/config`를 참조하도록 설정 변경.
|
||||||
|
|
||||||
|
### **[2단계] 기초 모듈 추출 (Base Migration)**
|
||||||
|
- 중복도가 가장 높은 `components/ui/*`를 `shared-ui`로 이동.
|
||||||
|
- `apiClient`, `i18n`, `utils` 등 순수 유틸리티 코드를 `shared-utils`로 이동.
|
||||||
|
- 각 앱의 임포트 경로를 `@shared/*`로 일괄 치환.
|
||||||
|
|
||||||
|
### **[3단계] 플랫폼 레이어 통합 (Platform)**
|
||||||
|
- `AppLayout.tsx`를 제네릭하게 리팩토링하여 `shared-ui`로 이동.
|
||||||
|
- `AuthGuard`, `LoginPage`, `sessionSliding` 등 인증 관련 핵심 로직을 `shared-auth`로 통합.
|
||||||
|
|
||||||
|
### **[4단계] 앱별 적용 및 최적화 (Optimization)**
|
||||||
|
- `devfront`와 `orgfront`를 우선적으로 공용 레이아웃 기반으로 전환.
|
||||||
|
- `adminfront`는 비즈니스 로직(Feature)은 유지하되 기반 레이어만 교체.
|
||||||
|
- 빌드 테스트 및 트리쉐이킹(번들 최적화) 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 기대 효과 (Expected Benefits)
|
||||||
|
- **관리 비용 절감**: 공통 UI 수정 시 3개 앱에 동시 반영.
|
||||||
|
- **개발 가속화**: 신규 프론트엔드 프로젝트 시작 시 인프라 세팅 시간 단축.
|
||||||
|
- **기술적 일관성**: 모든 프론트엔드가 동일한 기술 표준과 디자인 시스템을 공유.
|
||||||
Reference in New Issue
Block a user