feat: 3-section dashboard, reference dual-monitor layout, and detail dock

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-08 11:13:40 +09:00
parent 5f16515dab
commit 525a4fc1f2
13 changed files with 1205 additions and 386 deletions

View File

@@ -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>
);