feat: add admin UI for tenant-to-digital-twin company mapping
All checks were successful
Deploy to Production / deploy (push) Successful in 1m7s
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
24
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
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user