diff --git a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx new file mode 100644 index 00000000..3f465340 --- /dev/null +++ b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx @@ -0,0 +1,128 @@ +import { useMutation } from "@tanstack/react-query"; +import { Download, FileText, Loader2, Upload } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; +import { Button } from "../../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../../components/ui/dialog"; +import { importOrgChart } from "../../../lib/adminApi"; +import { t } from "../../../lib/i18n"; + +interface OrgChartUploadModalProps { + tenantId: string; + onSuccess?: () => void; +} + +export function OrgChartUploadModal({ tenantId, onSuccess }: OrgChartUploadModalProps) { + const [open, setOpen] = React.useState(false); + const [file, setFile] = React.useState(null); + const fileInputRef = React.useRef(null); + + const mutation = useMutation({ + mutationFn: (file: File) => importOrgChart(tenantId, file), + onSuccess: () => { + toast.success(t("msg.admin.org.import_success", "조직도가 성공적으로 업로드되었습니다.")); + setOpen(false); + onSuccess?.(); + }, + onError: (error: any) => { + toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), { + description: error.response?.data?.error || error.message, + }); + }, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile) { + setFile(selectedFile); + } + }; + + const handleUpload = () => { + if (file) { + mutation.mutate(file); + } + }; + + const downloadTemplate = () => { + const headers = "email,name,organization,position,jobtitle,is_owner"; + const example = "ceo@example.com,홍길동,경영진,대표이사,경영총괄,true +cto@example.com,이몽룡,기술부문,이사,기술총괄,true +user1@example.com,성춘향,기술부문/개발팀,팀원,프론트엔드 개발,false"; + const blob = new Blob([`${headers} +${example}`], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "org_chart_template.csv"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + + + + {t("ui.admin.org.import_title", "조직도 일괄 등록")} + + {t("msg.admin.org.import_description", "CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.")} + + + +
+
+ + + +
+ + {file && ( +
+ +
+
{file.name}
+
{(file.size / 1024).toFixed(1)} KB
+
+
+ )} +
+ + + + +
+
+ ); +} diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index 74be752b..a2518662 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -47,6 +47,7 @@ import { removeGroupMember, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { OrgChartUploadModal } from "../components/OrgChartUploadModal"; type UserGroupNode = GroupSummary & { children: UserGroupNode[]; @@ -443,13 +444,19 @@ function TenantGroupsPage() { )} - +
+ groupsQuery.refetch()} + /> + +
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index f398bdd3..8ab70ca7 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -260,6 +260,21 @@ export async function removeGroupMember( ); } +export async function importOrgChart(tenantId: string, file: File) { + const formData = new FormData(); + formData.append("file", file); + const { data } = await apiClient.post( + `/v1/admin/tenants/${tenantId}/organization/import`, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + }, + ); + return data; +} + export type GroupRole = { tenantId: string; tenantName: string; diff --git a/backend/internal/service/org_chart_service.go b/backend/internal/service/org_chart_service.go index df7000fe..08e240c8 100644 --- a/backend/internal/service/org_chart_service.go +++ b/backend/internal/service/org_chart_service.go @@ -81,6 +81,11 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R orgPath := strings.TrimSpace(record[colMap["organization"]]) position := strings.TrimSpace(record[colMap["position"]]) jobTitle := strings.TrimSpace(record[colMap["jobtitle"]]) + isOwner := false + if idx, ok := colMap["is_owner"]; ok && idx < len(record) { + val := strings.ToLower(record[idx]) + isOwner = val == "true" || val == "y" || val == "1" || val == "yes" + } if email == "" || name == "" || orgPath == "" { continue @@ -125,13 +130,25 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R // 3. Sync Membership to Keto via Outbox if s.ketoOutboxRepo != nil { + // Add as member of UserGroup _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", + Namespace: "UserGroup", Object: leafID, Relation: "members", Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) + + // Add as owner if applicable + if isOwner { + _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "UserGroup", + Object: leafID, + Relation: "owners", + Subject: "User:" + kratosID, + Action: domain.KetoOutboxActionCreate, + }) + } } } @@ -161,19 +178,19 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string } // Check DB if already exists - // We search for a USER_GROUP tenant with this name and parent - // Note: This logic assumes name is unique under a parent - // For robustness, we should probably have a better lookup var existingID string - // In a real implementation, Repo should have a FindByParentAndName method - // For this implementation, we'll try to find by Name and ParentID in TenantRepo or UserGroupRepo - // Since we're using Polymorphic Tenants, let's assume we can lookup - - // For simplicity in this POC, let's just use Create logic if not in cache - // In production, we MUST check DB first to avoid duplicates - - // [Placeholder] Lookup in DB logic... - // existingID = s.lookupOrgUnit(ctx, rootTenantID, currentParentID, part) + if s.userGroupRepo != nil { + groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID) + if err == nil { + for _, g := range groups { + // Match by name and parent + if g.Name == part && ((g.ParentID == nil && currentParentID == rootTenantID) || (g.ParentID != nil && *g.ParentID == currentParentID)) { + existingID = g.ID + break + } + } + } + } if existingID == "" { // Create new unit