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) => (
-
+
))}
)}
+
+ {mappingModal.open && (
+ setMappingModal((prev) => ({ ...prev, open: false }))}>
+
e.stopPropagation()}>
+
+
디지털 트윈 매핑
+ setMappingModal((prev) => ({ ...prev, open: false }))}
+ >
+ close
+
+
+
+
+ {mappingModal.tenantName} ({mappingModal.tenantId})
+
+
+
+
+
+ setMappingModal((prev) => ({ ...prev, companyId: e.target.value }))}
+ placeholder="회사 UUID (예: 7f5c058c-...)"
+ autoFocus
+ />
+
+
+
+
+ {mappingModal.companyId && (
+ {
+ setMappingModal((prev) => ({ ...prev, companyId: '' }));
+ }}
+ disabled={isSaving}
+ style={{ marginRight: 'auto' }}
+ >
+ 매핑 해제
+
+ )}
+ setMappingModal((prev) => ({ ...prev, open: false }))}
+ disabled={isSaving}
+ >
+ 취소
+
+
+ {isSaving ? '저장 중...' : '저장'}
+
+
+
+
+ )}
);
}
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),
}