1
0
forked from baron/baron-sso

feat: implement multi-tenant member management and UI improvements

- Add multi-tenant support (isAddTenant, isRemoveTenant) to backend UpdateUser API.
- Update UserRepository to support searching in company_codes array.
- Implement table sorting and align search bar layout in adminfront.
- Add 'Assign Existing Member' and 'Exclude from Organization' features to TenantUsersPage.
- Auto-populate tenantSlug in UserCreatePage via query parameters.
- Add necessary localization keys for new UI elements.

Resolves #644, #639, #642, #641
This commit is contained in:
2026-05-06 14:20:35 +09:00
parent 3169dd958a
commit 5f9a61de98
10 changed files with 591 additions and 140 deletions

View File

@@ -1,8 +1,11 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronDown,
ChevronUp,
Download,
FileSpreadsheet,
Loader2,
Pencil,
Plus,
RefreshCw,
@@ -66,6 +69,8 @@ function TenantListPage() {
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const [sortKey, setSortKey] = React.useState<string>("name");
const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc");
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [importMessage, setImportMessage] = React.useState("");
const [previewRows, setPreviewRows] = React.useState<
@@ -113,6 +118,15 @@ function TenantListPage() {
},
});
const handleSort = (key: string) => {
if (sortKey === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortOrder("asc");
}
};
const deleteBulkMutation = useMutation({
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
onSuccess: () => {
@@ -198,14 +212,27 @@ function TenantListPage() {
const allTenants = query.data?.items ?? [];
const tenants = React.useMemo(() => {
if (!search.trim()) return allTenants;
const term = search.toLowerCase();
return allTenants.filter(
(t) =>
t.name.toLowerCase().includes(term) ||
t.slug.toLowerCase().includes(term),
);
}, [allTenants, search]);
let filtered = allTenants;
if (search.trim()) {
const term = search.toLowerCase();
filtered = filtered.filter(
(t) =>
t.name.toLowerCase().includes(term) ||
t.slug.toLowerCase().includes(term),
);
}
return [...filtered].sort((a, b) => {
const valA = (a[sortKey as keyof typeof a] || "")
.toString()
.toLowerCase();
const valB = (b[sortKey as keyof typeof b] || "")
.toString()
.toLowerCase();
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
return 0;
});
}, [allTenants, search, sortKey, sortOrder]);
const deletableTenants = React.useMemo(
() => tenants.filter((tenant) => !isSeedTenant(tenant)),
@@ -460,18 +487,23 @@ function TenantListPage() {
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
<div className="mb-6 flex flex-wrap items-end gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-[240px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.list.search_placeholder",
"테넌트 이름 또는 슬러그 검색...",
)}
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
{t("ui.admin.tenants.list.search_label", "테넌트 검색")}
</label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.list.search_placeholder",
"테넌트 이름 또는 슬러그 검색...",
)}
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
</div>
@@ -498,26 +530,90 @@ function TenantListPage() {
}
/>
</TableHead>
<TableHead className="min-w-[220px]">
{t("ui.admin.tenants.table.id", "ID")}
<TableHead
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("id")}
>
<div className="flex items-center gap-1">
{t("ui.admin.tenants.table.id", "ID")}
{sortKey === "id" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("name")}
>
<div className="flex items-center gap-1">
{t("ui.admin.tenants.table.name", "NAME")}
{sortKey === "name" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.type", "TYPE")}
<TableHead>{t("ui.admin.tenants.table.type", "TYPE")}</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("slug")}
>
<div className="flex items-center gap-1">
{t("ui.admin.tenants.table.slug", "SLUG")}
{sortKey === "slug" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("status")}
>
<div className="flex items-center gap-1">
{t("ui.admin.tenants.table.status", "STATUS")}
{sortKey === "status" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("memberCount")}
>
<div className="flex items-center gap-1">
{t("ui.admin.tenants.table.members", "MEMBERS")}
{sortKey === "memberCount" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.updated", "UPDATED")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("updatedAt")}
>
<div className="flex items-center gap-1">
{t("ui.admin.tenants.table.updated", "UPDATED")}
{sortKey === "updatedAt" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
@@ -527,8 +623,11 @@ function TenantListPage() {
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={9}>
{t("msg.common.loading", "로딩 중...")}
<TableCell colSpan={9} className="h-24 text-center">
<div className="flex flex-col items-center gap-2">
<Loader2 className="animate-spin" size={20} />
{t("msg.common.loading", "로딩 중...")}
</div>
</TableCell>
</TableRow>
)}

View File

@@ -1,13 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { Mail, User } from "lucide-react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Mail, MoreHorizontal, Plus, User, UserPlus, UserMinus, Loader2 } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import {
Table,
TableBody,
@@ -16,12 +23,14 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
import { toast } from "../../../components/ui/use-toast";
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantUsersPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient();
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({
@@ -39,17 +48,51 @@ function TenantUsersPage() {
enabled: !!tenantSlug,
});
const removeTenantMutation = useMutation({
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
onSuccess: () => {
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", "제외 실패"));
},
});
const handleRemoveMember = (userId: string, userName: string) => {
if (!tenantSlug) return;
if (window.confirm(t("msg.admin.tenants.members.remove_confirm", "'{{name}}'님을 이 조직에서 제외하시겠습니까?", { name: userName }))) {
removeTenantMutation.mutate({ userId, slug: tenantSlug });
}
};
const users = usersQuery.data?.items ?? [];
return (
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex-shrink-0">
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" />
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
count: users.length,
})}
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild className="gap-2">
<Link to={`/users?addTenant=${tenantSlug}`}>
<UserPlus size={16} />
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</Link>
</Button>
<Button size="sm" asChild className="gap-2">
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
<Plus size={16} />
{t("ui.admin.tenants.members.create_new", "신규 멤버 생성")}
</Link>
</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">
@@ -69,13 +112,25 @@ function TenantUsersPage() {
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
<TableHead className="w-[80px] text-right">
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
{usersQuery.isLoading ? (
<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>
</div>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
colSpan={5}
className="text-center py-8 text-muted-foreground"
>
{t(
@@ -84,33 +139,59 @@ function TenantUsersPage() {
)}
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{t(
`ui.common.role.${user.role}`,
user.role.replace("_", " "),
)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={user.status === "active" ? "default" : "muted"}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<User size={14} className="mr-2" />
{t("ui.admin.tenants.members.view_profile", "상세 정보")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleRemoveMember(user.id, user.name)}
disabled={removeTenantMutation.isPending}
>
<UserMinus size={14} className="mr-2" />
{t("ui.admin.tenants.members.remove", "조직에서 제외")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{t(
`ui.common.role.${user.role}`,
user.role.replace("_", " "),
)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={user.status === "active" ? "default" : "muted"}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -104,6 +104,7 @@ function createEmptyAppointment(): AppointmentDraft {
function UserCreatePage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<
@@ -144,7 +145,7 @@ function UserCreatePage() {
password: "",
name: "",
phone: "",
tenantSlug: "",
tenantSlug: searchParams.get("tenantSlug") ?? "",
department: "",
position: "",
jobTitle: "",

View File

@@ -1,9 +1,12 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
FileDown,
Loader2,
Pencil,
Plus,
RefreshCw,
@@ -11,9 +14,10 @@ import {
Settings2,
Trash2,
User,
UserPlus,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -71,6 +75,8 @@ type UserSchemaField = {
function UserListPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const addTenantSlug = searchParams.get("addTenant");
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState("");
@@ -79,6 +85,8 @@ function UserListPage() {
Record<string, boolean>
>({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [sortKey, setSortKey] = React.useState<string>("name");
const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc");
const limit = 1000;
const offset = (page - 1) * limit;
@@ -151,6 +159,15 @@ function UserListPage() {
},
});
const handleSort = (key: string) => {
if (sortKey === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortOrder("asc");
}
};
const exportMutation = useMutation({
mutationFn: (includeIds: boolean) =>
exportUsersCSV(search, selectedCompany, includeIds),
@@ -184,6 +201,41 @@ function UserListPage() {
},
});
const addTenantMutation = useMutation({
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isAddTenant: true }),
onSuccess: () => {
toast.success(
t(
"msg.admin.users.add_tenant_success",
"해당 테넌트에 추가되었습니다.",
),
);
query.refetch();
},
onError: (err: any) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.users.add_tenant_error", "추가 실패"),
);
},
});
const handleAddTenant = (userId: string, userName: string) => {
if (!addTenantSlug) return;
if (
window.confirm(
t(
"msg.admin.users.add_tenant_confirm",
"'{{name}}'님을 '{{tenant}}' 테넌트에 추가하시겠습니까?",
{ name: userName, tenant: addTenantSlug },
),
)
) {
addTenantMutation.mutate({ userId, slug: addTenantSlug });
}
};
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
@@ -210,6 +262,20 @@ function UserListPage() {
: null;
const items = query.data?.items ?? [];
const sortedItems = React.useMemo(() => {
return [...items].sort((a, b) => {
const valA = (a[sortKey as keyof typeof a] || "")
.toString()
.toLowerCase();
const valB = (b[sortKey as keyof typeof b] || "")
.toString()
.toLowerCase();
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
return 0;
});
}, [items, sortKey, sortOrder]);
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
@@ -414,24 +480,29 @@ function UserListPage() {
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-6 flex flex-wrap items-center gap-4 flex-shrink-0">
<div className="mb-6 flex flex-wrap items-end gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-[240px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
{t("ui.admin.users.list.search_label", "사용자 검색")}
</label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground whitespace-nowrap">
{t("ui.admin.users.list.filter.tenant", "테넌트 필터")}
</span>
<select
className="flex h-9 w-[200px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
@@ -451,7 +522,11 @@ function UserListPage() {
</select>
</div>
<Button variant="secondary" onClick={handleSearch}>
<Button
variant="secondary"
onClick={handleSearch}
className="mb-0.5"
>
{t("ui.common.search", "검색")}
</Button>
</div>
@@ -478,17 +553,50 @@ function UserListPage() {
onChange={toggleSelectAll}
/>
</TableHead>
<TableHead className="min-w-[220px]">
{t("ui.admin.users.list.table.id", "ID")}
<TableHead
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("id")}
>
<div className="flex items-center gap-1">
{t("ui.admin.users.list.table.id", "ID")}
{sortKey === "id" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
<TableHead className="min-w-[200px]">
{t(
"ui.admin.users.list.table.name_email",
"이름 / 이메일 / 전화번호",
)}
<TableHead
className="min-w-[200px] cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("name")}
>
<div className="flex items-center gap-1">
{t(
"ui.admin.users.list.table.name_email",
"이름 / 이메일 / 전화번호",
)}
{sortKey === "name" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.status", "STATUS")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("status")}
>
<div className="flex items-center gap-1">
{t("ui.admin.users.list.table.status", "STATUS")}
{sortKey === "status" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
@@ -499,8 +607,19 @@ function UserListPage() {
</TableHead>
),
)}
<TableHead>
{t("ui.admin.users.list.table.created", "CREATED")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("createdAt")}
>
<div className="flex items-center gap-1">
{t("ui.admin.users.list.table.created", "CREATED")}
{sortKey === "createdAt" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
</div>
</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.list.table.actions", "ACTIONS")}
@@ -514,11 +633,14 @@ function UserListPage() {
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
<div className="flex flex-col items-center gap-2">
<Loader2 className="animate-spin" size={20} />
{t("msg.common.loading", "로딩 중...")}
</div>
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
{!query.isLoading && sortedItems.length === 0 && (
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
@@ -531,7 +653,7 @@ function UserListPage() {
</TableCell>
</TableRow>
)}
{items.map((user) => (
{sortedItems.map((user) => (
<TableRow
key={user.id}
className={
@@ -636,6 +758,23 @@ function UserListPage() {
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{addTenantSlug && (
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-primary"
onClick={() =>
handleAddTenant(user.id, user.name)
}
title={t(
"ui.admin.users.list.add_to_tenant",
"테넌트에 추가",
)}
disabled={addTenantMutation.isPending}
>
<UserPlus size={16} />
</Button>
)}
<Button
variant="ghost"
size="icon"

View File

@@ -433,6 +433,8 @@ export type UserUpdateRequest = {
role?: string;
status?: string;
tenantSlug?: string;
isAddTenant?: boolean;
isRemoveTenant?: boolean;
department?: string;
position?: string;
jobTitle?: string;

View File

@@ -1176,14 +1176,49 @@ primary = "Representative Affiliated Tenant"
title = "Affiliation & Organization Info"
[ui.admin.users.list]
add = "User Add"
add = "Add User"
add_to_tenant = "Add to Tenant"
bulk_import = "Bulk Import"
empty = "Empty"
fetch_error = "Fetch Error"
search_placeholder = "Search Placeholder"
subtitle = "Subtitle"
empty = "No users found."
fetch_error = "Failed to fetch user list."
search_label = "Search Users"
search_placeholder = "Search by name or email..."
subtitle = "View and manage system users."
toggle_status = "{{name}} active status"
title = "User Manage"
title = "User Management"
[msg.admin.users]
add_tenant_confirm = "Are you sure you want to add '{{name}}' to '{{tenant}}' tenant?"
add_tenant_error = "An error occurred while adding the tenant."
add_tenant_success = "Successfully added to the tenant."
export_error = "Failed to export users."
status_error = "Failed to change user status."
self_delete_blocked = "You cannot delete your own account."
[ui.admin.tenants.members]
add_existing = "Assign Existing Member"
create_new = "Create New Member"
remove = "Exclude from Organization"
title = "Tenant Members ({{count}})"
view_profile = "View Profile"
[ui.admin.tenants.members.table]
actions = "ACTIONS"
email = "EMAIL"
name = "NAME"
role = "ROLE"
status = "STATUS"
[msg.admin.tenants.members]
empty = "No members in this organization."
remove_confirm = "Are you sure you want to exclude '{{name}}' from this organization?"
remove_error = "An error occurred while excluding from organization."
remove_success = "Successfully excluded from organization."
[ui.admin.tenants.list]
search_label = "Search Tenants"
search_placeholder = "Search by name or slug..."
title = "Tenant List"
[ui.admin.users.list.breadcrumb]
list = "List"

View File

@@ -1179,14 +1179,49 @@ title = "소속 및 조직 정보"
[ui.admin.users.list]
add = "사용자 추가"
add_to_tenant = "테넌트에 추가"
bulk_import = "일괄 임포트"
empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다."
search_label = "사용자 검색"
search_placeholder = "이름 또는 이메일 검색..."
subtitle = "시스템 사용자를 조회하고 관리합니다."
toggle_status = "{{name}} 활성 상태"
title = "사용자 관리"
[msg.admin.users]
add_tenant_confirm = "'{{name}}'님을 '{{tenant}}' 테넌트에 추가하시겠습니까?"
add_tenant_error = "테넌트 추가 중 오류가 발생했습니다."
add_tenant_success = "해당 테넌트에 추가되었습니다."
export_error = "사용자 내보내기에 실패했습니다."
status_error = "사용자 상태 변경에 실패했습니다."
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
[ui.admin.tenants.members]
add_existing = "기존 멤버 배정"
create_new = "신규 멤버 생성"
remove = "조직에서 제외"
title = "테넌트 멤버 ({{count}})"
view_profile = "상세 정보"
[ui.admin.tenants.members.table]
actions = "ACTIONS"
email = "EMAIL"
name = "NAME"
role = "ROLE"
status = "STATUS"
[msg.admin.tenants.members]
empty = "소속된 사용자가 없습니다."
remove_confirm = "'{{name}}'님을 이 조직에서 제외하시겠습니까?"
remove_error = "조직에서 제외하는 중 오류가 발생했습니다."
remove_success = "조직에서 제외되었습니다."
[ui.admin.tenants.list]
search_label = "테넌트 검색"
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
title = "테넌트 목록"
[ui.admin.users.list.breadcrumb]
list = "List"
section = "Users"