feat(sprint-003): sister detail + org chart + settings viewer
Sprint 002 non-blocking:
- API_URL/POLL_INTERVAL lib/config.ts 공통 추출
- 폴링 간격 NEXT_PUBLIC_POLL_INTERVAL_MS env화
Sprint 003 본문:
- TASK-008: SisterDetailService (config/sessions/subagents/activity 조회)
- TASK-008: GET /api/sisters/:name/{config,sessions,subagents,activity}
- TASK-008: GET /api/org → 조직도 + 파이프라인 데이터
- TASK-009: /sisters 목록 페이지 (카드 클릭 → 상세 이동)
- TASK-009: /sisters/[name] 상세 페이지 (탭: 개요/설정/세션/서브에이전트)
- TASK-009: SisterCard → /sisters/:name 링크 연결
- TASK-010: /org 조직도 페이지 (OrgTree 컴포넌트, 호버 팝업)
- TASK-011: /settings 설정 뷰어 페이지 (자매 선택 드롭다운)
- 테스트 16/16 pass, FE build 성공 (7 routes)
This commit is contained in:
287
frontend/components/org/OrgTree.tsx
Normal file
287
frontend/components/org/OrgTree.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
'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: theme.colors.online,
|
||||
offline: theme.colors.offline,
|
||||
working: theme.colors.working,
|
||||
unknown: theme.colors.textSecondary,
|
||||
};
|
||||
|
||||
// ─── 조직도 레이아웃 ───
|
||||
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, ${theme.colors.border}, 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: ${theme.colors.cardBg};
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid ${({ $status }) =>
|
||||
$status ? `${statusColors[$status]}66` : theme.colors.border};
|
||||
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] : theme.colors.accent};
|
||||
}
|
||||
`;
|
||||
|
||||
const NodeEmoji = styled.div`
|
||||
font-size: 22px;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const NodeName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: ${theme.colors.textPrimary};
|
||||
margin-bottom: 2px;
|
||||
`;
|
||||
|
||||
const NodeRole = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${theme.colors.textSecondary};
|
||||
`;
|
||||
|
||||
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 ${theme.colors.online};
|
||||
`}
|
||||
`;
|
||||
|
||||
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 ${theme.colors.border};
|
||||
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: ${theme.colors.textPrimary};
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const PopupRole = styled.div`
|
||||
font-size: 12px;
|
||||
color: ${theme.colors.accent};
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const PopupDesc = styled.div`
|
||||
font-size: 12px;
|
||||
color: ${theme.colors.textSecondary};
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user