diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index b535cc9b..9351e1aa 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -285,6 +285,9 @@ function TenantListPage() { Record >({}); const [previewOpen, setPreviewOpen] = React.useState(false); + const [importResult, setImportResult] = + React.useState(null); + const [importResultOpen, setImportResultOpen] = React.useState(false); const [selectedBulkStatus, setSelectedBulkStatus] = React.useState(""); const _tenantTableScrollRef = React.useRef(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() { )} + + + + + {t( + "ui.admin.tenants.import_result.title", + "가져오기 결과 리포트", + )} + + + {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, + }, + )} + + + +
+ + + + + {t("ui.common.row", "행")} + + + {t("ui.admin.tenants.table.name", "NAME")} + + + {t("ui.admin.tenants.table.slug", "SLUG")} + + + {t("ui.admin.tenants.import_result.action", "작업")} + + + {t("ui.admin.tenants.import_result.status", "상태")} + + + {t("ui.admin.tenants.import_result.message", "상세 내용")} + + + + + {importResult?.details.map((detail) => ( + + + {detail.row} + + {detail.name} + + {detail.slug} + + + + {detail.action.toUpperCase()} + + + + {detail.success ? ( + + SUCCESS + + ) : ( + + FAILED + + )} + + + {detail.message} + {detail.modifiedFields && + detail.modifiedFields.length > 0 && ( +
+ + {t( + "ui.admin.tenants.import_result.modified", + "수정됨:", + )} + + {detail.modifiedFields.map((field) => ( + + {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/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 3082ba5b..38a22dd7 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -102,11 +102,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 { @@ -557,32 +568,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 } } @@ -594,19 +637,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) @@ -1385,12 +1441,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 @@ -1403,42 +1459,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) {