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:
@@ -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: "",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user