Files
hanarang-dashboard/frontend/app/sisters/page.tsx
narang-ai 4015e5f22f 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)
2026-04-04 11:55:14 +09:00

173 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import StatusBadge from '@/components/common/StatusBadge';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config';
const sisterEmojis: Record<string, string> = {
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺',
};
const sisterDisplayNames: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
const Page = styled.div`
padding: 32px;
min-height: 100vh;
background: ${theme.colors.bg};
`;
const PageTitle = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 6px;
`;
const PageSubtitle = styled.p`
font-size: 14px;
color: ${theme.colors.textSecondary};
margin-bottom: 28px;
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
`;
const Card = styled(Link)`
display: block;
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 14px;
padding: 24px;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
text-decoration: none;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 28px rgba(0,0,0,0.4);
border-color: rgba(88,166,255,0.3);
}
`;
const CardTop = styled.div`
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
`;
const Avatar = styled.div`
font-size: 36px;
line-height: 1;
`;
const NameBlock = styled.div``;
const Name = styled.div`
font-size: 18px;
font-weight: 700;
color: ${theme.colors.textPrimary};
margin-bottom: 4px;
`;
const Role = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
`;
const Divider = styled.div`
height: 1px;
background: ${theme.colors.border};
margin: 12px 0;
`;
const MetaItem = styled.div`
font-size: 12px;
color: ${theme.colors.textSecondary};
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
`;
const TaskBox = styled.div`
margin-top: 10px;
padding: 8px 12px;
background: rgba(88,166,255,0.06);
border: 1px solid rgba(88,166,255,0.15);
border-radius: 6px;
font-size: 12px;
color: ${theme.colors.accent};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
function formatLastSeen(lastSeen: string | null): 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}분 전`;
const hr = Math.floor(min / 60);
return hr < 24 ? `${hr}시간 전` : `${Math.floor(hr / 24)}일 전`;
}
export default function SistersListPage() {
const [sisters, setSisters] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
try {
const res = await fetch(`${API_URL}/api/sisters`);
if (res.ok) setSisters(await res.json());
} catch {
// silent
} finally {
setLoading(false);
}
};
load();
const interval = setInterval(load, 30000);
return () => clearInterval(interval);
}, []);
return (
<Page>
<PageTitle></PageTitle>
<PageSubtitle> 4 . .</PageSubtitle>
{loading ? (
<div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}> ...</div>
) : (
<Grid>
{sisters.map((s) => (
<Card key={s.id} href={`/sisters/${s.name}`}>
<CardTop>
<Avatar>{sisterEmojis[s.name] ?? '🤖'}</Avatar>
<NameBlock>
<Name>{sisterDisplayNames[s.name] ?? s.name}</Name>
<Role>{s.role}</Role>
</NameBlock>
</CardTop>
<StatusBadge status={s.status} />
<Divider />
<MetaItem>🕐 : {formatLastSeen(s.lastSeen)}</MetaItem>
<MetaItem>🖥 LXC {s.lxcId}</MetaItem>
{s.currentTask && <TaskBox>📌 {s.currentTask}</TaskBox>}
</Card>
))}
</Grid>
)}
</Page>
);
}