1
0
forked from baron/baron-sso

org chart 연동기능 추가

This commit is contained in:
2026-04-29 21:00:51 +09:00
parent 438f844f2b
commit 01e7b15c46
25 changed files with 5141 additions and 2940 deletions

View File

@@ -14,7 +14,6 @@ import {
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -33,6 +32,7 @@ import {
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Switch } from "../../components/ui/switch";
import {
Table,
TableBody,
@@ -46,14 +46,14 @@ import {
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
exportUsersCSVUrl,
exportUsersCSV,
fetchMe,
fetchTenant,
fetchTenants,
fetchUsers,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
type UserSchemaField = {
@@ -144,6 +144,41 @@ function UserListPage() {
},
});
const exportMutation = useMutation({
mutationFn: () => exportUsersCSV(search, selectedCompany),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () => {
toast.error(
t(
"msg.admin.users.export_error",
"사용자 내보내기에 실패했습니다.",
),
);
},
});
const statusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: string; status: string }) =>
updateUser(userId, { status }),
onSuccess: () => {
query.refetch();
},
onError: () => {
toast.error(
t("msg.admin.users.status_error", "사용자 상태 변경에 실패했습니다."),
);
},
});
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
@@ -156,8 +191,7 @@ function UserListPage() {
};
const handleExport = () => {
const url = exportUsersCSVUrl(search, selectedCompany);
window.open(url, "_blank");
exportMutation.mutate();
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
@@ -275,7 +309,12 @@ function UserListPage() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button variant="outline" onClick={handleExport} className="gap-2">
<Button
variant="outline"
onClick={handleExport}
className="gap-2"
disabled={exportMutation.isPending}
>
<FileDown size={16} />
{t("ui.common.export", "내보내기")}
</Button>
@@ -428,21 +467,12 @@ function UserListPage() {
<TableHead className="min-w-[200px]">
{t(
"ui.admin.users.list.table.name_email",
"NAME / EMAIL",
"이름 / 이메일 / 전화번호",
)}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.users.list.table.status", "STATUS")}
</TableHead>
<TableHead>
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
@@ -464,7 +494,7 @@ function UserListPage() {
{query.isLoading && (
<TableRow>
<TableCell
colSpan={7 + userSchema.length}
colSpan={5 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
@@ -474,7 +504,7 @@ function UserListPage() {
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={7 + userSchema.length}
colSpan={5 + userSchema.length}
className="h-24 text-center"
>
{t(
@@ -513,35 +543,47 @@ function UserListPage() {
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<div
className="truncate text-sm"
data-testid={`user-contact-${user.id}`}
>
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground">
{" "}
{user.email}
</span>
{user.phone && (
<span className="text-muted-foreground">
{" "}
{user.phone}
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{t(`ui.admin.role.${user.role}`, user.role)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium text-blue-600">
{user.tenant?.name || user.tenantSlug || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) =>
statusMutation.mutate({
userId: user.id,
status: checked ? "active" : "inactive",
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
aria-label={t(
"ui.admin.users.list.toggle_status",
"{{name}} 활성 상태",
{ name: user.name },
)}
data-testid={`user-status-toggle-${user.id}`}
/>
<span className="text-sm text-muted-foreground">
{t(`ui.common.status.${user.status}`, user.status)}
</span>
</div>
</TableCell>
@@ -625,16 +667,6 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<UserBulkMoveGroupModal
userIds={selectedUserIds}
selectedUsers={items.filter((u) =>
selectedUserIds.includes(u.id),
)}
onSuccess={() => {
query.refetch();
setSelectedUserIds([]);
}}
/>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"