Files
hanarang-dashboard/frontend/app/org/page.tsx
narang-ai 230d3847ef feat(redesign-v2): 미니멀 터미널 UI 전체 리디자인
- 디자인 시스템: #151515 배경, 1px 보더 카드, CSS vars, mono 포인트
- GlobalStyle/theme.ts v2 (DESIGN-SYSTEM.md 기반)
- 공통 컴포넌트 (ui/base.tsx): LabelMeta, BracketValue, Card, TechBar, Timeline 등
- Sidebar: [대시] 브라켓 네비 + 모바일 하단 탭바 (767px)
- LayoutShell: SidebarContext 제거, 단순화
- 대시보드 (/): SYS 상태 카드 4개 + ONGOING PROJECTS + ACTIVITY FEED
- 활동 로그 (/activities): 로그 테이블 + 필터 + 검색 + 페이지네이션 (신규)
- 설정 (/settings): 4섹션 Toggle/Bracket Input 그리드
- 자매 노드 관리 (/sisters): 3열 수평 레이아웃 (INFO/GRAPH/SYNC)
- 자매 상세 (/sisters/[name]): 간소화 + 터미널 스타일
- 조직도 (/org): 터미널 카드 트리 구조 + 레벨 색상
- 프로젝트 목록 (/projects): 새 라우트
- 프로젝트 상세 (/projects/[id]): Phase Timeline + Audit Log + Checklist
- FE 14 routes build 성공
2026-04-04 13:10:57 +09:00

