EENE Dashboard upload to Gitea
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
145
frontend/src/components/detail/MilestoneContentList.tsx
Normal file
145
frontend/src/components/detail/MilestoneContentList.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user