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:
2026-04-04 11:55:14 +09:00
parent dd6a2595f7
commit 4015e5f22f
20 changed files with 1553 additions and 8 deletions

View 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>
)}
</>
);
}