281 lines
7.5 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { API_URL } from '@/lib/config';
// ─── Styled ───
const MainViewport = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-xl);
min-height: 80vh;
`;
const HeaderInfo = styled.div`
display: flex;
gap: var(--space-xl);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
span { color: var(--text-primary); }
`;
const TreeContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
overflow-x: auto;
padding: var(--space-xl) 0;
@media (max-width: 767px) {
align-items: flex-start;
}
`;
const NodeGroup = styled.div`
display: flex;
flex-direction: column;
align-items: center;
position: relative;
`;
const TerminalCard = styled.div<{ $level: 'high' | 'mid' | 'low' | 'none' }>`
border: 1px solid ${({ $level }) => ({
high: '#f43f5e',
mid: '#3b82f6',
low: '#10b981',
none: '#525252',
})[$level]};
border-left-width: 3px;
padding: var(--space-md) var(--space-lg);
background: var(--bg-main);
min-width: 200px;
max-width: 260px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
transition: border-color 0.2s, box-shadow 0.2s;
position: relative;
cursor: default;
&:hover {
box-shadow: 0 12px 36px rgba(0,0,0,0.7);
}
`;
const NodeLabel = styled.div`
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-sm);
span {
color: var(--text-secondary);
margin-right: var(--space-xs);
font-family: var(--font-mono);
font-size: 11px;
}
`;
const BadgeRow = styled.div`
display: flex;
gap: var(--space-xs);
flex-wrap: wrap;
`;
const Badge = styled.div`
background: #1a1a1a;
border: 1px solid var(--border-color);
padding: 2px 6px;
font-size: 9px;
font-family: var(--font-mono);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
b { color: var(--text-primary); font-weight: normal; }
`;
const TreeConnector = styled.div`
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 auto;
`;
const TreeBranch = styled.div`
display: flex;
flex-direction: column;
align-items: center;
`;
const TreeChildren = styled.div`
display: flex;
gap: var(--space-xl);
align-items: flex-start;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: calc(var(--space-xl) / 2 + 100px);
right: calc(var(--space-xl) / 2 + 100px);
height: 1px;
background: var(--border-color);
}
@media (max-width: 767px) {
flex-direction: column;
gap: var(--space-lg);
&::before { display: none; }
}
`;
const PageFooter = styled.footer`
margin-top: auto;
width: 100%;
padding: var(--space-lg) 0;
display: flex;
justify-content: space-between;
border-top: 1px solid var(--border-color);
font-family: var(--font-mono);
color: var(--text-secondary);
font-size: 10px;
`;
type Level = 'high' | 'mid' | 'low' | 'none';
interface OrgData {
owner: { name: string; role: string };
sisters: Array<{
name: string;
role: string;
description?: string;
status?: string;
lxcId?: number;
}>;
pipeline: Array<{ from: string; to: string; label: string }>;
}
const LEVEL_MAP: Record<string, Level> = {
Orchestrator: 'high',
Generator: 'mid',
Evaluator: 'mid',
'Infra Manager': 'low',
};
const ROLE_TAG: Record<string, string> = {
Orchestrator: 'ORCH',
Generator: 'GEN',
Evaluator: 'EVAL',
'Infra Manager': 'INFRA',
};
const LXC_ID: Record<string, number> = {
harang: 104,
narang: 105,
darang: 106,
erang: 107,
};
export default function OrgPage() {
const [orgData, setOrgData] = useState<OrgData | null>(null);
const ts = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '.').replace('.', '') + '_' +
new Date().toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit' });
useEffect(() => {
fetch(`${API_URL}/api/org`)
.then((r) => r.json())
.then(setOrgData)
.catch(() => {});
}, []);
const sisters = orgData?.sisters ?? [];
return (
<MainViewport>
<HeaderInfo>
<div>SYS_TYPE: <span>CORE_ROOT_01</span></div>
<div>TIMESTAMP: <span style={{ color: 'var(--text-secondary)' }}>{ts}</span></div>
</HeaderInfo>
<TreeContainer>
<NodeGroup>
{/* HQ */}
<TerminalCard $level="high">
<NodeLabel><span>[HQ]</span> </NodeLabel>
<BadgeRow>
<Badge>MEM: <b>{sisters.length + 1}</b></Badge>
<Badge>S_ADMIN</Badge>
<Badge>LV_09</Badge>
</BadgeRow>
</TerminalCard>
<TreeConnector />
{/* 자매 레벨 */}
<TreeBranch>
<TreeChildren>
{/* 하랑이 (Orchestrator) */}
{sisters.filter((s) => s.role === 'Orchestrator').map((s) => (
<NodeGroup key={s.name}>
<TerminalCard $level={LEVEL_MAP[s.role] ?? 'none'}>
<NodeLabel>
<span>[{ROLE_TAG[s.role] ?? s.role}]</span>
{s.name === 'harang' ? '하랑이' : s.name}
</NodeLabel>
<BadgeRow>
<Badge>LXC: <b>{LXC_ID[s.name] ?? '---'}</b></Badge>
<Badge>{ROLE_TAG[s.role] ?? s.role}</Badge>
<Badge>{s.status === 'online' ? 'ACTIVE' : s.status === 'offline' ? 'STBY' : 'RUN'}</Badge>
</BadgeRow>
</TerminalCard>
<TreeConnector />
<TreeBranch>
<TreeChildren>
{/* 나랑/다랑/이랑 */}
{sisters.filter((s2) => s2.role !== 'Orchestrator').map((s2) => (
<NodeGroup key={s2.name}>
<TerminalCard $level={LEVEL_MAP[s2.role] ?? 'none'}>
<NodeLabel>
{s2.name === 'narang' ? '나랑이' : s2.name === 'darang' ? '다랑이' : '이랑이'}
</NodeLabel>
<BadgeRow>
<Badge>LXC: <b>{LXC_ID[s2.name] ?? '---'}</b></Badge>
<Badge>{ROLE_TAG[s2.role] ?? s2.role}</Badge>
<Badge>{s2.status === 'online' ? 'ON' : s2.status === 'offline' ? '--' : 'RUN'}</Badge>
</BadgeRow>
</TerminalCard>
</NodeGroup>
))}
</TreeChildren>
</TreeBranch>
</NodeGroup>
))}
{/* Orchestrator 없을 때 fallback */}
{sisters.filter((s) => s.role === 'Orchestrator').length === 0 && (
<NodeGroup>
<TerminalCard $level="none">
<NodeLabel> </NodeLabel>
<BadgeRow><Badge>N/A</Badge></BadgeRow>
</TerminalCard>
</NodeGroup>
)}
</TreeChildren>
</TreeBranch>
</NodeGroup>
</TreeContainer>
<PageFooter>
<div>TOTAL_NODES: {String(sisters.length + 1).padStart(2, '0')}</div>
<div>STATUS: SYNC_COMPLETE</div>
</PageFooter>
</MainViewport>
);
}