Files
eene_dashboard/frontend/src/components/detail/MilestoneContentList.tsx
EENE Dashboard b3f2da203b EENE Dashboard upload to Gitea
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 16:59:34 +09:00

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>
</>
);
}