From bcbffc9ce935ae36d30d8497e382cf5638e72eb5 Mon Sep 17 00:00:00 2001 From: Johngreen Date: Thu, 12 Feb 2026 14:51:44 +0900 Subject: [PATCH] feat: add admin UI for tenant-to-digital-twin company mapping - PATCH /api/admin/tenants/{tenant_id}/digital-twin endpoint - update_tenant() supports digital_twin_company_id param - Home page mapping modal with set/clear functionality - Settings gear on tenant cards (superadmin only, visible on hover) - Tenant type includes digital_twin_company_id field --- dashboard/app/globals.css | 38 ++++++++++++ dashboard/app/page.tsx | 123 +++++++++++++++++++++++++++++++++++++- dashboard/lib/types.ts | 1 + main.py | 24 +++++++- src/tenant/manager.py | 10 ++++ 5 files changed, 192 insertions(+), 4 deletions(-) diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index 6836398..06c79bc 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -392,6 +392,7 @@ a { } .tenant-card { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -442,6 +443,43 @@ a { font-size: 14px; } +.tenant-card-settings { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: var(--md-radius-full); + background: transparent; + color: var(--md-on-surface-variant); + cursor: pointer; + opacity: 0; + transition: opacity var(--md-motion-quick), background var(--md-motion-quick); +} + +.tenant-card:hover .tenant-card-settings { + opacity: 1; +} + +.tenant-card-settings:hover { + background: rgba(0, 0, 0, 0.08); +} + +.tenant-card-settings .material-symbols-outlined { + font-size: 18px; +} + +.mapping-modal-tenant-name { + font-size: 14px; + color: var(--md-on-surface-variant); + margin-bottom: 4px; + padding: 0 24px; +} + /* ===== Login ===== */ .login-container { min-height: 100vh; diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx index 5000b54..12377b2 100644 --- a/dashboard/app/page.tsx +++ b/dashboard/app/page.tsx @@ -1,19 +1,62 @@ 'use client'; +import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; import { useTenants } from '@/lib/hooks'; import { Card } from '@/components/Card'; +import { api } from '@/lib/api'; +import { Tenant } from '@/lib/types'; export default function HomePage() { const { user, logout } = useAuth(); - const { tenants, isLoading } = useTenants(); + const { tenants, isLoading, mutate } = useTenants(); const router = useRouter(); + + const [mappingModal, setMappingModal] = useState<{ + open: boolean; + tenantId: string; + tenantName: string; + companyId: string; + }>({ + open: false, + tenantId: '', + tenantName: '', + companyId: '', + }); + const [isSaving, setIsSaving] = useState(false); const handleTenantSelect = (tenantId: string) => { router.push(`/${tenantId}`); }; + const handleOpenMapping = (e: React.MouseEvent, tenant: Tenant) => { + e.stopPropagation(); + setMappingModal({ + open: true, + tenantId: tenant.id, + tenantName: tenant.name, + companyId: tenant.digital_twin_company_id || '', + }); + }; + + const handleSaveMapping = async () => { + if (!mappingModal.tenantId) return; + + setIsSaving(true); + try { + await api.patch(`/api/admin/tenants/${mappingModal.tenantId}/digital-twin`, { + digital_twin_company_id: mappingModal.companyId.trim() || null, + }); + await mutate(); + setMappingModal((prev) => ({ ...prev, open: false })); + } catch (err: any) { + alert(err.message || '저장 중 오류가 발생했습니다.'); + } finally { + setIsSaving(false); + } + }; + return (
@@ -41,11 +84,23 @@ export default function HomePage() { ) : (
{tenants.map((tenant) => ( - + )} factory {tenant.name} {tenant.id} @@ -55,12 +110,74 @@ export default function HomePage() { 디지털 트윈 연동 )} - +
))}
)}
+ + {mappingModal.open && ( +
setMappingModal((prev) => ({ ...prev, open: false }))}> +
e.stopPropagation()}> +
+

디지털 트윈 매핑

