Sprint 006 non-blocking:
- N2: AuthContext 401 시 refresh token 자동 재발급
- N3: login 후 /me API로 실제 userId 조회
- N4: register refreshToken localStorage 저장
TASK-024: Gitea 동기화
- GiteaSyncService: syncRepos() (Project upsert + ActivityLog)
- POST /api/admin/gitea/sync + GET /api/admin/gitea/status
- GiteaSyncModule (CompositeGuard + @Roles('admin'))
TASK-025: Commit/Branch/PR API
- GiteaService 강화: getCommits, getBranches, getPulls
- GiteaCommit/GiteaBranch 인터페이스 추가
- GET /api/projects/:id/{commits,branches,pulls}
TASK-026: 프로젝트 FE
- /projects: GITEA SYNC 버튼 + sync 결과 표시
- /projects/[id]: Overview/Commits/Branches/PRs 탭
TASK-027: 활동 로그 자동 기록
- SistersService: 상태 변경 시 ActivityLog 자동 기록 (@Optional ActivityService)
- GiteaSyncService: sync 시 new_repo/sync_complete 로그
- /activities: 실제 API 연결 확인 (하드코딩 없음)
테스트 26/26 pass, FE 16 routes build 성공
544 lines
17 KiB
TypeScript
544 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState, useCallback } from 'react';
|
|
import { useParams } from 'next/navigation';
|
|
import styled from 'styled-components';
|
|
import Link from 'next/link';
|
|
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base';
|
|
import { API_URL } from '@/lib/config';
|
|
|
|
// ─── Styled ───
|
|
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 TabBar = styled.div`
|
|
display: flex;
|
|
gap: var(--space-lg);
|
|
border-bottom: 1px solid var(--border-color);
|
|
margin-bottom: var(--space-xl);
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
&::-webkit-scrollbar { display: none; }
|
|
`;
|
|
|
|
const TabBtn = styled.button<{ $active: boolean }>`
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
|
padding: var(--space-sm) 0;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: color 0.15s, border-color 0.15s;
|
|
&:hover { color: var(--text-primary); }
|
|
`;
|
|
|
|
const CommitTable = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-code);
|
|
font-family: var(--font-mono);
|
|
`;
|
|
|
|
const CommitRow = styled.a`
|
|
display: grid;
|
|
grid-template-columns: 80px 1fr 120px 100px;
|
|
gap: var(--space-md);
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid #1a1a1a;
|
|
text-decoration: none;
|
|
transition: background 0.1s;
|
|
&:last-child { border-bottom: none; }
|
|
&:hover { background: #1a1a1a; }
|
|
|
|
@media (max-width: 767px) {
|
|
grid-template-columns: 70px 1fr;
|
|
gap: var(--space-sm);
|
|
}
|
|
`;
|
|
|
|
const CommitHash = styled.span`
|
|
font-size: 12px;
|
|
color: #5fafff;
|
|
font-family: var(--font-mono);
|
|
`;
|
|
|
|
const CommitMsg = styled.span`
|
|
font-size: 12px;
|
|
color: var(--text-primary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
|
|
const CommitMeta = styled.span`
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
|
|
const BranchGrid = styled.div`
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
gap: var(--space-md);
|
|
`;
|
|
|
|
const BranchCard = styled.div`
|
|
border: 1px solid var(--border-color);
|
|
padding: var(--space-md) var(--space-lg);
|
|
transition: border-color 0.15s;
|
|
&:hover { border-color: var(--border-hover); }
|
|
`;
|
|
|
|
const BranchName = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
margin-bottom: 4px;
|
|
`;
|
|
|
|
const BranchMeta = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
`;
|
|
|
|
const PRList = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
`;
|
|
|
|
const PRItem = styled.a`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-md);
|
|
padding: 12px var(--space-lg);
|
|
border: 1px solid var(--border-color);
|
|
text-decoration: none;
|
|
transition: border-color 0.15s;
|
|
&:hover { border-color: var(--border-hover); }
|
|
`;
|
|
|
|
const PRState = styled.span<{ $state: string }>`
|
|
font-family: var(--font-mono);
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
padding: 2px 6px;
|
|
border: 1px solid;
|
|
white-space: nowrap;
|
|
|
|
${({ $state }) => {
|
|
switch ($state) {
|
|
case 'open': return "color: #5fff8a; border-color: #5fff8a44;";
|
|
case 'closed': return "color: var(--text-secondary); border-color: var(--border-color);";
|
|
case 'merged': return "color: #5fafff; border-color: #5fafff44;";
|
|
default: return "color: var(--text-secondary); border-color: var(--border-color);";
|
|
}
|
|
}}
|
|
`;
|
|
|
|
const PRTitle = styled.span`
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
flex: 1;
|
|
`;
|
|
|
|
const PRMeta = styled.span`
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
font-family: var(--font-mono);
|
|
white-space: nowrap;
|
|
`;
|
|
|
|
const FilterRow = styled.div`
|
|
display: flex;
|
|
gap: var(--space-sm);
|
|
margin-bottom: var(--space-lg);
|
|
`;
|
|
|
|
const FilterBtn = styled.button<{ $active: boolean }>`
|
|
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
|
border: 1px solid var(--border-color);
|
|
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
padding: 4px 12px;
|
|
cursor: pointer;
|
|
font-family: var(--font-mono);
|
|
transition: all 0.15s;
|
|
&:hover { border-color: var(--border-hover); color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'}; }
|
|
`;
|
|
|
|
const Empty = styled.div`
|
|
padding: var(--space-xl) 0;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
font-family: var(--font-mono);
|
|
`;
|
|
|
|
// Phase timeline 컴포넌트들
|
|
const ProjectGrid = styled.div`
|
|
display: grid;
|
|
grid-template-columns: 1fr 320px;
|
|
gap: var(--space-xxl);
|
|
align-items: start;
|
|
@media (max-width: 1199px) { grid-template-columns: 1fr; gap: var(--space-xl); }
|
|
`;
|
|
|
|
const PhaseTimeline = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
margin-bottom: var(--space-xl);
|
|
`;
|
|
|
|
const PhaseItem = styled.div`
|
|
display: grid;
|
|
grid-template-columns: 100px 1fr;
|
|
gap: var(--space-lg);
|
|
align-items: flex-start;
|
|
`;
|
|
|
|
const PhaseMeta = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
line-height: 1.6;
|
|
padding-top: var(--space-sm);
|
|
`;
|
|
|
|
const PhaseBox = styled.div<{ $active?: boolean }>`
|
|
border: 1px solid ${({ $active }) => $active ? 'var(--border-hover)' : 'var(--border-color)'};
|
|
padding: var(--space-md) var(--space-lg);
|
|
background: ${({ $active }) => $active ? '#1a1a1a' : 'transparent'};
|
|
`;
|
|
|
|
const PhaseName = styled.div`
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: var(--space-xs);
|
|
`;
|
|
|
|
const PhaseDesc = styled.div`
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.5;
|
|
`;
|
|
|
|
const NodeStack = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
margin-bottom: var(--space-xl);
|
|
`;
|
|
|
|
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;
|
|
`;
|
|
|
|
const NodeDot = styled.div`
|
|
width: 6px; height: 6px;
|
|
background: #00FF00;
|
|
border-radius: 50%;
|
|
`;
|
|
|
|
const Checklist = styled.ul`
|
|
list-style: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
`;
|
|
|
|
const CheckItem = styled.li<{ $done?: boolean }>`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-md);
|
|
padding: var(--space-sm) 0;
|
|
border-bottom: 1px solid #1a1a1a;
|
|
font-size: 13px;
|
|
color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'};
|
|
text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'};
|
|
&:last-child { border-bottom: none; }
|
|
`;
|
|
|
|
const CheckBox = styled.div<{ $checked?: boolean }>`
|
|
width: 14px; height: 14px;
|
|
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
|
|
flex-shrink: 0;
|
|
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'};
|
|
position: relative;
|
|
${({ $checked }) => $checked && `&::after { content: '✓'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #000; }`}
|
|
`;
|
|
|
|
// ─── Helpers ───
|
|
function getSprintMeta(sprint: any): { label: string; isActive: boolean } {
|
|
if (sprint.status === 'done') return { label: `COMPLETED`, isActive: false };
|
|
if (sprint.status === 'in_progress') return { label: 'IN PROGRESS', isActive: true };
|
|
return { label: 'PENDING', isActive: false };
|
|
}
|
|
|
|
function formatDate(str: string): string {
|
|
return new Date(str).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
|
}
|
|
|
|
const TABS = [
|
|
{ id: 'overview', label: 'Overview' },
|
|
{ id: 'commits', label: 'Commits' },
|
|
{ id: 'branches', label: 'Branches' },
|
|
{ id: 'prs', label: 'Pull Requests' },
|
|
];
|
|
|
|
export default function ProjectDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [project, setProject] = useState<any>(null);
|
|
const [tasks, setTasks] = useState<any[]>([]);
|
|
const [commits, setCommits] = useState<any[]>([]);
|
|
const [branches, setBranches] = useState<any[]>([]);
|
|
const [pulls, setPulls] = useState<any[]>([]);
|
|
const [prFilter, setPrFilter] = useState<'open' | 'closed' | 'all'>('open');
|
|
const [tab, setTab] = useState('overview');
|
|
const [loading, setLoading] = useState(true);
|
|
const [tabLoading, setTabLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
Promise.allSettled([
|
|
fetch(`${API_URL}/api/projects/${id}`),
|
|
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
|
]).then(([pRes, tRes]) => {
|
|
if (pRes.status === 'fulfilled' && pRes.value.ok) pRes.value.json().then(setProject);
|
|
if (tRes.status === 'fulfilled' && tRes.value.ok) tRes.value.json().then(setTasks);
|
|
}).finally(() => setLoading(false));
|
|
}, [id]);
|
|
|
|
const loadCommits = useCallback(async () => {
|
|
setTabLoading(true);
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/projects/${id}/commits?limit=30`);
|
|
if (res.ok) setCommits(await res.json());
|
|
} finally { setTabLoading(false); }
|
|
}, [id]);
|
|
|
|
const loadBranches = useCallback(async () => {
|
|
setTabLoading(true);
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/projects/${id}/branches`);
|
|
if (res.ok) setBranches(await res.json());
|
|
} finally { setTabLoading(false); }
|
|
}, [id]);
|
|
|
|
const loadPulls = useCallback(async (state: 'open' | 'closed' | 'all') => {
|
|
setTabLoading(true);
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/projects/${id}/pulls?state=${state}`);
|
|
if (res.ok) setPulls(await res.json());
|
|
} finally { setTabLoading(false); }
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (tab === 'commits' && commits.length === 0) loadCommits();
|
|
if (tab === 'branches' && branches.length === 0) loadBranches();
|
|
if (tab === 'prs' && pulls.length === 0) loadPulls(prFilter);
|
|
}, [tab]);
|
|
|
|
useEffect(() => {
|
|
if (tab === 'prs') loadPulls(prFilter);
|
|
}, [prFilter]);
|
|
|
|
const allTasks = tasks.flatMap((s: any) => s.tasks ?? []);
|
|
const doneTasks = allTasks.filter((t: any) => t.status === 'done');
|
|
const activeSprint = tasks.find((s: any) => s.status === 'in_progress');
|
|
|
|
if (loading) return <Empty>LOADING...</Empty>;
|
|
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
|
|
|
|
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()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta>
|
|
</PageTitleRow>
|
|
|
|
<TabBar>
|
|
{TABS.map((t) => (
|
|
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
|
|
{t.label}
|
|
</TabBtn>
|
|
))}
|
|
</TabBar>
|
|
|
|
{/* OVERVIEW */}
|
|
{tab === 'overview' && (
|
|
<ProjectGrid>
|
|
<div>
|
|
<SectionTitle>
|
|
<span>PHASE TIMELINE</span>
|
|
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
|
|
</SectionTitle>
|
|
<PhaseTimeline>
|
|
{tasks.map((sprint: any) => {
|
|
const { label, isActive } = getSprintMeta(sprint);
|
|
return (
|
|
<PhaseItem key={sprint.id}>
|
|
<PhaseMeta>{label}</PhaseMeta>
|
|
<PhaseBox $active={isActive}>
|
|
<PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName>
|
|
<PhaseDesc>태스크 {sprint.tasks?.length ?? 0}개 · 완료 {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0}개</PhaseDesc>
|
|
<TechBar style={{ marginTop: 'var(--space-sm)' }}>
|
|
<TechBarFill $width={sprint.progress ?? 0} />
|
|
</TechBar>
|
|
</PhaseBox>
|
|
</PhaseItem>
|
|
);
|
|
})}
|
|
{tasks.length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint 없음</PhaseName></PhaseBox></PhaseItem>}
|
|
</PhaseTimeline>
|
|
</div>
|
|
|
|
<div>
|
|
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
|
|
<NodeStack>
|
|
{['harang', 'narang', 'darang', 'erang'].map((name) => (
|
|
<NodeMiniCard key={name}>
|
|
<LabelMeta>{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'} [{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'GEN' : name === 'darang' ? 'EVAL' : 'INFRA'}]</LabelMeta>
|
|
<NodeDot />
|
|
</NodeMiniCard>
|
|
))}
|
|
</NodeStack>
|
|
|
|
<SectionTitle>TASK CHECKLIST</SectionTitle>
|
|
<Checklist>
|
|
{allTasks.slice(0, 8).map((task: any) => (
|
|
<CheckItem key={task.id} $done={task.status === 'done'}>
|
|
<CheckBox $checked={task.status === 'done'} />
|
|
<span>[{task.taskId}] {task.title}</span>
|
|
</CheckItem>
|
|
))}
|
|
{allTasks.length === 0 && <CheckItem><CheckBox /><span style={{ color: 'var(--text-secondary)' }}>태스크 없음</span></CheckItem>}
|
|
</Checklist>
|
|
</div>
|
|
</ProjectGrid>
|
|
)}
|
|
|
|
{/* COMMITS */}
|
|
{tab === 'commits' && (
|
|
<>
|
|
{tabLoading ? <Empty>LOADING COMMITS...</Empty> : (
|
|
<>
|
|
<CommitTable>
|
|
{commits.length === 0 ? (
|
|
<div style={{ padding: 'var(--space-xl)', color: 'var(--text-secondary)', fontSize: '12px', textAlign: 'center', fontFamily: 'var(--font-mono)' }}>NO COMMITS</div>
|
|
) : (
|
|
commits.map((c: any) => (
|
|
<CommitRow key={c.sha} href={c.html_url} target="_blank" rel="noopener">
|
|
<CommitHash>{c.sha?.slice(0, 7)}</CommitHash>
|
|
<CommitMsg>{c.commit?.message?.split('\n')[0]}</CommitMsg>
|
|
<CommitMeta style={{ display: 'var(--media-hide, initial)' }}>
|
|
{c.commit?.author?.name ?? c.author?.login ?? '-'}
|
|
</CommitMeta>
|
|
<CommitMeta>
|
|
{c.commit?.author?.date ? formatDate(c.commit.author.date) : '-'}
|
|
</CommitMeta>
|
|
</CommitRow>
|
|
))
|
|
)}
|
|
</CommitTable>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* BRANCHES */}
|
|
{tab === 'branches' && (
|
|
<>
|
|
{tabLoading ? <Empty>LOADING BRANCHES...</Empty> : (
|
|
<BranchGrid>
|
|
{branches.length === 0 ? (
|
|
<Empty>NO BRANCHES</Empty>
|
|
) : (
|
|
branches.map((b: any) => (
|
|
<BranchCard key={b.name}>
|
|
<BranchName>{b.name}</BranchName>
|
|
<BranchMeta>
|
|
{b.commit?.id?.slice(0, 7) ?? '-'}
|
|
{b.protected && ' · PROTECTED'}
|
|
</BranchMeta>
|
|
</BranchCard>
|
|
))
|
|
)}
|
|
</BranchGrid>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* PULL REQUESTS */}
|
|
{tab === 'prs' && (
|
|
<>
|
|
<FilterRow>
|
|
{(['open', 'closed', 'all'] as const).map((s) => (
|
|
<FilterBtn key={s} $active={prFilter === s} onClick={() => setPrFilter(s)}>
|
|
{s.toUpperCase()}
|
|
</FilterBtn>
|
|
))}
|
|
</FilterRow>
|
|
{tabLoading ? <Empty>LOADING PULL REQUESTS...</Empty> : (
|
|
<PRList>
|
|
{pulls.length === 0 ? (
|
|
<Empty>NO PULL REQUESTS ({prFilter})</Empty>
|
|
) : (
|
|
pulls.map((pr: any) => (
|
|
<PRItem key={pr.id} href={pr.html_url} target="_blank" rel="noopener">
|
|
<PRState $state={pr.merged ? 'merged' : pr.state}>
|
|
{pr.merged ? 'MERGED' : pr.state?.toUpperCase()}
|
|
</PRState>
|
|
<PRTitle>#{pr.number} {pr.title}</PRTitle>
|
|
<PRMeta>{pr.user?.login ?? '-'} · {pr.created_at ? formatDate(pr.created_at) : '-'}</PRMeta>
|
|
</PRItem>
|
|
))
|
|
)}
|
|
</PRList>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|