1
0
forked from baron/baron-sso

린트 적용

This commit is contained in:
2026-03-05 17:50:34 +09:00
parent c2b55081a6
commit 45ae1bb1c0
21 changed files with 1114 additions and 810 deletions

View File

@@ -14,12 +14,12 @@ import {
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
bulkUpdateUsers,
fetchGroups,
fetchTenants,
import {
type GroupSummary,
type TenantSummary,
type GroupSummary
bulkUpdateUsers,
fetchGroups,
fetchTenants,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -28,12 +28,16 @@ interface UserBulkMoveGroupModalProps {
onSuccess?: () => void;
}
export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroupModalProps) {
export function UserBulkMoveGroupModal({
userIds,
onSuccess,
}: UserBulkMoveGroupModalProps) {
const [open, setOpen] = React.useState(false);
const [selectedTenantSlug, setSelectedTenantSlug] = React.useState<string>("");
const [selectedTenantSlug, setSelectedTenantSlug] =
React.useState<string>("");
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
const [searchTerm, setSearchTerm] = React.useState("");
const queryClient = useQueryClient();
const { data: tenantsData } = useQuery({
@@ -43,9 +47,10 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
});
const tenants = tenantsData?.items ?? [];
const selectedTenantId = React.useMemo(() =>
tenants.find(t => t.slug === selectedTenantSlug)?.id ?? "",
[tenants, selectedTenantSlug]);
const selectedTenantId = React.useMemo(
() => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "",
[tenants, selectedTenantSlug],
);
const { data: groups, isLoading: isGroupsLoading } = useQuery({
queryKey: ["tenant-groups", selectedTenantId],
@@ -56,7 +61,12 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
const mutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
toast.success(t("msg.admin.users.bulk.move_success", "사용자들의 부서가 이동되었습니다."));
toast.success(
t(
"msg.admin.users.bulk.move_success",
"사용자들의 부서가 이동되었습니다.",
),
);
setOpen(false);
onSuccess?.();
},
@@ -79,27 +89,41 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
const filteredGroups = React.useMemo(() => {
if (!groups) return [];
if (!searchTerm) return groups;
return groups.filter(g => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
return groups.filter((g) =>
g.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [groups, searchTerm]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-background hover:bg-background/10 h-8">
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
>
{t("ui.admin.users.bulk.move_group", "부서 이동")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}</DialogTitle>
<DialogTitle>
{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}
</DialogTitle>
<DialogDescription>
{t("msg.admin.users.bulk.move_description", "선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.", { count: userIds.length })}
{t(
"msg.admin.users.bulk.move_description",
"선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.",
{ count: userIds.length },
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t("ui.admin.users.create.form.tenant", "테넌트 선택")}</label>
<label className="text-sm font-medium">
{t("ui.admin.users.create.form.tenant", "테넌트 선택")}
</label>
<select
className="flex h-9 w-full 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"
value={selectedTenantSlug}
@@ -110,14 +134,18 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
>
<option value="">{t("ui.common.select", "선택하세요...")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>{t.name} ({t.slug})</option>
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
{selectedTenantSlug && (
<div className="space-y-2">
<label className="text-sm font-medium">{t("ui.admin.users.bulk.select_group", "부서 선택")}</label>
<label className="text-sm font-medium">
{t("ui.admin.users.bulk.select_group", "부서 선택")}
</label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
@@ -133,14 +161,18 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
type="button"
onClick={() => setSelectedGroupName("")}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === "" ? "bg-primary text-primary-foreground" : "hover:bg-muted"
selectedGroupName === ""
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
<FolderTree size={14} />
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
</button>
{isGroupsLoading ? (
<div className="flex justify-center py-4"><Loader2 className="animate-spin text-muted-foreground" /></div>
<div className="flex justify-center py-4">
<Loader2 className="animate-spin text-muted-foreground" />
</div>
) : (
filteredGroups.map((group) => (
<button
@@ -148,7 +180,9 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
type="button"
onClick={() => setSelectedGroupName(group.name)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === group.name ? "bg-primary text-primary-foreground" : "hover:bg-muted"
selectedGroupName === group.name
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
<FolderTree size={14} />
@@ -166,11 +200,13 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
<Button variant="outline" onClick={() => setOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleMove}
<Button
onClick={handleMove}
disabled={!selectedTenantSlug || mutation.isPending}
>
{mutation.isPending && <Loader2 size={16} className="mr-2 animate-spin" />}
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.do_move", "이동 실행")}
</Button>
</DialogFooter>

View File

@@ -1,5 +1,12 @@
import { useMutation } from "@tanstack/react-query";
import { AlertCircle, CheckCircle2, Download, FileText, Loader2, Upload } from "lucide-react";
import {
AlertCircle,
CheckCircle2,
Download,
FileText,
Loader2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Button } from "../../../components/ui/button";
import {
@@ -12,7 +19,11 @@ import {
DialogTrigger,
} from "../../../components/ui/dialog";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { bulkCreateUsers, type BulkUserItem, type BulkUserResult } from "../../../lib/adminApi";
import {
type BulkUserItem,
type BulkUserResult,
bulkCreateUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { parseUserCSV } from "../utils/csvParser";
@@ -64,9 +75,15 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const downloadTemplate = () => {
const headers = "email,name,phone,role,companyCode,department,employee_id";
const example = "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
const blob = new Blob([`${headers}
${example}`], { type: "text/csv" });
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
const blob = new Blob(
[
`${headers}
${example}`,
],
{ type: "text/csv" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -82,11 +99,17 @@ ${example}`], { type: "text/csv" });
if (fileInputRef.current) fileInputRef.current.value = "";
};
const successCount = results?.filter(r => r.success).length ?? 0;
const successCount = results?.filter((r) => r.success).length ?? 0;
const failCount = results ? results.length - successCount : 0;
return (
<Dialog open={open} onOpenChange={(val) => { setOpen(val); if (!val) reset(); }}>
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<Upload size={16} />
@@ -95,16 +118,26 @@ ${example}`], { type: "text/csv" });
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}</DialogTitle>
<DialogTitle>
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t("msg.admin.users.bulk.description", "CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.")}
{t(
"msg.admin.users.bulk.description",
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
)}
</DialogDescription>
</DialogHeader>
{!results ? (
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button variant="ghost" size="sm" onClick={downloadTemplate} className="gap-2">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
>
<Download size={14} />
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
</Button>
@@ -115,8 +148,13 @@ ${example}`], { type: "text/csv" });
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button onClick={() => fileInputRef.current?.click()} variant="secondary">
{file ? t("ui.common.change_file", "파일 변경") : t("ui.common.select_file", "파일 선택")}
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
@@ -125,7 +163,9 @@ ${example}`], { type: "text/csv" });
<div className="flex items-center gap-3 mb-2">
<FileText className="text-primary" />
<span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
<span className="text-xs text-muted-foreground">
({(file.size / 1024).toFixed(1)} KB)
</span>
</div>
{parsing ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -134,7 +174,11 @@ ${example}`], { type: "text/csv" });
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("msg.admin.users.bulk.parsed_count", "{{count}}명의 사용자가 감지되었습니다.", { count: previewData.length })}
{t(
"msg.admin.users.bulk.parsed_count",
"{{count}}명의 사용자가 감지되었습니다.",
{ count: previewData.length },
)}
</div>
)}
</div>
@@ -160,7 +204,10 @@ ${example}`], { type: "text/csv" });
))}
{previewData.length > 10 && (
<tr>
<td colSpan={3} className="p-2 text-center text-muted-foreground italic">
<td
colSpan={3}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users
</td>
</tr>
@@ -174,28 +221,49 @@ ${example}`], { type: "text/csv" });
<div className="space-y-4 py-4">
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">{successCount}</div>
<div className="text-xs text-muted-foreground uppercase">{t("ui.common.success", "성공")}</div>
<div className="text-2xl font-bold text-green-600">
{successCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.success", "성공")}
</div>
</div>
<div className="w-px h-10 bg-border" />
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-destructive">{failCount}</div>
<div className="text-xs text-muted-foreground uppercase">{t("ui.common.fail", "실패")}</div>
<div className="text-2xl font-bold text-destructive">
{failCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.fail", "실패")}
</div>
</div>
</div>
<ScrollArea className="h-[250px] rounded-md border">
<div className="p-2 space-y-2">
{results.map((r, i) => (
<div key={i} className="flex items-start gap-3 p-2 rounded border bg-card text-sm">
<div
key={i}
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
>
{r.success ? (
<CheckCircle2 size={16} className="text-green-500 mt-0.5" />
<CheckCircle2
size={16}
className="text-green-500 mt-0.5"
/>
) : (
<AlertCircle size={16} className="text-destructive mt-0.5" />
<AlertCircle
size={16}
className="text-destructive mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{r.email}</div>
{!r.success && <div className="text-xs text-destructive">{r.message}</div>}
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
</div>
)}
</div>
</div>
))}
@@ -206,12 +274,14 @@ ${example}`], { type: "text/csv" });
<DialogFooter>
{!results ? (
<Button
onClick={handleUpload}
<Button
onClick={handleUpload}
disabled={previewData.length === 0 || mutation.isPending}
className="w-full sm:w-auto"
>
{mutation.isPending && <Loader2 size={16} className="mr-2 animate-spin" />}
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
</Button>
) : (