146 lines
4.8 KiB
TypeScript
146 lines
4.8 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
|
|
import {
|
|
fmtPeriodPickerLabel,
|
|
parseMilestonePeriods,
|
|
parsePeriodNoteLines,
|
|
pickLatestPeriodId,
|
|
sortPeriodsByRecent,
|
|
} from '../../lib/milestonePeriods';
|
|
import type { Milestone } from '../../types';
|
|
|
|
interface MilestoneContentListProps {
|
|
milestone: Pick<Milestone, 'id' | 'periodEntries' | 'startDate' | 'dueDate' | 'description'> | null | undefined;
|
|
emptyMessage: string;
|
|
onContextMenu?: (event: MouseEvent) => void;
|
|
}
|
|
|
|
function PeriodPicker({
|
|
periods,
|
|
selectedId,
|
|
onSelect,
|
|
}: {
|
|
periods: ReturnType<typeof parseMilestonePeriods>;
|
|
selectedId: string;
|
|
onSelect: (id: string) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
const selected = periods.find((p) => p.id === selectedId) ?? periods[0];
|
|
const selectedIndex = periods.findIndex((p) => p.id === selected?.id);
|
|
const label = selected ? fmtPeriodPickerLabel(selected, selectedIndex >= 0 ? selectedIndex : 0) : '';
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('pointerdown', onPointerDown);
|
|
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
}, [open]);
|
|
|
|
if (!selected || !label) return null;
|
|
|
|
const canPick = periods.length > 1;
|
|
|
|
return (
|
|
<div className="milestone-content-period" ref={rootRef}>
|
|
<button
|
|
type="button"
|
|
className={`milestone-content-period__btn${open ? ' is-open' : ''}`}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
disabled={!canPick}
|
|
onClick={() => canPick && setOpen((v) => !v)}
|
|
title={canPick ? '다른 기간 업무내용 보기' : undefined}
|
|
>
|
|
<span className="milestone-content-period__label">{label}</span>
|
|
{canPick && (
|
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden className="milestone-content-period__chev">
|
|
<path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
{open && canPick && (
|
|
<ul className="milestone-content-period__menu" role="listbox">
|
|
{periods.map((period, index) => {
|
|
const isActive = period.id === selectedId;
|
|
return (
|
|
<li key={period.id} role="option" aria-selected={isActive}>
|
|
<button
|
|
type="button"
|
|
className={`milestone-content-period__option${isActive ? ' is-active' : ''}`}
|
|
onClick={() => {
|
|
onSelect(period.id);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{fmtPeriodPickerLabel(period, index)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MilestoneContentList({
|
|
milestone,
|
|
emptyMessage,
|
|
onContextMenu,
|
|
}: MilestoneContentListProps) {
|
|
const periods = useMemo(
|
|
() => sortPeriodsByRecent(parseMilestonePeriods(milestone)),
|
|
[milestone],
|
|
);
|
|
|
|
const latestId = useMemo(() => pickLatestPeriodId(periods), [periods]);
|
|
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setSelectedPeriodId(latestId);
|
|
}, [milestone?.id, latestId]);
|
|
|
|
const activePeriod =
|
|
periods.find((p) => p.id === selectedPeriodId) ?? periods[0] ?? null;
|
|
const lines = parsePeriodNoteLines(activePeriod?.note);
|
|
|
|
return (
|
|
<>
|
|
<div className="detail-section-head">
|
|
<h3 className="detail-section-label">업무내용</h3>
|
|
{milestone && periods.length > 0 && activePeriod && (
|
|
<PeriodPicker
|
|
periods={periods}
|
|
selectedId={activePeriod.id}
|
|
onSelect={setSelectedPeriodId}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<ul
|
|
className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
|
|
onContextMenu={onContextMenu}
|
|
>
|
|
{!milestone ? (
|
|
<li className="detail-body-muted">{emptyMessage}</li>
|
|
) : periods.length === 0 ? (
|
|
<li className="detail-body-muted">{emptyMessage}</li>
|
|
) : lines.length === 0 ? (
|
|
<li className="detail-body-muted">이 기간에 입력된 업무내용이 없습니다.</li>
|
|
) : (
|
|
lines.map((line, index) => (
|
|
<li key={`${activePeriod!.id}-${index}`} className="flex gap-2" onContextMenu={onContextMenu}>
|
|
<span className="detail-body-text shrink-0 text-[#4a90d9]">•</span>
|
|
<p className="detail-body-content min-w-0 flex-1 whitespace-pre-wrap break-words">{line}</p>
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
</>
|
|
);
|
|
}
|