1
0
forked from baron/baron-sso

Merge pull request 'feature/admin-user-data-mgmt-fix' (#870) from feature/admin-user-data-mgmt-fix into dev

Reviewed-on: baron/baron-sso#870
This commit is contained in:
2026-05-20 18:00:52 +09:00
2 changed files with 117 additions and 45 deletions

View File

@@ -8,13 +8,16 @@ import {
ChevronLeft,
ChevronRight,
Users,
Download,
FileDown,
FileSpreadsheet,
Plus,
RefreshCw,
Search,
Settings2,
ShieldCheck,
Trash2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
@@ -90,7 +93,10 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import {
UserBulkUploadModal,
downloadUserTemplate,
} from "./components/UserBulkUploadModal";
import {
normalizeUserStatusValue,
type UserStatusValue,
@@ -140,6 +146,7 @@ function UserListPage() {
React.useState("");
const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null);
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
const limit = 1000;
const offset = (page - 1) * limit;
@@ -486,27 +493,65 @@ function UserListPage() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button
variant="outline"
onClick={() => handleExport(false)}
className="gap-2"
disabled={exportMutation.isPending}
data-testid="user-export-without-ids-btn"
>
<FileDown size={16} />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</Button>
<Button
variant="outline"
onClick={() => handleExport(true)}
className="gap-2"
disabled={exportMutation.isPending}
data-testid="user-export-with-ids-btn"
>
<FileDown size={16} />
{t("ui.common.export_with_ids", "UUID 포함")}
</Button>
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
data-testid="user-data-mgmt-btn"
className="gap-2 h-9"
>
<LayoutDashboard size={16} />
{t("ui.admin.users.data_mgmt", "데이터 관리")}
<ChevronDown size={14} className="opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={downloadUserTemplate}
data-testid="user-template-menu-item"
className="cursor-pointer"
>
<FileSpreadsheet size={16} className="mr-2 opacity-50" />
{t("ui.admin.users.csv_template", "템플릿 다운로드")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setBulkUploadOpen(true);
}}
className="cursor-pointer"
>
<Upload size={16} className="mr-2 opacity-50" />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleExport(false)}
disabled={exportMutation.isPending}
data-testid="user-export-menu-item"
className="cursor-pointer"
>
<FileDown size={16} className="mr-2 opacity-50" />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport(true)}
disabled={exportMutation.isPending}
data-testid="user-export-with-ids-menu-item"
className="cursor-pointer"
>
<FileDown size={16} className="mr-2 opacity-50" />
{t("ui.common.export_with_ids", "UUID 포함 내보내기")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<UserBulkUploadModal
variant="custom"
open={bulkUploadOpen}
onOpenChange={setBulkUploadOpen}
onSuccess={() => query.refetch()}
/>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">

View File

@@ -18,6 +18,7 @@ import {
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { DropdownMenuItem } from "../../../components/ui/dropdown-menu";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
type BulkUserItem,
@@ -42,7 +43,9 @@ import {
interface UserBulkUploadModalProps {
onSuccess?: () => void;
variant?: "button" | "dropdown";
variant?: "button" | "dropdown" | "custom";
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function buildUserTenantPreviewRows(
@@ -121,11 +124,34 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
return "text-muted-foreground";
}
export const downloadUserTemplate = () => {
const headers =
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "user_bulk_template.csv";
a.click();
URL.revokeObjectURL(url);
};
export function UserBulkUploadModal({
onSuccess,
variant = "button",
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: UserBulkUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [localOpen, setLocalOpen] = React.useState(false);
const open = controlledOpen !== undefined ? controlledOpen : localOpen;
const setOpen = (val: boolean) => {
setLocalOpen(val);
controlledOnOpenChange?.(val);
};
const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false);
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
@@ -334,17 +360,15 @@ export function UserBulkUploadModal({
const triggerNode =
variant === "dropdown" ? (
<div
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
role="menuitem"
tabIndex={-1}
<DropdownMenuItem
onClick={() => setOpen(true)}
className="cursor-pointer"
{...triggerProps}
>
<Upload size={16} className="mr-2 opacity-50" />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</div>
) : (
</DropdownMenuItem>
) : variant === "custom" ? null : (
<DialogTrigger asChild>
<Button variant="outline" className="gap-2" {...triggerProps}>
<Upload size={16} />
@@ -363,7 +387,7 @@ export function UserBulkUploadModal({
if (!val) reset();
}}
>
{variant !== "dropdown" && triggerNode}
{variant !== "dropdown" && variant !== "custom" && triggerNode}
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle data-testid="bulk-upload-title">
@@ -392,20 +416,23 @@ export function UserBulkUploadModal({
"템플릿 다운로드",
)}
</Button>
<input
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
<Button asChild variant="secondary" className="cursor-pointer">
<label>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
<input
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
onClick={(e) => {
// Allow picking the same file again if it was cleared
(e.target as HTMLInputElement).value = "";
}}
/>
</label>
</Button>
</div>