forked from baron/baron-sso
org chart 연동기능 추가
This commit is contained in:
@@ -47,7 +47,6 @@ import {
|
||||
removeGroupMember,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
|
||||
type UserGroupNode = GroupSummary & {
|
||||
children: UserGroupNode[];
|
||||
@@ -445,10 +444,6 @@ function TenantGroupsPage() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<OrgChartUploadModal
|
||||
tenantId={tenantId}
|
||||
onSuccess={() => groupsQuery.refetch()}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
CornerDownRight,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@@ -21,6 +23,14 @@ import {
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
@@ -34,16 +44,35 @@ import {
|
||||
type TenantSummary,
|
||||
deleteTenant,
|
||||
deleteTenantsBulk,
|
||||
exportTenantsCSV,
|
||||
fetchMe,
|
||||
fetchTenants,
|
||||
importTenantsCSV,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
import {
|
||||
type TenantImportPreviewRow,
|
||||
buildTenantImportPreview,
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "../utils/tenantCsvImport";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n";
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [importMessage, setImportMessage] = React.useState("");
|
||||
const [previewRows, setPreviewRows] = React.useState<
|
||||
TenantImportPreviewRow[]
|
||||
>([]);
|
||||
const [selectedMatches, setSelectedMatches] = React.useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
@@ -87,6 +116,50 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: exportTenantsCSV,
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (file: File) => importTenantsCSV(file),
|
||||
onSuccess: (result) => {
|
||||
setImportMessage(
|
||||
t(
|
||||
"msg.admin.tenants.import_result",
|
||||
"생성 {{created}}, 갱신 {{updated}}, 실패 {{failed}}",
|
||||
{
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
failed: result.failed,
|
||||
},
|
||||
),
|
||||
);
|
||||
setPreviewOpen(false);
|
||||
setPreviewRows([]);
|
||||
setSelectedMatches({});
|
||||
query.refetch();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
setImportMessage(
|
||||
error.response?.data?.error ??
|
||||
t(
|
||||
"msg.admin.tenants.import_error",
|
||||
"테넌트 가져오기에 실패했습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
profile &&
|
||||
profile.role !== "super_admin" &&
|
||||
@@ -161,8 +234,50 @@ function TenantListPage() {
|
||||
deleteBulkMutation.mutate(selectedIds);
|
||||
};
|
||||
|
||||
const rootTenant =
|
||||
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
|
||||
const handleTemplateDownload = () => {
|
||||
const blob = new Blob([tenantCSVTemplate], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "tenant-import-template.csv";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImportFile = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
setImportMessage("");
|
||||
const text = await file.text();
|
||||
const rows = parseTenantCSV(text);
|
||||
if (rows.length === 0) {
|
||||
setImportMessage(
|
||||
t("msg.admin.tenants.import_empty", "가져올 테넌트 행이 없습니다."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const preview = buildTenantImportPreview(rows, allTenants);
|
||||
setPreviewRows(preview);
|
||||
setSelectedMatches(
|
||||
Object.fromEntries(
|
||||
preview
|
||||
.filter((row) => row.defaultTenantId)
|
||||
.map((row) => [row.row.rowNumber, row.defaultTenantId]),
|
||||
),
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
const csv = serializeTenantImportCSV(previewRows, selectedMatches);
|
||||
const file = new File([csv], "tenants.csv", { type: "text/csv" });
|
||||
importMutation.mutate(file);
|
||||
};
|
||||
|
||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||
if (
|
||||
@@ -210,10 +325,40 @@ function TenantListPage() {
|
||||
</RoleGuard>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<OrgChartUploadModal
|
||||
tenantId={rootTenant?.id || "root"}
|
||||
onSuccess={() => query.refetch()}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
data-testid="tenant-import-input"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTemplateDownload}
|
||||
data-testid="tenant-template-btn"
|
||||
>
|
||||
<FileSpreadsheet size={16} />
|
||||
{t("ui.admin.tenants.csv_template", "템플릿")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportMutation.mutate()}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-btn"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("ui.admin.tenants.export", "내보내기")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-btn"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{t("ui.admin.tenants.import", "가져오기")}
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
|
||||
<Button
|
||||
@@ -233,6 +378,14 @@ function TenantListPage() {
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
</div>
|
||||
{importMessage && (
|
||||
<div
|
||||
className="basis-full rounded-md border border-border bg-secondary px-3 py-2 text-sm"
|
||||
data-testid="tenant-import-result"
|
||||
>
|
||||
{importMessage}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
@@ -247,7 +400,7 @@ function TenantListPage() {
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>{" "}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||
@@ -410,6 +563,139 @@ function TenantListPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.import_preview.title", "CSV 가져오기 확인")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.import_preview.description",
|
||||
"tenant_id가 없는 행은 기존 테넌트 후보와 비교한 뒤 신규 생성 또는 기존 테넌트 갱신으로 처리합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-secondary">
|
||||
<TableRow>
|
||||
<TableHead className="w-[72px]">
|
||||
{t("ui.common.row", "행")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.import_preview.match", "매칭")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.import_preview.candidates", "후보")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewRows.map((preview) => (
|
||||
<TableRow
|
||||
key={preview.row.rowNumber}
|
||||
data-testid={`tenant-import-preview-row-${preview.row.rowNumber}`}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{preview.row.rowNumber}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{preview.row.name}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{preview.row.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{preview.row.tenantId ? (
|
||||
<Badge variant="outline">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.fixed_id",
|
||||
"ID 지정됨",
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectedMatches[preview.row.rowNumber] ?? ""}
|
||||
data-testid={`tenant-import-match-select-${preview.row.rowNumber}`}
|
||||
onChange={(event) =>
|
||||
setSelectedMatches((prev) => ({
|
||||
...prev,
|
||||
[preview.row.rowNumber]: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.create_new",
|
||||
"신규 생성",
|
||||
)}
|
||||
</option>
|
||||
{preview.candidates.map((candidate) => (
|
||||
<option
|
||||
key={candidate.tenantId}
|
||||
value={candidate.tenantId}
|
||||
>
|
||||
{candidate.name} ({candidate.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{preview.candidates.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{preview.candidates.map((candidate) => (
|
||||
<Badge
|
||||
key={candidate.tenantId}
|
||||
variant={
|
||||
candidate.score >= 0.95 ? "default" : "outline"
|
||||
}
|
||||
data-testid="tenant-import-candidate"
|
||||
>
|
||||
{candidate.name}{" "}
|
||||
{Math.round(candidate.score * 100)}%
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.no_candidates",
|
||||
"후보 없음",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPreviewOpen(false)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-confirm-btn"
|
||||
>
|
||||
{t("ui.admin.tenants.import_preview.confirm", "가져오기 실행")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user