- 디자인 시스템: #151515 배경, 1px 보더 카드, CSS vars, mono 포인트 - GlobalStyle/theme.ts v2 (DESIGN-SYSTEM.md 기반) - 공통 컴포넌트 (ui/base.tsx): LabelMeta, BracketValue, Card, TechBar, Timeline 등 - Sidebar: [대시] 브라켓 네비 + 모바일 하단 탭바 (767px) - LayoutShell: SidebarContext 제거, 단순화 - 대시보드 (/): SYS 상태 카드 4개 + ONGOING PROJECTS + ACTIVITY FEED - 활동 로그 (/activities): 로그 테이블 + 필터 + 검색 + 페이지네이션 (신규) - 설정 (/settings): 4섹션 Toggle/Bracket Input 그리드 - 자매 노드 관리 (/sisters): 3열 수평 레이아웃 (INFO/GRAPH/SYNC) - 자매 상세 (/sisters/[name]): 간소화 + 터미널 스타일 - 조직도 (/org): 터미널 카드 트리 구조 + 레벨 색상 - 프로젝트 목록 (/projects): 새 라우트 - 프로젝트 상세 (/projects/[id]): Phase Timeline + Audit Log + Checklist - FE 14 routes build 성공
202 lines
5.0 KiB
TypeScript
202 lines
5.0 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: 'var(--text-secondary)',
|
||
in_progress: '#2979FF',
|
||
review: '#FF9800',
|
||
done: '#00FF00',
|
||
failed: '#FF1744',
|
||
};
|
||
|
||
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: var(--bg-surface);
|
||
border: 1px solid var(--border-color);
|
||
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: var(--text-primary);
|
||
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: var(--text-secondary);
|
||
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 var(--border-color);
|
||
`;
|
||
|
||
const TaskRow = styled.div`
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
font-size: 13px;
|
||
|
||
&:last-child { border-bottom: none; }
|
||
&:hover { background: rgba(240,246,252,0.03); }
|
||
`;
|
||
|
||
const TaskId = styled.code`
|
||
font-size: 11px;
|
||
color: #58A6FF;
|
||
min-width: 70px;
|
||
font-family: monospace;
|
||
`;
|
||
|
||
const TaskTitle = styled.span`
|
||
flex: 1;
|
||
color: var(--text-primary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
`;
|
||
|
||
const Assignee = styled.span`
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
min-width: 60px;
|
||
`;
|
||
|
||
const Iteration = styled.span<{ $count: number }>`
|
||
font-size: 11px;
|
||
color: ${({ $count }) => $count > 0 ? '#FF9800' : 'var(--text-secondary)'};
|
||
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] ?? 'var(--text-secondary)';
|
||
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: 'var(--text-secondary)' }}>태스크 없음</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>
|
||
);
|
||
}
|