+ +
+ +
+ {mappingModal.tenantName} ({mappingModal.tenantId}) +
+ +
+
+ + setMappingModal((prev) => ({ ...prev, companyId: e.target.value }))} + placeholder="회사 UUID (예: 7f5c058c-...)" + autoFocus + /> +
+
+ +
+ {mappingModal.companyId && ( + + )} + + +
+
+
+ )} ); } diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index 1b88b5b..4ca57dc 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -13,6 +13,7 @@ export interface Tenant { industry_type: string; is_active: boolean; has_digital_twin: boolean; + digital_twin_company_id: string | null; created_at: string | null; } diff --git a/main.py b/main.py index 14275ff..7d3b1b6 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ import logging import os from contextlib import asynccontextmanager -from typing import List +from typing import List, Optional from dotenv import load_dotenv @@ -72,6 +72,10 @@ class TenantCreate(BaseModel): industry_type: str = "general" +class TenantDigitalTwinMapping(BaseModel): + digital_twin_company_id: Optional[str] = None + + @app.get("/api/tenants") async def list_accessible_tenants( current_user: TokenData = Depends(require_auth), @@ -133,6 +137,24 @@ async def get_tenant( raise HTTPException(status_code=404, detail=str(e)) +@app.patch("/api/admin/tenants/{tenant_id}/digital-twin") +async def update_tenant_digital_twin_mapping( + body: TenantDigitalTwinMapping, + tenant_id: str = Path(..., pattern=r"^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$"), + current_user: TokenData = Depends(require_superadmin), + db: AsyncSession = Depends(get_db), +): + try: + result = await tenant_manager.update_tenant( + db, tenant_id, digital_twin_company_id=body.digital_twin_company_id + ) + return {"status": "success", "tenant": result} + except TenantNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except InvalidTenantIdError as e: + raise HTTPException(status_code=400, detail=str(e)) + + if __name__ == "__main__": import uvicorn diff --git a/src/tenant/manager.py b/src/tenant/manager.py index bd0ef77..2858d20 100644 --- a/src/tenant/manager.py +++ b/src/tenant/manager.py @@ -45,6 +45,9 @@ async def get_tenant(db: AsyncSession, tenant_id: str) -> Dict: "industry_type": str(tenant.industry_type), "is_active": bool(tenant.is_active), "has_digital_twin": bool(tenant.digital_twin_company_id), + "digital_twin_company_id": str(tenant.digital_twin_company_id) + if tenant.digital_twin_company_id + else None, "created_at": _format_timestamp(tenant.created_at), } @@ -101,6 +104,9 @@ async def list_tenants(db: AsyncSession) -> List[Dict]: "industry_type": str(t.industry_type), "is_active": bool(t.is_active), "has_digital_twin": bool(t.digital_twin_company_id), + "digital_twin_company_id": str(t.digital_twin_company_id) + if t.digital_twin_company_id + else None, "created_at": _format_timestamp(t.created_at), } for t in tenants @@ -112,6 +118,7 @@ async def update_tenant( tenant_id: str, name: Optional[str] = None, is_active: Optional[bool] = None, + digital_twin_company_id: Optional[str] = "__UNSET__", ) -> Dict: if not validate_tenant_id(tenant_id): raise InvalidTenantIdError(f"유효하지 않은 테넌트 ID: {tenant_id}") @@ -125,6 +132,8 @@ async def update_tenant( tenant.name = name # type: ignore[assignment] if is_active is not None: tenant.is_active = is_active # type: ignore[assignment] + if digital_twin_company_id != "__UNSET__": + tenant.digital_twin_company_id = digital_twin_company_id or None # type: ignore[assignment] await db.commit() await db.refresh(tenant) @@ -134,5 +143,6 @@ async def update_tenant( "name": str(tenant.name), "industry_type": str(tenant.industry_type), "is_active": bool(tenant.is_active), + "has_digital_twin": bool(tenant.digital_twin_company_id), "created_at": _format_timestamp(tenant.created_at), }