feat: 3-section dashboard, reference dual-monitor layout, and detail dock
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,23 +1,16 @@
|
||||
import { useRef } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { DraggableAttributes } from '@dnd-kit/core';
|
||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import type { Task } from '../../types';
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
IN_PROGRESS: 'bg-blue-500 text-white shadow-blue-500/20',
|
||||
REVIEW: 'bg-amber-400 text-white shadow-amber-400/20',
|
||||
TODO: 'bg-slate-200 text-slate-600 shadow-slate-300/20',
|
||||
DONE: 'bg-emerald-500 text-white shadow-emerald-500/20',
|
||||
CANCELLED: 'bg-slate-200 text-slate-400 shadow-slate-300/20',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
IN_PROGRESS: '진행',
|
||||
REVIEW: '보류',
|
||||
TODO: '대기',
|
||||
DONE: '완료',
|
||||
CANCELLED: '취소',
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
IN_PROGRESS: 'ongoing',
|
||||
REVIEW: 'hold',
|
||||
TODO: 'hold',
|
||||
CANCELLED: 'hold',
|
||||
DONE: 'done',
|
||||
};
|
||||
|
||||
function fmtDate(iso: string | null | undefined): string {
|
||||
@@ -26,21 +19,99 @@ function fmtDate(iso: string | null | undefined): string {
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fmtDateRange(task: Task): string {
|
||||
if (!task.showDate || (!task.startDate && !task.dueDate)) return '';
|
||||
const start = task.startDate ? fmtDate(task.startDate) : '?';
|
||||
const end = task.dueDate ? fmtDate(task.dueDate) : '?';
|
||||
return `${start} ~ ${end}`;
|
||||
}
|
||||
|
||||
function firstDescriptionLine(text: string | null | undefined): string {
|
||||
if (!text) return '';
|
||||
const line = text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).find(Boolean);
|
||||
return line ?? '';
|
||||
}
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
return STATUS_DOT[status] ?? 'hold';
|
||||
}
|
||||
|
||||
function SemiCircleGauge({ value }: { value: number }) {
|
||||
const p = Math.min(100, Math.max(0, value));
|
||||
const stroke = 6.75;
|
||||
const w = 88;
|
||||
const h = 56;
|
||||
const cx = 44;
|
||||
const r = 32;
|
||||
/** arc 좌·우 끝 = 숫자 세로 중앙 (100%도 여유 있게) */
|
||||
const cy = 46;
|
||||
const arcLen = Math.PI * r;
|
||||
const dash = (p / 100) * arcLen;
|
||||
const path = `M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="board-gauge"
|
||||
style={{ ['--gauge-cy' as string]: `${(cy / h) * 100}%` }}
|
||||
aria-label={`진행률 ${p}%`}
|
||||
>
|
||||
<svg className="board-gauge-svg" viewBox={`0 0 ${w} ${h}`} aria-hidden>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="#d4e8de"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="#29724f"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="butt"
|
||||
strokeDasharray={`${dash} ${arcLen}`}
|
||||
/>
|
||||
</svg>
|
||||
<span className="board-gauge-value">{p}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SectionOption = { value: string; label: string };
|
||||
|
||||
export function SortableTaskCard({
|
||||
task,
|
||||
variant = 'project',
|
||||
onSelect,
|
||||
}: {
|
||||
task: Task;
|
||||
variant?: 'project' | 'routine';
|
||||
sectionOptions?: SectionOption[];
|
||||
onSelect?: (task: Task) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
||||
const pointerStart = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
pointerStart.current = { x: e.clientX, y: e.clientY };
|
||||
listeners?.onPointerDown?.(e);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0 || !pointerStart.current) return;
|
||||
const dx = e.clientX - pointerStart.current.x;
|
||||
const dy = e.clientY - pointerStart.current.y;
|
||||
pointerStart.current = null;
|
||||
if (!isDragging && Math.hypot(dx, dy) < 8) {
|
||||
onSelect?.(task);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TaskCard
|
||||
task={task}
|
||||
variant={variant}
|
||||
dragRef={setNodeRef}
|
||||
dragStyle={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -49,26 +120,64 @@ export function SortableTaskCard({
|
||||
}}
|
||||
dragAttributes={attributes}
|
||||
dragListeners={listeners}
|
||||
onCardClick={() => { if (!isDragging) onSelect?.(task); }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskCard({
|
||||
task,
|
||||
variant = 'project',
|
||||
dragRef,
|
||||
dragStyle,
|
||||
dragAttributes,
|
||||
dragListeners,
|
||||
onCardClick,
|
||||
onPointerDown,
|
||||
onPointerUp,
|
||||
}: {
|
||||
task: Task;
|
||||
variant?: 'project' | 'routine';
|
||||
dragRef?: (node: HTMLElement | null) => void;
|
||||
dragStyle?: React.CSSProperties;
|
||||
dragAttributes?: DraggableAttributes;
|
||||
dragListeners?: SyntheticListenerMap;
|
||||
onCardClick?: () => void;
|
||||
onPointerDown?: (e: React.PointerEvent) => void;
|
||||
onPointerUp?: (e: React.PointerEvent) => void;
|
||||
}) {
|
||||
const dragHandlers = {
|
||||
onPointerDown: (e: React.PointerEvent) => {
|
||||
onPointerDown?.(e);
|
||||
},
|
||||
onPointerUp: (e: React.PointerEvent) => {
|
||||
onPointerUp?.(e);
|
||||
},
|
||||
onKeyDown: dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined,
|
||||
};
|
||||
|
||||
const dotClass = statusDotClass(task.status);
|
||||
|
||||
if (variant === 'routine') {
|
||||
return (
|
||||
<div
|
||||
ref={dragRef}
|
||||
style={dragStyle}
|
||||
{...dragAttributes}
|
||||
data-task-card="true"
|
||||
data-task-id={task.id}
|
||||
className="board-routine-item"
|
||||
{...dragHandlers}
|
||||
>
|
||||
<span className={`board-status-dot board-status-dot--${dotClass}`} aria-hidden />
|
||||
<span className="board-routine-name">{task.title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dateRange = fmtDateRange(task);
|
||||
const descLine = task.showDescription ? firstDescriptionLine(task.description) : '';
|
||||
const showProgress = task.showProgress !== false;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragRef}
|
||||
@@ -76,60 +185,26 @@ export function TaskCard({
|
||||
{...dragAttributes}
|
||||
data-task-card="true"
|
||||
data-task-id={task.id}
|
||||
className="mb-3 cursor-grab select-none overflow-hidden rounded-[1.35rem] border border-white/80 bg-white px-5 py-4 shadow-[0_10px_28px_rgba(15,23,42,0.08)] ring-1 ring-slate-200/60 transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(15,23,42,0.14)] active:cursor-grabbing"
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
dragListeners?.onPointerDown?.(e);
|
||||
}}
|
||||
onKeyDown={dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined}
|
||||
onClick={onCardClick}
|
||||
className="board-project-card"
|
||||
{...dragHandlers}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-900">
|
||||
{task.title}
|
||||
</span>
|
||||
{task.showProgress !== false && (
|
||||
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${
|
||||
task.progress >= 70 ? 'text-emerald-500' :
|
||||
task.progress >= 40 ? 'text-blue-400' : 'text-orange-400'
|
||||
}`}>
|
||||
{task.progress}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="flex-1 truncate text-sm font-semibold text-slate-400">
|
||||
{task.showDate && (task.startDate || task.dueDate)
|
||||
? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}`
|
||||
: ''}
|
||||
</span>
|
||||
{task.showStatus && (
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-sm font-black shadow-sm ${STATUS_STYLE[task.status]}`}>
|
||||
{STATUS_LABEL[task.status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.keywords && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{task.keywords.split(',').map((kw, i) => (
|
||||
<span key={i} className="rounded-md border border-slate-200/60 bg-slate-100 px-2 py-0.5 text-sm font-bold text-slate-600">
|
||||
{kw.trim()}
|
||||
</span>
|
||||
))}
|
||||
<div className="board-project-top">
|
||||
<div className="board-project-main">
|
||||
<div className="board-project-title-row">
|
||||
<span className={`board-status-dot board-status-dot--${dotClass}`} aria-hidden />
|
||||
<span className="board-project-title">{task.title}</span>
|
||||
</div>
|
||||
{dateRange && <p className="board-project-date">{dateRange}</p>}
|
||||
</div>
|
||||
)}
|
||||
{showProgress && <SemiCircleGauge value={task.progress} />}
|
||||
</div>
|
||||
|
||||
{task.showDescription && task.description && (
|
||||
<div className="mt-2 truncate text-2xl text-slate-700">{task.description}</div>
|
||||
{descLine && (
|
||||
<p className="board-project-desc">• {descLine}</p>
|
||||
)}
|
||||
|
||||
{task.showIssue && task.issueNote && (
|
||||
<div className="mt-1.5 flex min-w-0 gap-2 rounded-xl bg-red-50/80 px-2 py-1 text-2xl text-red-500">
|
||||
<span className="shrink-0">▶</span>
|
||||
<span className="truncate">{task.issueNote}</span>
|
||||
</div>
|
||||
<p className="board-project-issue">▶ {task.issueNote}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user