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

View File

@@ -18,6 +18,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { DropdownMenuItem } from "../../../components/ui/dropdown-menu";
import { ScrollArea } from "../../../components/ui/scroll-area"; import { ScrollArea } from "../../../components/ui/scroll-area";
import { import {
type BulkUserItem, type BulkUserItem,
@@ -42,7 +43,9 @@ import {
interface UserBulkUploadModalProps { interface UserBulkUploadModalProps {
onSuccess?: () => void; onSuccess?: () => void;
variant?: "button" | "dropdown"; variant?: "button" | "dropdown" | "custom";
open?: boolean;
onOpenChange?: (open: boolean) => void;
} }
function buildUserTenantPreviewRows( function buildUserTenantPreviewRows(
@@ -121,11 +124,34 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
return "text-muted-foreground"; 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({ export function UserBulkUploadModal({
onSuccess, onSuccess,
variant = "button", variant = "button",
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: UserBulkUploadModalProps) { }: 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 [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false); const [parsing, setParsing] = React.useState(false);
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]); const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
@@ -334,17 +360,15 @@ export function UserBulkUploadModal({
const triggerNode = const triggerNode =
variant === "dropdown" ? ( variant === "dropdown" ? (
<div <DropdownMenuItem
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}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="cursor-pointer"
{...triggerProps} {...triggerProps}
> >
<Upload size={16} className="mr-2 opacity-50" /> <Upload size={16} className="mr-2 opacity-50" />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")} {t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</div> </DropdownMenuItem>
) : ( ) : variant === "custom" ? null : (
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="gap-2" {...triggerProps}> <Button variant="outline" className="gap-2" {...triggerProps}>
<Upload size={16} /> <Upload size={16} />
@@ -363,7 +387,7 @@ export function UserBulkUploadModal({
if (!val) reset(); if (!val) reset();
}} }}
> >
{variant !== "dropdown" && triggerNode} {variant !== "dropdown" && variant !== "custom" && triggerNode}
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle data-testid="bulk-upload-title"> <DialogTitle data-testid="bulk-upload-title">
@@ -392,20 +416,23 @@ export function UserBulkUploadModal({
"템플릿 다운로드", "템플릿 다운로드",
)} )}
</Button> </Button>
<input <Button asChild variant="secondary" className="cursor-pointer">
type="file" <label>
accept=".csv" {file
className="hidden" ? t("ui.common.change_file", "파일 변경")
ref={fileInputRef} : t("ui.common.select_file", "파일 선택")}
onChange={handleFileChange} <input
/> type="file"
<Button accept=".csv"
onClick={() => fileInputRef.current?.click()} className="hidden"
variant="secondary" ref={fileInputRef}
> onChange={handleFileChange}
{file onClick={(e) => {
? t("ui.common.change_file", "파일 변경") // Allow picking the same file again if it was cleared
: t("ui.common.select_file", "파일 선택")} (e.target as HTMLInputElement).value = "";
}}
/>
</label>
</Button> </Button>
</div> </div>