267 lines
16 KiB
TypeScript
267 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { useParams } from 'next/navigation';
|
|
import styled from 'styled-components';
|
|
import Link from 'next/link';
|
|
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill, BtnToggle } from '@/components/ui/base';
|
|
import { API_URL } from '@/lib/config';
|
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
|
|
|
interface TaskItem { id: number; taskId: string; title: string; assignee: string; status: string; }
|
|
interface SprintItem { id: number; number: number; name: string; status: string; progress: number; tasks: TaskItem[]; }
|
|
interface HistoryItem { label: string; kind: 'sprint' | 'hotfix'; summary?: string | null; description?: string | null; }
|
|
interface SisterState { name: string; status: string; }
|
|
interface CommitItem { sha?: string; html_url?: string; commit?: { message?: string; author?: { date?: string; name?: string } }; author?: { login?: string } | null; }
|
|
interface BranchItem { name?: string; protected?: boolean; commit?: { id?: string } }
|
|
interface PullItem { id?: number; number?: number; title?: string; html_url?: string; state?: string; merged?: boolean; user?: { login?: string }; created_at?: string; }
|
|
interface ProjectDetail {
|
|
id: number;
|
|
name: string;
|
|
repoUrl: string;
|
|
status: string;
|
|
phase: string;
|
|
currentSprint?: string | null;
|
|
latestQa: { label: string | null; status: 'passed' | 'failed' | 'unknown'; blockerCount: number; summary: string | null } | null;
|
|
deploy: { branch: string; status: string; latestDeployAt: string | null; redeployRequired: boolean; note: string };
|
|
}
|
|
|
|
const Breadcrumb = styled.div`font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);margin-bottom:var(--space-md);a{color:var(--text-secondary);text-decoration:none;&:hover{color:var(--text-primary);}}`;
|
|
const PageTitleRow = styled.div`display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-lg);margin-bottom:var(--space-xl);flex-wrap:wrap;`;
|
|
const ProjectGrid = styled.div`display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:var(--space-xl);align-items:start;@media (max-width:1199px){display:flex;flex-direction:column;}`;
|
|
const Panel = styled.section<{ $column?: 'main' | 'side'; $mobileOrder?: number }>`border:1px solid var(--border-color);padding:var(--space-lg);background:var(--bg-surface);${({$column})=>$column==='side'?'grid-column:2;':'grid-column:1;'}@media (max-width:1199px){order:${({$mobileOrder=0})=>$mobileOrder};}`;
|
|
const FlowList = styled.div`display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--space-sm);@media (max-width:767px){grid-template-columns:1fr;}`;
|
|
const FlowItem = styled.div<{ $active:boolean }>`border:1px solid ${({$active})=>$active?'var(--text-primary)':'var(--border-color)'};padding:var(--space-md);background:${({$active})=>$active?'#111':'transparent'};display:flex;flex-direction:column;gap:4px;`;
|
|
const LedgerList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
|
|
const LedgerItem = styled.div`display:flex;flex-direction:column;gap:8px;border-top:1px solid #1a1a1a;padding-top:var(--space-md);&:first-child{border-top:none;padding-top:0;}`;
|
|
const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
|
|
const NodeStack = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
|
|
const NodeMiniCard = styled.div`border:1px solid var(--border-color);padding:var(--space-sm) var(--space-md);display:flex;justify-content:space-between;align-items:center;gap:var(--space-md);`;
|
|
const NodeInfo = styled.div`display:flex;align-items:center;gap:var(--space-sm);`;
|
|
const NodeDot = styled.div<{ $status:string }>`width:8px;height:8px;border-radius:50%;background:${({$status})=>$status==='online'?'#00ff88':$status==='working'?'#5fafff':'#555'};`;
|
|
const Empty = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`;
|
|
const TabBar = styled.div`display:flex;gap:var(--space-sm);margin-bottom:var(--space-xl);flex-wrap:wrap;`;
|
|
const GitList = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`;
|
|
const GitItem = styled.a`border:1px solid var(--border-color);padding:12px var(--space-lg);text-decoration:none;color:var(--text-primary);display:flex;justify-content:space-between;gap:var(--space-md);align-items:center;`;
|
|
const GitPrimary = styled.div`display:flex;flex-direction:column;gap:4px;min-width:0;`;
|
|
const GitTitle = styled.div`font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
|
|
|
const ROLE_LABEL: Record<string, string> = { harang: 'ORCHESTRATOR', narang: 'GENERATOR', darang: 'EVALUATOR', erang: 'INFRA' };
|
|
const ROLE_NAME: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
|
|
|
function formatDate(value?: string | null): string {
|
|
if (!value) return '-';
|
|
return new Date(value).toLocaleString('ko-KR');
|
|
}
|
|
|
|
export default function ProjectDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [project, setProject] = useState<ProjectDetail | null>(null);
|
|
const [tasks, setTasks] = useState<SprintItem[]>([]);
|
|
const [history, setHistory] = useState<HistoryItem[]>([]);
|
|
const [sisters, setSisters] = useState<SisterState[]>([]);
|
|
const [commits, setCommits] = useState<CommitItem[]>([]);
|
|
const [branches, setBranches] = useState<BranchItem[]>([]);
|
|
const [pulls, setPulls] = useState<PullItem[]>([]);
|
|
const [tab, setTab] = useState<'overview'|'commits'|'branches'|'prs'>('overview');
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
Promise.allSettled([
|
|
fetch(`${API_URL}/api/projects/${id}`),
|
|
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
|
fetch(`${API_URL}/api/projects/${id}/history`),
|
|
fetch(`${API_URL}/api/sisters`),
|
|
]).then(async ([projectRes, tasksRes, historyRes, sistersRes]) => {
|
|
if (projectRes.status === 'fulfilled' && projectRes.value.ok) setProject(await projectRes.value.json());
|
|
if (tasksRes.status === 'fulfilled' && tasksRes.value.ok) setTasks(await tasksRes.value.json());
|
|
if (historyRes.status === 'fulfilled' && historyRes.value.ok) setHistory(await historyRes.value.json());
|
|
if (sistersRes.status === 'fulfilled' && sistersRes.value.ok) setSisters(await sistersRes.value.json());
|
|
}).finally(() => setLoading(false));
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (tab === 'commits' && commits.length === 0) fetch(`${API_URL}/api/projects/${id}/commits?limit=20`).then((r) => r.ok ? r.json() : []).then(setCommits).catch(() => {});
|
|
if (tab === 'branches' && branches.length === 0) fetch(`${API_URL}/api/projects/${id}/branches`).then((r) => r.ok ? r.json() : []).then(setBranches).catch(() => {});
|
|
if (tab === 'prs' && pulls.length === 0) fetch(`${API_URL}/api/projects/${id}/pulls?state=all`).then((r) => r.ok ? r.json() : []).then(setPulls).catch(() => {});
|
|
}, [tab, id, commits.length, branches.length, pulls.length]);
|
|
|
|
const allTasks = useMemo(() => tasks.flatMap((sprint) => sprint.tasks ?? []), [tasks]);
|
|
const assignedNodes = useMemo(() => {
|
|
const names = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean)));
|
|
return (names.length > 0 ? names : ['harang', 'narang', 'darang', 'erang']).map((name) => ({
|
|
name,
|
|
status: sisters.find((sister) => sister.name === name)?.status ?? 'offline',
|
|
}));
|
|
}, [allTasks, sisters]);
|
|
const hotfixes = history.filter((entry) => entry.kind === 'hotfix');
|
|
const currentSprintMeta = useMemo(() => {
|
|
if (project?.currentSprint) return project.currentSprint;
|
|
|
|
const activeSprint = tasks.find((sprint) => sprint.status === 'in_progress');
|
|
if (activeSprint) return activeSprint.name || String(activeSprint.number).padStart(2, '0');
|
|
|
|
const latestSprint = tasks.at(-1);
|
|
if (latestSprint?.status === 'done') return 'COMPLETED';
|
|
if (latestSprint) return latestSprint.name || String(latestSprint.number).padStart(2, '0');
|
|
|
|
return 'BACKLOG';
|
|
}, [project, tasks]);
|
|
|
|
if (loading) return <Empty>LOADING...</Empty>;
|
|
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
|
|
|
|
const flowStates = [
|
|
{ key: 'PLANNING', title: 'Planning', active: project.phase === 'PLANNING' },
|
|
{ key: 'IMPLEMENT', title: 'Implement', active: project.phase === 'IMPLEMENT' },
|
|
{ key: 'QA', title: 'QA', active: project.phase === 'QA' },
|
|
{ key: 'READY FOR DEPLOY', title: 'Merge to Main', active: project.phase === 'READY FOR DEPLOY' },
|
|
{ key: 'DEPLOYED', title: 'Redeploy', active: project.phase === 'DEPLOYED' },
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<Breadcrumb><Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}</Breadcrumb>
|
|
<PageTitleRow>
|
|
<PageTitle>
|
|
{project.name}
|
|
{project.repoUrl && <a href={project.repoUrl} target="_blank" rel="noopener" style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}>↗ GITEA</a>}
|
|
</PageTitle>
|
|
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / <span>SPRINT:</span>{currentSprintMeta} / <span>DEPLOY:</span>{project.deploy.status}</LabelMeta>
|
|
</PageTitleRow>
|
|
|
|
<TabBar>
|
|
<BtnToggle $active={tab === 'overview'} onClick={() => setTab('overview')}>Overview</BtnToggle>
|
|
<BtnToggle $active={tab === 'commits'} onClick={() => setTab('commits')}>Commits</BtnToggle>
|
|
<BtnToggle $active={tab === 'branches'} onClick={() => setTab('branches')}>Branches</BtnToggle>
|
|
<BtnToggle $active={tab === 'prs'} onClick={() => setTab('prs')}>Pull Requests</BtnToggle>
|
|
</TabBar>
|
|
|
|
{tab === 'overview' && (
|
|
<ProjectGrid>
|
|
<Panel $column="main" $mobileOrder={1}>
|
|
<SectionTitle><span>DELIVERY FLOW</span><LabelMeta>{project.phase}</LabelMeta></SectionTitle>
|
|
<FlowList>
|
|
{flowStates.map((item) => (
|
|
<FlowItem key={item.key} $active={item.active}>
|
|
<LabelMeta>{item.title}</LabelMeta>
|
|
<Meta>{item.key}</Meta>
|
|
</FlowItem>
|
|
))}
|
|
</FlowList>
|
|
</Panel>
|
|
|
|
<Panel $column="side" $mobileOrder={2}>
|
|
<SectionTitle><span>QA STATUS</span><LabelMeta>{project.latestQa?.status ?? 'unknown'}</LabelMeta></SectionTitle>
|
|
<Meta>{project.latestQa?.label ?? 'QA 문서 없음'}</Meta>
|
|
<div style={{ marginTop: 'var(--space-md)', fontSize: '13px', color: 'var(--text-primary)' }}>{project.latestQa?.summary ?? 'latest QA 없음'}</div>
|
|
<Meta style={{ marginTop: 'var(--space-md)' }}>{project.latestQa?.status === 'passed' ? 'QA PASSED' : project.latestQa?.status === 'failed' ? `QA FAILED · ${project.latestQa.blockerCount} BLOCKERS` : 'QA PENDING'}</Meta>
|
|
</Panel>
|
|
|
|
<Panel $column="side" $mobileOrder={3}>
|
|
<SectionTitle><span>DEPLOY STATUS</span><LabelMeta>{project.deploy.branch}</LabelMeta></SectionTitle>
|
|
<Meta>{project.deploy.status}</Meta>
|
|
<Meta style={{ marginTop: 'var(--space-sm)' }}>{project.deploy.note}</Meta>
|
|
<Meta style={{ marginTop: 'var(--space-sm)' }}>{project.deploy.latestDeployAt ? formatDate(project.deploy.latestDeployAt) : 'LAST DEPLOY UNKNOWN'}</Meta>
|
|
</Panel>
|
|
|
|
<Panel $column="main" $mobileOrder={4}>
|
|
<SectionTitle><span>SPRINT LEDGER</span><LabelMeta>{tasks.length} SPRINTS</LabelMeta></SectionTitle>
|
|
<LedgerList>
|
|
{tasks.map((sprint) => (
|
|
<LedgerItem key={sprint.id}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 'var(--space-md)', flexWrap: 'wrap' }}>
|
|
<div>
|
|
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>SPRINT {String(sprint.number).padStart(2, '0')} · {sprint.name}</div>
|
|
<Meta>{sprint.tasks?.length ?? 0} TASKS · {sprint.tasks?.filter((task) => task.status === 'done').length ?? 0} DONE</Meta>
|
|
</div>
|
|
<LabelMeta>{sprint.status}</LabelMeta>
|
|
</div>
|
|
<TechBar><TechBarFill $width={sprint.progress ?? 0} /></TechBar>
|
|
</LedgerItem>
|
|
))}
|
|
{tasks.length === 0 && <Empty>NO SPRINTS</Empty>}
|
|
</LedgerList>
|
|
</Panel>
|
|
|
|
<Panel $column="main" $mobileOrder={5}>
|
|
<SectionTitle><span>HOTFIX HISTORY</span><LabelMeta>{hotfixes.length} ITEMS</LabelMeta></SectionTitle>
|
|
<LedgerList>
|
|
{hotfixes.map((entry) => (
|
|
<LedgerItem key={entry.label}>
|
|
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>{entry.label}</div>
|
|
<Meta>{entry.summary ?? entry.description ?? 'hotfix summary 없음'}</Meta>
|
|
</LedgerItem>
|
|
))}
|
|
{hotfixes.length === 0 && <Empty>NO HOTFIX HISTORY</Empty>}
|
|
</LedgerList>
|
|
</Panel>
|
|
|
|
<Panel $column="side" $mobileOrder={6}>
|
|
<SectionTitle><span>ASSIGNED NODES</span><LabelMeta>LIVE STATUS</LabelMeta></SectionTitle>
|
|
<NodeStack>
|
|
{assignedNodes.map((node) => (
|
|
<NodeMiniCard key={node.name}>
|
|
<NodeInfo>
|
|
<SisterAvatar name={node.name} size={28} />
|
|
<div>
|
|
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[node.name] ?? node.name}</div>
|
|
<Meta>{ROLE_LABEL[node.name] ?? 'NODE'}</Meta>
|
|
</div>
|
|
</NodeInfo>
|
|
<NodeDot $status={node.status} />
|
|
</NodeMiniCard>
|
|
))}
|
|
</NodeStack>
|
|
</Panel>
|
|
</ProjectGrid>
|
|
)}
|
|
|
|
{tab === 'commits' && (
|
|
<GitList>
|
|
{commits.map((commit) => (
|
|
<GitItem key={commit.sha} href={commit.html_url ?? '#'} target="_blank" rel="noopener">
|
|
<GitPrimary>
|
|
<GitTitle>{commit.commit?.message?.split('\n')[0] ?? commit.sha ?? 'UNKNOWN COMMIT'}</GitTitle>
|
|
<Meta>{commit.commit?.author?.name ?? commit.author?.login ?? '-'}</Meta>
|
|
</GitPrimary>
|
|
<Meta>{commit.sha?.slice(0, 7) ?? '-'} · {formatDate(commit.commit?.author?.date)}</Meta>
|
|
</GitItem>
|
|
))}
|
|
{commits.length === 0 && <Empty>NO COMMITS</Empty>}
|
|
</GitList>
|
|
)}
|
|
|
|
{tab === 'branches' && (
|
|
<GitList>
|
|
{branches.map((branch) => (
|
|
<GitItem key={branch.name} href={project.repoUrl} target="_blank" rel="noopener">
|
|
<GitPrimary>
|
|
<GitTitle>{branch.name ?? 'UNKNOWN BRANCH'}</GitTitle>
|
|
<Meta>{branch.protected ? 'PROTECTED' : 'ACTIVE BRANCH'}</Meta>
|
|
</GitPrimary>
|
|
<Meta>{branch.commit?.id?.slice(0, 7) ?? '-'}</Meta>
|
|
</GitItem>
|
|
))}
|
|
{branches.length === 0 && <Empty>NO BRANCHES</Empty>}
|
|
</GitList>
|
|
)}
|
|
|
|
{tab === 'prs' && (
|
|
<GitList>
|
|
{pulls.map((pull) => (
|
|
<GitItem key={pull.id} href={pull.html_url ?? '#'} target="_blank" rel="noopener">
|
|
<GitPrimary>
|
|
<GitTitle>#{pull.number ?? '-'} {pull.title ?? 'UNKNOWN PR'}</GitTitle>
|
|
<Meta>{pull.user?.login ?? '-'} · {pull.merged ? 'MERGED' : pull.state?.toUpperCase() ?? 'UNKNOWN'}</Meta>
|
|
</GitPrimary>
|
|
<Meta>{formatDate(pull.created_at)}</Meta>
|
|
</GitItem>
|
|
))}
|
|
{pulls.length === 0 && <Empty>NO PULL REQUESTS</Empty>}
|
|
</GitList>
|
|
)}
|
|
</>
|
|
);
|
|
}
|