1
0
forked from baron/baron-sso

adminfront 테스트 실패 해결 및 UI/Lint 수정

- 테넌트 목록 테이블 헤더 스타일 보정(nowrap) 및 삭제 버튼 추가
- 초기 설정(Seed) 테넌트 삭제 보호 로직 적용
- 사용자 상태 변경 및 대표 조직 지정 UI를 Switch로 변경하여 테스트와 동기화
- Playwright 테스트 코드의 선택자 및 상호작용 로직 업데이트
- Biome을 통한 린트 오류 및 타입 안정성(AxiosError) 확보
- 프론트엔드 모노레포 통합 마스터 플랜 문서 추가
This commit is contained in:
2026-05-08 14:54:48 +09:00
parent ab66f13afd
commit 262c988226
12 changed files with 654 additions and 492 deletions

View File

@@ -18,13 +18,13 @@ import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import { fetchMe } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";

View File

@@ -250,7 +250,9 @@ function TenantGroupsPage() {
// Modal States
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = 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 [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="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">
<CardHeader className="flex-shrink-0">
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />{" "}
{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")}
{/* 그룹 생성 폼 */}
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader className="flex-shrink-0">
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
"ui.admin.groups.create.description",
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
)}
</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>
</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>
</CardContent>
</Card>
<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>
<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>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{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>
{/* Add Member Modal */}
<Dialog
open={isAddMemberModalOpen}
onOpenChange={(val) => {
setIsAddMemberModalOpen(val);
if (!val) setUserSearchTerm("");
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
{/* Add Member Modal */}
<Dialog
open={isAddMemberModalOpen}
onOpenChange={(val) => {
setIsAddMemberModalOpen(val);
if (!val) setUserSearchTerm("");
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.groups.members.add_modal_desc",
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
)}
{t(
"msg.admin.groups.members.add_modal_desc",
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "검색...")}
className="pl-9 h-9"
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
/>
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "검색...")}
className="pl-9 h-9"
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[250px] rounded-md border p-2">
<div className="space-y-1">
{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="space-y-1">
{usersQuery.isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("msg.admin.groups.members.all_added", "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.")}
{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>
))
)}
</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>
</div>
<DialogFooter>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddMemberModalOpen(false)}
variant="outline"
onClick={() => setIsAddMemberModalOpen(false)}
>
{t("ui.common.close", "닫기")}
{t("ui.common.close", "닫기")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Move Member Modal */}
<Dialog
open={isMoveMemberModalOpen}
onOpenChange={(val) => {
setIsMoveMemberModalOpen(val);
if (!val) {
{/* Move Member Modal */}
<Dialog
open={isMoveMemberModalOpen}
onOpenChange={(val) => {
setIsMoveMemberModalOpen(val);
if (!val) {
setMemberActionTargetUserId(null);
setGroupSearchTerm("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.groups.members.move_modal_desc",
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
)}
{t(
"msg.admin.groups.members.move_modal_desc",
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search_group", "그룹 검색...")}
className="pl-9 h-9"
value={groupSearchTerm}
onChange={(e) => setGroupSearchTerm(e.target.value)}
/>
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search_group", "그룹 검색...")}
className="pl-9 h-9"
value={groupSearchTerm}
onChange={(e) => setGroupSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[250px] rounded-md border p-2">
<div className="space-y-1">
{groupsQuery.isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : groupsQuery.data && groupsQuery.data.length > 0 ? (
groupsQuery.data
.filter((g) =>
g.name
.toLowerCase()
.includes(groupSearchTerm.toLowerCase()),
)
.filter((g) => g.id !== currentGroup?.id) // Exclude current group
.map((group) => (
<div
key={group.id}
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}
<div className="space-y-1">
{groupsQuery.isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : groupsQuery.data && groupsQuery.data.length > 0 ? (
groupsQuery.data
.filter((g) =>
g.name
.toLowerCase()
.includes(groupSearchTerm.toLowerCase()),
)
.filter((g) => g.id !== currentGroup?.id) // Exclude current group
.map((group) => (
<div
key={group.id}
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
>
{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>
<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", "이동")}
</Button>
</div>
))
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("msg.admin.groups.list.no_results", "그룹이 없습니다.")}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMoveMemberModalOpen(false)}
variant="outline"
onClick={() => setIsMoveMemberModalOpen(false)}
>
{t("ui.common.cancel", "취소")}
{t("ui.common.cancel", "취소")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default TenantGroupsPage;
export default TenantGroupsPage;

View File

@@ -633,7 +633,7 @@ function TenantListPage() {
/>
</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")}
>
<div className="flex items-center">
@@ -642,7 +642,7 @@ function TenantListPage() {
</div>
</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")}
>
<div className="flex items-center">
@@ -651,7 +651,7 @@ function TenantListPage() {
</div>
</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")}
>
<div className="flex items-center">
@@ -660,7 +660,7 @@ function TenantListPage() {
</div>
</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")}
>
<div className="flex items-center">
@@ -669,7 +669,7 @@ function TenantListPage() {
</div>
</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")}
>
<div className="flex items-center">
@@ -678,7 +678,7 @@ function TenantListPage() {
</div>
</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")}
>
<div className="flex items-center">
@@ -687,7 +687,7 @@ function TenantListPage() {
</div>
</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")}
>
<div className="flex items-center">
@@ -695,6 +695,9 @@ function TenantListPage() {
{getSortIcon("updatedAt")}
</div>
</TableHead>
<TableHead className="whitespace-nowrap">
{t("ui.common.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -796,8 +799,24 @@ function TenantListPage() {
)
: "-"}
</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>
))}
))}{" "}
</TableBody>
</Table>
</div>

View File

@@ -1,5 +1,14 @@
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 { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -52,18 +61,34 @@ function TenantUsersPage() {
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
onSuccess: () => {
toast.success(t("msg.admin.tenants.members.remove_success", "조직에서 제외되었습니다."));
toast.success(
t(
"msg.admin.tenants.members.remove_success",
"조직에서 제외되었습니다.",
),
);
usersQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
},
onError: (err: any) => {
toast.error(err.response?.data?.error || t("msg.admin.tenants.members.remove_error", "제외 실패"));
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.tenants.members.remove_error", "제외 실패"),
);
},
});
const handleRemoveMember = (userId: string, userName: string) => {
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 });
}
};
@@ -122,8 +147,13 @@ function TenantUsersPage() {
<TableRow>
<TableCell colSpan={5} className="text-center py-20">
<div className="flex flex-col items-center gap-2">
<Loader2 className="animate-spin text-muted-foreground" size={24} />
<span className="text-sm text-muted-foreground">{t("ui.common.loading", "Loading...")}</span>
<Loader2
className="animate-spin text-muted-foreground"
size={24}
/>
<span className="text-sm text-muted-foreground">
{t("ui.common.loading", "Loading...")}
</span>
</div>
</TableCell>
</TableRow>
@@ -142,7 +172,9 @@ function TenantUsersPage() {
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell className="font-semibold">
{user.name}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
@@ -159,7 +191,9 @@ function TenantUsersPage() {
</TableCell>
<TableCell>
<Badge
variant={user.status === "active" ? "default" : "muted"}
variant={
user.status === "active" ? "default" : "muted"
}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
@@ -167,7 +201,11 @@ function TenantUsersPage() {
<TableCell className="text-right">
<DropdownMenu>
<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} />
</Button>
</DropdownMenuTrigger>
@@ -175,16 +213,24 @@ function TenantUsersPage() {
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<User size={14} className="mr-2" />
{t("ui.admin.tenants.members.view_profile", "상세 정보")}
{t(
"ui.admin.tenants.members.view_profile",
"상세 정보",
)}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleRemoveMember(user.id, user.name)}
onClick={() =>
handleRemoveMember(user.id, user.name)
}
disabled={removeTenantMutation.isPending}
>
<UserMinus size={14} className="mr-2" />
{t("ui.admin.tenants.members.remove", "조직에서 제외")}
{t(
"ui.admin.tenants.members.remove",
"조직에서 제외",
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,16 +1,16 @@
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
canOpenWorksmobilePasswordManage,
canCreateWorksmobileRow,
canOpenWorksmobilePasswordManage,
canSelectWorksmobileRow,
filterWorksmobileComparisonRows,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
getDefaultWorksmobileComparisonColumns,
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileComparisonStatusLabel,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
userFilterOptions,

View File

@@ -1,8 +1,8 @@
import type { TenantSummary } from "../../../lib/adminApi";
import { parseTenantCSV } from "./tenantCsvImport";
// Vite ?raw import는 seed CSV를 빌드 타임 상수로 번들합니다.
// eslint-disable-next-line import/no-unresolved
import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
import type { TenantSummary } from "../../../lib/adminApi";
import { parseTenantCSV } from "./tenantCsvImport";
const seedTenantSlugs = new Set(
parseTenantCSV(seedTenantCSVRaw)

View File

@@ -30,6 +30,7 @@ import {
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Switch } from "../../components/ui/switch";
import {
Tabs,
TabsContent,
@@ -741,15 +742,22 @@ function UserCreatePage() {
</span>
)}
<label className="flex items-center gap-3 text-sm">
<Checkbox
<Switch
checked={appointment.isOwner}
onCheckedChange={(checked) =>
updateAppointment(index, {
isOwner: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
</label>
</div>
</div>

View File

@@ -1161,17 +1161,21 @@ function UserDetailPage() {
</span>
)}
<label className="flex items-center gap-3 text-sm">
<Checkbox
<Switch
checked={appointment.isOwner}
onCheckedChange={(checked) =>
updateAppointment(index, {
isOwner: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"조직",
"대표 조직",
)}
</label>
</div>

View File

@@ -33,12 +33,12 @@ import {
type TenantImportPreviewRow,
buildTenantImportPreview,
} from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { parseUserCSV } from "../utils/csvParser";
import {
type HanmacImportEmailPreview,
buildHanmacImportEmailPreview,
} from "../utils/hanmacImportEmail";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
interface UserBulkUploadModalProps {
onSuccess?: () => void;