feat: add admin UI for tenant-to-digital-twin company mapping
All checks were successful
Deploy to Production / deploy (push) Successful in 1m7s

- 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
This commit is contained in:
Johngreen
2026-02-12 14:51:44 +09:00
parent fcd0b78f3c
commit bcbffc9ce9
5 changed files with 192 additions and 4 deletions

View File

@@ -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;

View File

@@ -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 (
<div className="home-container">
<div className="home-header">
@@ -41,11 +84,23 @@ export default function HomePage() {
) : (
<div className="tenant-grid">
{tenants.map((tenant) => (
<button
<div
key={tenant.id}
className="tenant-card"
role="button"
tabIndex={0}
onClick={() => handleTenantSelect(tenant.id)}
onKeyDown={(e) => { if (e.key === 'Enter') handleTenantSelect(tenant.id); }}
>
{user?.role === 'superadmin' && (
<button
className="tenant-card-settings"
onClick={(e) => handleOpenMapping(e, tenant)}
title="디지털 트윈 매핑 설정"
>
<span className="material-symbols-outlined">settings</span>
</button>
)}
<span className="material-symbols-outlined tenant-icon">factory</span>
<span className="tenant-name">{tenant.name}</span>
<span className="tenant-id">{tenant.id}</span>
@@ -55,12 +110,74 @@ export default function HomePage() {
</span>
)}
</button>
</div>
))}
</div>
)}
</Card>
</div>
{mappingModal.open && (
<div className="modal-overlay" onClick={() => setMappingModal((prev) => ({ ...prev, open: false }))}>
<div className="modal-content modal-sm" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3> </h3>
<button
className="modal-close"
onClick={() => setMappingModal((prev) => ({ ...prev, open: false }))}
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="mapping-modal-tenant-name">
{mappingModal.tenantName} ({mappingModal.tenantId})
</div>
<div className="modal-body-form">
<div className="form-field">
<label> ID</label>
<input
type="text"
value={mappingModal.companyId}
onChange={(e) => setMappingModal((prev) => ({ ...prev, companyId: e.target.value }))}
placeholder="회사 UUID (예: 7f5c058c-...)"
autoFocus
/>
</div>
</div>
<div className="modal-actions">
{mappingModal.companyId && (
<button
className="btn-outline btn-danger"
onClick={() => {
setMappingModal((prev) => ({ ...prev, companyId: '' }));
}}
disabled={isSaving}
style={{ marginRight: 'auto' }}
>
</button>
)}
<button
className="btn-text"
onClick={() => setMappingModal((prev) => ({ ...prev, open: false }))}
disabled={isSaving}
>
</button>
<button
className="btn-primary"
onClick={handleSaveMapping}
disabled={isSaving}
>
{isSaving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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;
}

24
main.py
View File

@@ -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

View File

@@ -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),
}