Files
hanarang-dashboard/frontend/components/org/OrgTree.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

288 lines
7.4 KiB
TypeScript

'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import StatusBadge from '../common/StatusBadge';
import { theme } from '@/styles/theme';
type Status = 'online' | 'offline' | 'working' | 'unknown';
interface OrgNodeData {
id?: number;
name: string;
role: string;
description?: string;
status?: Status;
lastSeen?: string | null;
lxcId?: number;
isOwner?: boolean;
}
interface PopupState {
node: OrgNodeData;
x: number;
y: number;
}
const sisterEmojis: Record<string, string> = {
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺', : '👤',
};
const sisterDisplayNames: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
const statusColors: Record<Status, string> = {
online: '#00FF00',
offline: '#FF1744',
working: '#2979FF',
unknown: 'var(--text-secondary)',
};
// ─── 조직도 레이아웃 ───
const Tree = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 20px 0;
position: relative;
overflow-x: auto;
`;
const Level = styled.div`
display: flex;
justify-content: center;
gap: 24px;
position: relative;
`;
const ConnectorV = styled.div`
width: 2px;
height: 32px;
background: linear-gradient(to bottom, var(--border-color), rgba(88,166,255,0.4));
margin: 0 auto;
`;
const ConnectorH = styled.div`
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
height: 2px;
background: rgba(88,166,255,0.3);
width: calc(100% - 100px);
`;
const BranchWrap = styled.div`
position: relative;
display: flex;
gap: 24px;
align-items: flex-start;
`;
const NodeCard = styled.div<{ $status?: Status; $isOwner?: boolean }>`
background: var(--bg-surface);
backdrop-filter: blur(12px);
border: 1px solid ${({ $status }) =>
$status ? `${statusColors[$status]}66` : 'var(--border-color)'};
border-radius: 12px;
padding: ${({ $isOwner }) => $isOwner ? '14px 24px' : '12px 18px'};
min-width: ${({ $isOwner }) => $isOwner ? '140px' : '130px'};
text-align: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
position: relative;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
border-color: ${({ $status }) => $status ? statusColors[$status] : '#58A6FF'};
}
`;
const NodeEmoji = styled.div`
font-size: 22px;
margin-bottom: 4px;
`;
const NodeName = styled.div`
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 2px;
`;
const NodeRole = styled.div`
font-size: 11px;
color: var(--text-secondary);
`;
const StatusDot = styled.div<{ $status: Status }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $status }) => statusColors[$status]};
position: absolute;
top: 8px;
right: 8px;
${({ $status }) => $status === 'online' && `
box-shadow: 0 0 6px #00FF00;
`}
`;
const PipelineLabel = styled.div`
font-size: 10px;
color: rgba(88,166,255,0.6);
text-align: center;
margin-top: 2px;
`;
// Popup
const Popup = styled.div<{ $x: number; $y: number }>`
position: fixed;
left: ${({ $x }) => $x}px;
top: ${({ $y }) => $y}px;
z-index: 200;
background: rgba(22,27,34,0.97);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 14px 16px;
min-width: 200px;
max-width: 260px;
pointer-events: none;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
`;
const PopupName = styled.div`
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
`;
const PopupRole = styled.div`
font-size: 12px;
color: #58A6FF;
margin-bottom: 8px;
`;
const PopupDesc = styled.div`
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
`;
function formatLastSeen(lastSeen: string | null | undefined): string {
if (!lastSeen) return '기록 없음';
const diff = Date.now() - new Date(lastSeen).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return '방금 전';
if (min < 60) return `${min}분 전`;
return `${Math.floor(min / 60)}시간 전`;
}
interface OrgTreeProps {
sisters: OrgNodeData[];
}
export default function OrgTree({ sisters }: OrgTreeProps) {
const [popup, setPopup] = useState<PopupState | null>(null);
const harang = sisters.find((s) => s.name === 'harang');
const narang = sisters.find((s) => s.name === 'narang');
const darang = sisters.find((s) => s.name === 'darang');
const erang = sisters.find((s) => s.name === 'erang');
const handleHover = (node: OrgNodeData, e: React.MouseEvent) => {
const rect = (e.target as HTMLElement).getBoundingClientRect();
setPopup({ node, x: rect.right + 10, y: rect.top });
};
const renderNode = (node: OrgNodeData | undefined, isOwner = false) => {
if (!node) return null;
const displayName = sisterDisplayNames[node.name] ?? node.name;
const status = node.status ?? 'unknown';
const isClickable = !isOwner;
return (
<NodeCard
as={isClickable ? Link : 'div'}
{...(isClickable ? { href: `/sisters/${node.name}` } : {})}
$status={isOwner ? undefined : status as Status}
$isOwner={isOwner}
onMouseEnter={(e: React.MouseEvent) => handleHover(node, e)}
onMouseLeave={() => setPopup(null)}
>
{!isOwner && <StatusDot $status={status as Status} />}
<NodeEmoji>{sisterEmojis[node.name] ?? '🤖'}</NodeEmoji>
<NodeName>{displayName}</NodeName>
<NodeRole>{node.role}</NodeRole>
</NodeCard>
);
};
return (
<>
<Tree>
{/* 자기야 */}
<Level>
{renderNode({ name: '자기야', role: 'Owner', isOwner: true }, true)}
</Level>
<ConnectorV />
{/* 하랑이 */}
<Level>
{renderNode(harang)}
</Level>
{/* 파이프라인 라벨 */}
<div style={{ display: 'flex', gap: '80px', marginTop: '4px' }}>
<PipelineLabel> </PipelineLabel>
<PipelineLabel> </PipelineLabel>
</div>
<ConnectorV />
{/* 나랑이 / 다랑이 / 이랑이 */}
<div style={{ position: 'relative' }}>
<Level>
{renderNode(narang)}
{renderNode(darang)}
{renderNode(erang)}
</Level>
<ConnectorH />
</div>
{/* 파이프라인 설명 */}
<div style={{ display: 'flex', gap: '16px', marginTop: '12px', justifyContent: 'center' }}>
<PipelineLabel>Generator</PipelineLabel>
<PipelineLabel> / </PipelineLabel>
<PipelineLabel>Evaluator</PipelineLabel>
<PipelineLabel style={{ marginLeft: '8px' }}>Infra</PipelineLabel>
</div>
</Tree>
{popup && (
<Popup $x={popup.x} $y={popup.y}>
<PopupName>
{sisterEmojis[popup.node.name] ?? '🤖'}{' '}
{sisterDisplayNames[popup.node.name] ?? popup.node.name}
</PopupName>
<PopupRole>{popup.node.role}</PopupRole>
{popup.node.description && (
<PopupDesc>{popup.node.description}</PopupDesc>
)}
{!popup.node.isOwner && (
<PopupDesc style={{ marginTop: '8px' }}>
: {popup.node.status ?? 'unknown'}<br />
: {formatLastSeen(popup.node.lastSeen)}
</PopupDesc>
)}
</Popup>
)}
</>
);
}