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
202 lines
5.1 KiB
TypeScript
202 lines
5.1 KiB
TypeScript
'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>
|
||
);
|
||
}
|