forked from baron/baron-sso
feat(admin): add org chart bulk import button to main tenants list page (#500)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
|
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -100,6 +101,9 @@ function TenantListPage() {
|
|||||||
|
|
||||||
const tenants = query.data?.items ?? [];
|
const tenants = query.data?.items ?? [];
|
||||||
|
|
||||||
|
// [New] Find a primary COMPANY_GROUP tenant to act as the root for matrix org charts
|
||||||
|
const rootTenant = tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
|
||||||
|
|
||||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
@@ -130,6 +134,16 @@ function TenantListPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* [New] Add Upload Modal to global list page, visible to Super Admin */}
|
||||||
|
<RoleGuard roles={["super_admin"]}>
|
||||||
|
{rootTenant && (
|
||||||
|
<OrgChartUploadModal
|
||||||
|
tenantId={rootTenant.id}
|
||||||
|
onSuccess={() => query.refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RoleGuard>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => query.refetch()}
|
onClick={() => query.refetch()}
|
||||||
|
|||||||
@@ -115,41 +115,51 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Process Hierarchy (Build path from multiple columns)
|
// Extract domain from email
|
||||||
var parts []string
|
parts := strings.Split(email, "@")
|
||||||
|
domainName := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
domainName = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Ensure Company Tenant Exists (The Core Requirement)
|
||||||
|
companySlug := s.generateCompanySlug(companyName)
|
||||||
|
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, domainName, pathCache)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to ensure company tenant", "company", companyName, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process Hierarchy (Build path from multiple columns under the Company)
|
||||||
|
var orgParts []string
|
||||||
for _, idx := range hierarchyIdx {
|
for _, idx := range hierarchyIdx {
|
||||||
val := strings.TrimSpace(record[idx])
|
val := strings.TrimSpace(record[idx])
|
||||||
if val != "" && val != "-" {
|
if val != "" && val != "-" {
|
||||||
parts = append(parts, val)
|
orgParts = append(orgParts, val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
orgPath := strings.Join(parts, "/")
|
orgPath := strings.Join(orgParts, "/")
|
||||||
|
|
||||||
leafID := tenantID // Default to root
|
leafID := companyTenantID // Default to company tenant
|
||||||
if orgPath != "" {
|
if orgPath != "" {
|
||||||
leafID, err = s.ensureOrgPath(ctx, tenantID, orgPath, pathCache)
|
leafID, err = s.ensureOrgPath(ctx, companyTenantID, orgPath, pathCache)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to ensure hierarchy", "path", orgPath, "error", err)
|
slog.Error("Failed to ensure hierarchy", "path", orgPath, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Ensure User exists in Kratos (Auto-create if missing)
|
// 3. Ensure User exists in Kratos (Auto-create if missing)
|
||||||
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
|
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
|
||||||
if err != nil || kratosID == "" {
|
if err != nil || kratosID == "" {
|
||||||
slog.Info("User not found in Kratos, auto-creating...", "email", email)
|
slog.Info("User not found in Kratos, auto-creating...", "email", email)
|
||||||
|
|
||||||
// Map company name to slug (Simple mapping for now)
|
|
||||||
companyCode := strings.ToLower(companyName)
|
|
||||||
if companyCode == "한맥" { companyCode = "hanmac" }
|
|
||||||
if companyCode == "삼안" { companyCode = "saman" }
|
|
||||||
|
|
||||||
brokerUser := &domain.BrokerUser{
|
brokerUser := &domain.BrokerUser{
|
||||||
Email: email,
|
Email: email,
|
||||||
Name: name,
|
Name: name,
|
||||||
Attributes: map[string]interface{}{
|
Attributes: map[string]interface{}{
|
||||||
"affiliationType": "AFFILIATE",
|
"affiliationType": "AFFILIATE",
|
||||||
"companyCode": companyCode,
|
"companyCode": companySlug,
|
||||||
"department": orgPath,
|
"department": orgPath,
|
||||||
"grade": "member",
|
"grade": "member",
|
||||||
},
|
},
|
||||||
@@ -163,11 +173,7 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
|
|||||||
kratosID = newID
|
kratosID = newID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update User in Local DB
|
// 4. Update User in Local DB (Bind to Company Tenant)
|
||||||
companyCode := strings.ToLower(companyName)
|
|
||||||
if companyCode == "한맥" { companyCode = "hanmac" }
|
|
||||||
if companyCode == "삼안" { companyCode = "saman" }
|
|
||||||
|
|
||||||
user := &domain.User{
|
user := &domain.User{
|
||||||
ID: kratosID,
|
ID: kratosID,
|
||||||
Email: email,
|
Email: email,
|
||||||
@@ -175,8 +181,8 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
|
|||||||
Position: position,
|
Position: position,
|
||||||
JobTitle: jobTitle,
|
JobTitle: jobTitle,
|
||||||
Department: orgPath,
|
Department: orgPath,
|
||||||
TenantID: &leafID,
|
TenantID: &companyTenantID, // 편입: 사용자의 메인 소속은 회사(COMPANY)
|
||||||
CompanyCode: companyCode,
|
CompanyCode: companySlug,
|
||||||
AffiliationType: "AFFILIATE",
|
AffiliationType: "AFFILIATE",
|
||||||
Status: "active",
|
Status: "active",
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
@@ -187,9 +193,19 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Sync Membership to Keto
|
// 5. Sync Membership to Keto
|
||||||
if s.ketoOutboxRepo != nil {
|
if s.ketoOutboxRepo != nil {
|
||||||
// Add as member of the specific unit
|
// Add as member of the Company Tenant
|
||||||
|
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: companyTenantID,
|
||||||
|
Relation: "members",
|
||||||
|
Subject: "User:" + kratosID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add as member of the specific Department unit (if exists)
|
||||||
|
if leafID != companyTenantID {
|
||||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
Namespace: "Tenant",
|
Namespace: "Tenant",
|
||||||
Object: leafID,
|
Object: leafID,
|
||||||
@@ -197,8 +213,9 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
|
|||||||
Subject: "User:" + kratosID,
|
Subject: "User:" + kratosID,
|
||||||
Action: domain.KetoOutboxActionCreate,
|
Action: domain.KetoOutboxActionCreate,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// If owner/leader, assign owner role
|
// If owner/leader, assign owner role to the leaf unit
|
||||||
if isOwner {
|
if isOwner {
|
||||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
Namespace: "Tenant",
|
Namespace: "Tenant",
|
||||||
@@ -214,6 +231,79 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [New] Maps korean names to official slugs
|
||||||
|
func (s *orgChartService) generateCompanySlug(name string) string {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if strings.Contains(n, "한맥") || strings.Contains(n, "hanmac") {
|
||||||
|
return "hanmac"
|
||||||
|
}
|
||||||
|
if strings.Contains(n, "삼안") || strings.Contains(n, "saman") {
|
||||||
|
return "saman"
|
||||||
|
}
|
||||||
|
if strings.Contains(n, "장헌") || strings.Contains(n, "jangheon") {
|
||||||
|
return "jangheon"
|
||||||
|
}
|
||||||
|
if strings.Contains(n, "평화") || strings.Contains(n, "ptc") {
|
||||||
|
return "ptc"
|
||||||
|
}
|
||||||
|
if strings.Contains(n, "바론") || strings.Contains(n, "baron") {
|
||||||
|
return "baron"
|
||||||
|
}
|
||||||
|
if strings.Contains(n, "한라") || strings.Contains(n, "halla") {
|
||||||
|
return "halla"
|
||||||
|
}
|
||||||
|
// Fallback for unknown companies
|
||||||
|
return "comp-" + uuid.NewString()[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
// [New] Ensures the COMPANY tenant exists and binds the domain
|
||||||
|
func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootTenantID, name, slug, domainName string, cache map[string]string) (string, error) {
|
||||||
|
cacheKey := "company:" + slug
|
||||||
|
if id, ok := cache[cacheKey]; ok {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := s.tenantRepo.FindBySlug(ctx, slug)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "record not found") {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tenant == nil {
|
||||||
|
// Auto-create missing company tenant
|
||||||
|
slog.Info("Auto-creating missing company tenant", "name", name, "slug", slug)
|
||||||
|
tenant = &domain.Tenant{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Name: name,
|
||||||
|
Slug: slug,
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
ParentID: &rootTenantID, // Bind to the root COMPANY_GROUP
|
||||||
|
}
|
||||||
|
if err := s.tenantRepo.Create(ctx, tenant); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync hierarchy to Keto (Company belongs to Group)
|
||||||
|
if s.ketoOutboxRepo != nil {
|
||||||
|
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenant.ID,
|
||||||
|
Relation: "parents",
|
||||||
|
Subject: "Tenant:" + rootTenantID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the email domain is registered to this company
|
||||||
|
if domainName != "" {
|
||||||
|
_ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainName, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache[cacheKey] = tenant.ID
|
||||||
|
return tenant.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, path string, cache map[string]string) (string, error) {
|
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, path string, cache map[string]string) (string, error) {
|
||||||
parts := strings.Split(path, "/")
|
parts := strings.Split(path, "/")
|
||||||
currentParentID := rootTenantID
|
currentParentID := rootTenantID
|
||||||
|
|||||||
Reference in New Issue
Block a user