Files
hanarang-dashboard/frontend/components/projects/SprintAccordion.tsx
narang-ai 52732e88b9 feat(sprint-002): projects + task ledger + activity feed
Sprint 001 non-blocking 해결:
- N6: npm audit high 패키지 업데이트 (@nestjs/config, @nestjs/cli)
- S1: CORS origin 화이트리스트 (CORS_ORIGINS env)
- S3: DATABASE_URL 미설정 시 즉시 throw
- N2: SSH_KEY_PATH 미설정 시 즉시 throw (하드코딩 fallback 제거)
- N3: API 응답에서 내부 IP 제거
- N7: SshService 에러 메시지 마스킹 (IP 노출 차단)
- N4: theme.ts 색상 SisterCard/StatusBadge에 실제 적용
- N5: Sidebar-MainContent margin 연동 (SidebarContext)
- N9: LayoutShell Client 컴포넌트로 분리

Sprint 002 본문:
- TASK-004: GiteaService (org repo 목록, PR 조회)
- TASK-004: GET /api/projects → Gitea + DB 병합
- TASK-004: GET /api/projects/:id → 상세 + open PRs
- TASK-005: GET /api/projects/:id/tasks → Sprint/Task Ledger
- TASK-005: POST /api/projects/:id/sprints → Sprint 생성
- TASK-005: PATCH /api/tasks/:id → Task 상태 업데이트 + ActivityLog
- TASK-006: GET /api/activity, /api/projects/:id/activity
- TASK-007: /projects/[id] 상세 페이지 (SprintAccordion + TaskTable + 활동탭)
- TASK-007: 메인 대시보드에 프로젝트 섹션 + 활동 피드 추가
- 테스트 11/11 pass
2026-04-04 11:34:04 +09:00

202 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import TaskBadge from '../common/TaskBadge';
import ProgressBar from '../common/ProgressBar';
import { theme } from '@/styles/theme';
interface Task {
id: number;
taskId: string;
title: string;
assignee: string;
status: string;
iteration: number;
}
interface Sprint {
id: number;
number: number;
name: string;
status: string;
progress: number;
tasks: Task[];
}
const sprintStatusIcon: Record<string, string> = {
pending: '⏳',
in_progress: '🔄',
review: '🔍',
done: '✅',
failed: '❌',
};
const sprintStatusColor: Record<string, string> = {
pending: theme.colors.textSecondary,
in_progress: theme.colors.working,
review: '#FF9800',
done: theme.colors.online,
failed: theme.colors.offline,
};
const assigneeEmojis: Record<string, string> = {
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺',
};
const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const AccordionItem = styled.div<{ $open: boolean }>`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 10px;
overflow: hidden;
transition: border-color 0.2s;
${({ $open }) => $open && `border-color: rgba(88,166,255,0.3);`}
`;
const Header = styled.button<{ $statusColor: string }>`
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: ${theme.colors.textPrimary};
transition: background 0.15s;
&:hover {
background: rgba(240, 246, 252, 0.04);
}
`;
const SprintNum = styled.span<{ $color: string }>`
font-size: 13px;
font-weight: 700;
color: ${({ $color }) => $color};
min-width: 60px;
`;
const SprintName = styled.span`
font-size: 14px;
font-weight: 600;
flex: 1;
`;
const ProgressWrap = styled.div`
width: 80px;
`;
const Chevron = styled.span<{ $open: boolean }>`
font-size: 12px;
color: ${theme.colors.textSecondary};
transform: ${({ $open }) => $open ? 'rotate(90deg)' : 'none'};
transition: transform 0.2s;
`;
const Body = styled.div<{ $open: boolean }>`
display: ${({ $open }) => $open ? 'block' : 'none'};
border-top: 1px solid ${theme.colors.border};
`;
const TaskRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid ${theme.colors.border};
font-size: 13px;
&:last-child { border-bottom: none; }
&:hover { background: rgba(240,246,252,0.03); }
`;
const TaskId = styled.code`
font-size: 11px;
color: ${theme.colors.accent};
min-width: 70px;
font-family: monospace;
`;
const TaskTitle = styled.span`
flex: 1;
color: ${theme.colors.textPrimary};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Assignee = styled.span`
font-size: 12px;
color: ${theme.colors.textSecondary};
min-width: 60px;
`;
const Iteration = styled.span<{ $count: number }>`
font-size: 11px;
color: ${({ $count }) => $count > 0 ? '#FF9800' : theme.colors.textSecondary};
min-width: 28px;
text-align: right;
`;
export default function SprintAccordion({ sprints }: { sprints: Sprint[] }) {
const [openIds, setOpenIds] = useState<Set<number>>(
() => new Set(sprints.filter((s) => s.status === 'in_progress').map((s) => s.id))
);
const toggle = (id: number) => {
setOpenIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
return (
<Wrapper>
{sprints.map((sprint) => {
const open = openIds.has(sprint.id);
const color = sprintStatusColor[sprint.status] ?? theme.colors.textSecondary;
return (
<AccordionItem key={sprint.id} $open={open}>
<Header $statusColor={color} onClick={() => toggle(sprint.id)}>
<SprintNum $color={color}>
{sprintStatusIcon[sprint.status] ?? '⏳'} #{sprint.number}
</SprintNum>
<SprintName>{sprint.name}</SprintName>
<ProgressWrap>
<ProgressBar value={sprint.progress} size="sm" />
</ProgressWrap>
<Chevron $open={open}></Chevron>
</Header>
<Body $open={open}>
{sprint.tasks.length === 0 ? (
<TaskRow><TaskTitle style={{ color: theme.colors.textSecondary }}> </TaskTitle></TaskRow>
) : (
sprint.tasks.map((task) => (
<TaskRow key={task.id}>
<TaskId>{task.taskId}</TaskId>
<TaskTitle>{task.title}</TaskTitle>
<Assignee>{assigneeEmojis[task.assignee] ?? '🤖'} {task.assignee}</Assignee>
<TaskBadge status={task.status as any} />
<Iteration $count={task.iteration}>
{task.iteration > 0 ? `×${task.iteration}` : ''}
</Iteration>
</TaskRow>
))
)}
</Body>
</AccordionItem>
);
})}
</Wrapper>
);
}