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>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button <Button
variant="outline" 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)} onClick={() => handleExport(false)}
className="gap-2"
disabled={exportMutation.isPending} disabled={exportMutation.isPending}
data-testid="user-export-without-ids-btn" data-testid="user-export-menu-item"
className="cursor-pointer"
> >
<FileDown size={16} /> <FileDown size={16} className="mr-2 opacity-50" />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")} {t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</Button> </DropdownMenuItem>
<Button <DropdownMenuItem
variant="outline"
onClick={() => handleExport(true)} onClick={() => handleExport(true)}
className="gap-2"
disabled={exportMutation.isPending} disabled={exportMutation.isPending}
data-testid="user-export-with-ids-btn" data-testid="user-export-with-ids-menu-item"
className="cursor-pointer"
> >
<FileDown size={16} /> <FileDown size={16} className="mr-2 opacity-50" />
{t("ui.common.export_with_ids", "UUID 포함")} {t("ui.common.export_with_ids", "UUID 포함 내보내기")}
</Button> </DropdownMenuItem>
<UserBulkUploadModal onSuccess={() => query.refetch()} /> </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>
<Button asChild variant="secondary" className="cursor-pointer">
<label>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
<input <input
type="file" type="file"
accept=".csv" accept=".csv"
className="hidden" className="hidden"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
onClick={(e) => {
// Allow picking the same file again if it was cleared
(e.target as HTMLInputElement).value = "";
}}
/> />
<Button </label>
onClick={() => fileInputRef.current?.click()}
variant="secondary"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button> </Button>
</div> </div>