- 디자인 시스템: #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 성공
288 lines
7.4 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|