diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index b535cc9b..88151c11 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -76,6 +76,7 @@ import { TableHeader, TableRow, } from "../../../components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs"; import { toast } from "../../../components/ui/use-toast"; import type { UserProfileResponse } from "../../../lib/adminApi"; import { @@ -84,6 +85,8 @@ import { fetchMe, fetchTenants, importTenantsCSV, + type TenantImportDetail, + type TenantImportResult, type TenantSummary, updateTenant, } from "../../../lib/adminApi"; @@ -285,6 +288,22 @@ function TenantListPage() { Record >({}); const [previewOpen, setPreviewOpen] = React.useState(false); + const [importResult, setImportResult] = + React.useState(null); + const [importResultOpen, setImportResultOpen] = React.useState(false); + const [importResultFilter, setImportResultFilter] = React.useState< + "all" | "created" | "updated" | "failed" | "skipped" + >("all"); + + const filteredImportDetails = React.useMemo(() => { + if (!importResult) return []; + if (importResultFilter === "all") return importResult.details; + if (importResultFilter === "failed") + return importResult.details.filter((d: TenantImportDetail) => !d.success); + return importResult.details.filter( + (d: TenantImportDetail) => d.action === importResultFilter, + ); + }, [importResult, importResultFilter]); const [selectedBulkStatus, setSelectedBulkStatus] = React.useState(""); const _tenantTableScrollRef = React.useRef(null); @@ -387,6 +406,8 @@ function TenantListPage() { const importMutation = useMutation({ mutationFn: (file: File) => importTenantsCSV(file), onSuccess: (result) => { + setImportResult(result); + setImportResultOpen(true); setImportMessage( t( "msg.admin.tenants.import_result", @@ -1006,6 +1027,194 @@ function TenantListPage() { )} + + + + + {t( + "ui.admin.tenants.import_result.title", + "가져오기 결과 리포트", + )} + + + + {importResult && ( +
+
+ + Total + + + {importResult.details.length} + +
+
+ + Created + + + {importResult.created} + +
+
+ + Updated + + + {importResult.updated} + +
+
+ + Failed + + + {importResult.failed} + +
+
+ )} + + + setImportResultFilter( + v as "all" | "created" | "updated" | "failed" | "skipped", + ) + } + className="w-full" + > + + + ALL + + + CREATED + + + UPDATED + + + FAILED + + + SKIPPED + + + + +
+ + + + + {t("ui.common.row", "행")} + + + {t("ui.admin.tenants.table.name", "NAME")} + + + {t("ui.admin.tenants.table.slug", "SLUG")} + + + {t("ui.admin.tenants.import_result.status", "상태")} + + + {t("ui.admin.tenants.import_result.message", "상세 내용")} + + + + + {filteredImportDetails.length === 0 ? ( + + + {t("ui.common.no_results", "표시할 결과가 없습니다.")} + + + ) : ( + filteredImportDetails.map((detail: TenantImportDetail) => ( + + + {detail.row} + + + {detail.name} + + + {detail.slug} + + + + {detail.action.toUpperCase()} + + + + {detail.message} + {detail.modifiedFields && + detail.modifiedFields.length > 0 && ( +
+ + {t( + "ui.admin.tenants.import_result.modified", + "수정됨:", + )} + + {detail.modifiedFields.map((field: string) => ( + + {field} + + ))} +
+ )} +
+
+ )) + )} +
+
+
+ + + + +
+
+ diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index f083b12a..f0720947 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -70,11 +70,22 @@ export type TenantUpdateRequest = { config?: Record; }; +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 = { diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 56a62705..d6ca892b 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -684,7 +684,22 @@ test.describe("Tenants Management", () => { importRequested = true; importBody = route.request().postData() ?? ""; return route.fulfill({ - json: { created: 0, updated: 1, failed: 0, errors: [] }, + json: { + created: 0, + updated: 1, + failed: 0, + errors: [], + details: [ + { + row: 2, + name: "Tenant Alpha", + slug: "tenant-alpha", + success: true, + action: "updated", + message: "updated successfully", + }, + ], + }, headers, }); } @@ -774,9 +789,12 @@ test.describe("Tenants Management", () => { ); await page.getByTestId("tenant-import-confirm-btn").click(); - await expect(page.getByTestId("tenant-import-result")).toContainText( - /갱신 1|Updated 1/i, - ); + const resultDialog = page.getByRole("dialog").filter({ + hasText: /가져오기 결과 리포트|Import Result Report/i, + }); + await expect(resultDialog).toBeVisible({ timeout: 15000 }); + await expect(resultDialog).toContainText(/Updated|갱신/i); + await expect(resultDialog).toContainText("1"); expect(importRequested).toBe(true); expect(importBody).toContain('filename="tenants.csv"'); if (browserName !== "webkit") { @@ -799,7 +817,30 @@ test.describe("Tenants Management", () => { if (url.includes("/import")) { importBody = route.request().postData() ?? ""; return route.fulfill({ - json: { created: 2, updated: 0, failed: 0, errors: [] }, + json: { + created: 2, + updated: 0, + failed: 0, + errors: [], + details: [ + { + row: 2, + name: "Child A", + slug: "child-a", + success: true, + action: "created", + message: "created successfully", + }, + { + row: 3, + name: "Child B", + slug: "child-b", + success: true, + action: "created", + message: "created successfully", + }, + ], + }, headers, }); } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 9bb66e66..74f93a2e 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "maps" + "reflect" "sort" "strings" "time" @@ -106,11 +107,22 @@ func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ( }) } +type tenantImportDetail struct { + Row int `json:"row"` + Slug string `json:"slug"` + Name string `json:"name"` + Success bool `json:"success"` + Action string `json:"action"` // "created", "updated", "failed", "skipped" + Message string `json:"message"` // Detailed error or success message + ModifiedFields []string `json:"modifiedFields"` // List of fields changed during update +} + type tenantImportResult struct { - Created int `json:"created"` - Updated int `json:"updated"` - Failed int `json:"failed"` - Errors []string `json:"errors"` + Created int `json:"created"` + Updated int `json:"updated"` + Failed int `json:"failed"` + Errors []string `json:"errors"` + Details []tenantImportDetail `json:"details"` } type tenantDomainConflict struct { @@ -561,32 +573,64 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error { } } - result := tenantImportResult{Errors: make([]string, 0)} + result := tenantImportResult{ + Errors: make([]string, 0), + Details: make([]tenantImportDetail, 0, len(records)), + } for i, record := range records { rowNumber := i + 2 + detail := tenantImportDetail{ + Row: rowNumber, + Slug: record.Slug, + Name: record.Name, + } + if record.ParentTenantID == nil && record.ParentTenantSlug != "" { parentID := tenantIDBySlug[strings.ToLower(record.ParentTenantSlug)] if parentID == "" { result.Failed++ - result.Errors = append(result.Errors, fmt.Sprintf("row %d: parent tenant slug not found: %s", rowNumber, record.ParentTenantSlug)) + msg := fmt.Sprintf("row %d: parent tenant slug not found: %s", rowNumber, record.ParentTenantSlug) + result.Errors = append(result.Errors, msg) + detail.Success = false + detail.Action = "failed" + detail.Message = msg + result.Details = append(result.Details, detail) continue } record.ParentTenantID = &parentID } if record.TenantID != "" || (h.DB != nil && record.Slug != "") { - tenant, updated, err := h.upsertTenantCSVRecord(c, record) + tenant, modifiedFields, err := h.upsertTenantCSVRecord(c, record) if err != nil { result.Failed++ - result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error())) + msg := fmt.Sprintf("row %d: %s", rowNumber, err.Error()) + result.Errors = append(result.Errors, msg) + detail.Success = false + detail.Action = "failed" + detail.Message = msg + result.Details = append(result.Details, detail) continue } - if updated { - tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID - result.Updated++ - if h.Worksmobile != nil { - _ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant) + if tenant != nil { + if len(modifiedFields) > 0 { + tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID + result.Updated++ + detail.Success = true + detail.Action = "updated" + detail.ModifiedFields = modifiedFields + if h.Worksmobile != nil { + _ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant) + } + result.Details = append(result.Details, detail) + continue + } else { + // No changes, skip + detail.Success = true + detail.Action = "skipped" + detail.Message = "no changes detected" + result.Details = append(result.Details, detail) + continue } - continue } } @@ -598,19 +642,32 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error { tenant, err := h.createTenantCSVRecord(c, record, recordCreatorID) if err != nil { result.Failed++ - result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error())) + msg := fmt.Sprintf("row %d: %s", rowNumber, err.Error()) + result.Errors = append(result.Errors, msg) + detail.Success = false + detail.Action = "failed" + detail.Message = msg + result.Details = append(result.Details, detail) continue } if tenant == nil { result.Failed++ - result.Errors = append(result.Errors, fmt.Sprintf("row %d: tenant creation returned empty result", rowNumber)) + msg := fmt.Sprintf("row %d: tenant creation returned empty result", rowNumber) + result.Errors = append(result.Errors, msg) + detail.Success = false + detail.Action = "failed" + detail.Message = msg + result.Details = append(result.Details, detail) continue } tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID result.Created++ + detail.Success = true + detail.Action = "created" if h.Worksmobile != nil { _ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant) } + result.Details = append(result.Details, detail) } return c.JSON(result) @@ -1389,12 +1446,12 @@ func (h *TenantHandler) replaceTenantDomains(ctx context.Context, tenantID strin return nil } -func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (*domain.Tenant, bool, error) { +func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (*domain.Tenant, []string, error) { if h.DB == nil { if record.TenantID != "" { - return nil, false, errors.New("database not available for tenant update") + return nil, nil, errors.New("database not available for tenant update") } - return nil, false, nil + return nil, nil, nil } var tenant domain.Tenant @@ -1407,42 +1464,85 @@ func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco } if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, false, nil + return nil, nil, nil } if err != nil { - return nil, false, err + return nil, nil, err + } + + modifiedFields := []string{} + if tenant.Name != record.Name { + tenant.Name = record.Name + modifiedFields = append(modifiedFields, "Name") + } + if tenant.Type != record.Type { + tenant.Type = record.Type + modifiedFields = append(modifiedFields, "Type") + } + if record.ParentTenantID != nil { + oldParentID := "" + if tenant.ParentID != nil { + oldParentID = *tenant.ParentID + } + if oldParentID != *record.ParentTenantID { + tenant.ParentID = record.ParentTenantID + modifiedFields = append(modifiedFields, "ParentID") + } + } else if tenant.ParentID != nil { + tenant.ParentID = nil + modifiedFields = append(modifiedFields, "ParentID") + } + + if tenant.Slug != record.Slug { + tenant.Slug = record.Slug + modifiedFields = append(modifiedFields, "Slug") + } + if tenant.Description != record.Memo { + tenant.Description = record.Memo + modifiedFields = append(modifiedFields, "Description") } - tenant.Name = record.Name - tenant.Type = record.Type - tenant.ParentID = record.ParentTenantID - tenant.Slug = record.Slug - tenant.Description = record.Memo if tenant.Status == "" { tenant.Status = domain.TenantStatusActive } mergedConfig, changedConfig, err := mergeTenantCSVRecordConfig(tenant.Config, record) if err != nil { - return nil, false, err + return nil, nil, err } if changedConfig { tenant.Config = mergedConfig + modifiedFields = append(modifiedFields, "Config") + } + + existingDomains := make([]string, len(tenant.Domains)) + for i, d := range tenant.Domains { + existingDomains[i] = d.Domain + } + sort.Strings(existingDomains) + newDomains := append([]string(nil), record.Domains...) + sort.Strings(newDomains) + if !reflect.DeepEqual(existingDomains, newDomains) { + modifiedFields = append(modifiedFields, "Domains") + } + + if len(modifiedFields) == 0 { + return &tenant, nil, nil } if err := h.DB.Save(&tenant).Error; err != nil { - return nil, false, err + return nil, nil, err } if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil { - return nil, false, err + return nil, nil, err } repo := repository.NewTenantRepository(h.DB) for _, domainName := range record.Domains { if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil { - return nil, false, err + return nil, nil, err } } - return &tenant, true, nil + return &tenant, modifiedFields, nil } func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) (*domain.Tenant, error) { diff --git a/common/locales/en.toml b/common/locales/en.toml index a9a3a9d3..8b6d9b81 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -26,6 +26,7 @@ subtitle = "View administrator activity history." subtitle = "View developer activity history within the current app scope." [ui.common] +no_results = "No results to display." apply = "Apply" actions = "Actions" add = "Add" diff --git a/common/locales/ko.toml b/common/locales/ko.toml index 9efc4959..57ba4cdb 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -26,6 +26,7 @@ subtitle = "관리자 작업 이력을 조회합니다." subtitle = "현재 앱 범위의 개발자 작업 이력을 조회합니다." [ui.common] +no_results = "표시할 결과가 없습니다." apply = "적용" actions = "액션" add = "추가" diff --git a/common/locales/template.toml b/common/locales/template.toml index c07c1054..9e536ab3 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -26,6 +26,7 @@ subtitle = "" subtitle = "" [ui.common] +no_results = "" apply = "Apply" actions = "" add = "" diff --git a/common/ui/badge.ts b/common/ui/badge.ts index 8d2b7db5..36f069d8 100644 --- a/common/ui/badge.ts +++ b/common/ui/badge.ts @@ -12,6 +12,8 @@ export const commonBadgeVariantClasses = { "border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300", warning: "border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", info: "border-transparent bg-blue-500 text-white hover:bg-blue-500/90", } as const; diff --git a/locales/en.toml b/locales/en.toml index dde44f75..6c33882b 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -1179,6 +1179,12 @@ pick = "Select parent scope" description = "" title = "Domain conflict" +[ui.admin.tenants.import_result] +message = "Message" +modified = "Modified:" +status = "Status" +title = "Import Result Report" + [ui.admin.tenants.import_preview] candidates = "Candidates" confirm = "Run import" @@ -1323,6 +1329,12 @@ total = "Total" total_label = "Total" view_profile = "View Profile" +[ui.admin.tenants.import_result] +message = "Message" +modified = "Modified:" +status = "Status" +title = "Import Result Report" + [ui.admin.tenants.import_preview] candidates = "Candidates" confirm = "Confirm Import" diff --git a/locales/ko.toml b/locales/ko.toml index e99be6df..f2655ee2 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -375,6 +375,12 @@ view_org_chart = "전체 조직도 보기" description = "" title = "도메인 충돌" +[ui.admin.tenants.import_result] +message = "상세 내용" +modified = "수정됨:" +status = "상태" +title = "가져오기 결과 리포트" + [ui.admin.tenants.import_preview] candidates = "후보" confirm = "가져오기 실행" @@ -1786,6 +1792,12 @@ total = "전체" total_label = "전체" view_profile = "상세 정보" +[ui.admin.tenants.import_result] +message = "상세 내용" +modified = "수정됨:" +status = "상태" +title = "가져오기 결과 리포트" + [ui.admin.tenants.import_preview] candidates = "후보" confirm = "임포트 확정" diff --git a/locales/template.toml b/locales/template.toml index bd9cf3f9..eac89aca 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -233,6 +233,12 @@ view_org_chart = "" description = "" title = "" +[ui.admin.tenants.import_result] +message = "" +modified = "" +status = "" +title = "" + [ui.admin.tenants.import_preview] candidates = "" confirm = "" @@ -1747,6 +1753,12 @@ search_placeholder = "" title = "" tree_search_placeholder = "" +[ui.admin.tenants.import_result] +message = "" +modified = "" +status = "" +title = "" + [ui.admin.tenants.import_preview] candidates = "" confirm = ""