1
0
forked from baron/baron-sso

feat(admin): improve tenant bulk import reporting with detailed results

- Backend: Enhanced 'ImportTenantsCSV' to return row-by-row details including action, status, and modified fields.
- Backend: Refactored 'upsertTenantCSVRecord' to detect and return specific modified fields (Name, Type, ParentID, Slug, Description, Config, Domains).
- Frontend: Added 'TenantImportDetail' and updated 'TenantImportResult' types.
- Frontend: Implemented a detailed results modal in 'TenantListPage' showing processing summary and row-level feedback for better transparency.
This commit is contained in:
2026-06-04 11:12:47 +09:00
parent a125b1d7ae
commit fbdfb97c3e
3 changed files with 268 additions and 42 deletions

View File

@@ -285,6 +285,9 @@ function TenantListPage() {
Record<number, string>
>({});
const [previewOpen, setPreviewOpen] = React.useState(false);
const [importResult, setImportResult] =
React.useState<TenantImportResult | null>(null);
const [importResultOpen, setImportResultOpen] = React.useState(false);
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
@@ -387,17 +390,8 @@ function TenantListPage() {
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,
},
),
);
setImportResult(result);
setImportResultOpen(true);
setPreviewOpen(false);
setPreviewRows([]);
setSelectedMatches({});
@@ -1006,6 +1000,128 @@ function TenantListPage() {
</div>
)}
<Dialog open={importResultOpen} onOpenChange={setImportResultOpen}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>
{t(
"ui.admin.tenants.import_result.title",
"가져오기 결과 리포트",
)}
</DialogTitle>
<DialogDescription>
{importResult &&
t(
"msg.admin.tenants.import_result.summary",
"총 {{total}}건 처리: 생성 {{created}}, 갱신 {{updated}}, 실패 {{failed}}",
{
total: importResult.details.length,
created: importResult.created,
updated: importResult.updated,
failed: importResult.failed,
},
)}
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto rounded-md border">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<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_result.action", "작업")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.import_result.status", "상태")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.import_result.message", "상세 내용")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importResult?.details.map((detail) => (
<TableRow key={detail.row}>
<TableCell className="font-mono text-xs">
{detail.row}
</TableCell>
<TableCell className="font-medium">{detail.name}</TableCell>
<TableCell className="font-mono text-xs">
{detail.slug}
</TableCell>
<TableCell>
<Badge
variant={
detail.action === "created"
? "success"
: detail.action === "updated"
? "warning"
: detail.action === "skipped"
? "outline"
: "destructive"
}
className="text-[10px]"
>
{detail.action.toUpperCase()}
</Badge>
</TableCell>
<TableCell>
{detail.success ? (
<Badge variant="success" className="text-[10px]">
SUCCESS
</Badge>
) : (
<Badge variant="destructive" className="text-[10px]">
FAILED
</Badge>
)}
</TableCell>
<TableCell className="text-xs">
{detail.message}
{detail.modifiedFields &&
detail.modifiedFields.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
<span className="mr-1 text-muted-foreground">
{t(
"ui.admin.tenants.import_result.modified",
"수정됨:",
)}
</span>
{detail.modifiedFields.map((field) => (
<Badge
key={field}
variant="outline"
className="bg-muted text-[10px]"
>
{field}
</Badge>
))}
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<Button onClick={() => setImportResultOpen(false)}>
{t("ui.common.confirm", "확인")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-5xl">
<DialogHeader>

View File

@@ -70,11 +70,22 @@ export type TenantUpdateRequest = {
config?: Record<string, unknown>;
};
export type TenantImportDetail = {
row: number;
slug: string;
name: string;
success: boolean;
action: "created" | "updated" | "failed" | "skipped";
message: string;
modifiedFields?: string[];
};
export type TenantImportResult = {
created: number;
updated: number;
failed: number;
errors: string[];
details: TenantImportDetail[];
};
export type ApiKeySummary = {