Apply preview-style 4-dept layout with center hub, PM/assignee team status linking, task type label updates, and remove task keywords. Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
5.1 KiB
TypeScript
172 lines
5.1 KiB
TypeScript
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';
|
|
import { DonutGauge } from './DonutGauge';
|
|
|
|
function fmtDate(iso: string | null | undefined): string {
|
|
if (!iso) return '';
|
|
const d = new Date(iso);
|
|
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 ?? '';
|
|
}
|
|
|
|
type SectionOption = { value: string; label: string };
|
|
|
|
export function SortableTaskCard({
|
|
task,
|
|
variant = 'project',
|
|
onSelect,
|
|
}: {
|
|
task: Task;
|
|
variant?: 'project' | 'routine';
|
|
sectionOptions?: SectionOption[];
|
|
accent?: string;
|
|
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),
|
|
transition,
|
|
opacity: isDragging ? 0.35 : 1,
|
|
}}
|
|
dragAttributes={attributes}
|
|
dragListeners={listeners}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerUp={handlePointerUp}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function TaskCard({
|
|
task,
|
|
variant = 'project',
|
|
dragRef,
|
|
dragStyle,
|
|
dragAttributes,
|
|
dragListeners,
|
|
onPointerDown,
|
|
onPointerUp,
|
|
}: {
|
|
task: Task;
|
|
variant?: 'project' | 'routine';
|
|
dragRef?: (node: HTMLElement | null) => void;
|
|
dragStyle?: React.CSSProperties;
|
|
dragAttributes?: DraggableAttributes;
|
|
dragListeners?: SyntheticListenerMap;
|
|
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,
|
|
};
|
|
|
|
if (variant === 'routine') {
|
|
const descLine = firstDescriptionLine(task.description);
|
|
return (
|
|
<div
|
|
ref={dragRef}
|
|
style={dragStyle}
|
|
{...dragAttributes}
|
|
data-task-card="true"
|
|
data-task-id={task.id}
|
|
className="board-routine-item"
|
|
{...dragHandlers}
|
|
>
|
|
<span className="board-project-title">{task.title}</span>
|
|
{descLine && <p className="board-project-desc">• {descLine}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const dateRange = fmtDateRange(task);
|
|
const descLine = task.showDescription ? firstDescriptionLine(task.description) : '';
|
|
const showProgress = task.showProgress !== false;
|
|
|
|
return (
|
|
<article
|
|
ref={dragRef}
|
|
style={dragStyle}
|
|
{...dragAttributes}
|
|
data-task-card="true"
|
|
data-task-id={task.id}
|
|
className="project-sub-card"
|
|
{...dragHandlers}
|
|
>
|
|
<div className="project-sub-body">
|
|
<div className="project-fields">
|
|
<div className="project-sub-title">{task.title}</div>
|
|
{dateRange && (
|
|
<div className="project-field">
|
|
<span className="project-field-label">작업 기간</span>
|
|
<span className="project-field-value">{dateRange}</span>
|
|
</div>
|
|
)}
|
|
{descLine && (
|
|
<div className="project-field">
|
|
<span className="project-field-label">주요 내용</span>
|
|
<span className="project-field-value">{descLine}</span>
|
|
</div>
|
|
)}
|
|
{task.showIssue && task.issueNote && (
|
|
<div className="project-field">
|
|
<span className="project-field-label">이슈</span>
|
|
<span className="project-field-value" style={{ color: '#c0392b' }}>
|
|
{task.issueNote}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{showProgress && (
|
|
<div className="progress-col">
|
|
<DonutGauge task={task} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|