import { useState, useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient, getApiErrorMessage } from '../lib/apiClient'; import { onDualMonitorEvent, getPersistedTaskId } from '../lib/dualMonitor'; import { ContextMenu } from '../components/common/ContextMenu'; import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal'; import { ResultPreview } from '../components/detail/ResultPreview'; import { StageModal, parseMilestoneLinks, type StageFileSavePayload, type StageFormData, } from '../components/detail/StageModal'; import { sortFilesByOrder } from '../lib/fileDisplay'; import { useAuth } from '../contexts/AuthContext'; import type { Task, Milestone, FileRecord, TaskDetail } from '../types'; const STATUS_CONFIG: Record = { IN_PROGRESS: { label: '진행중' }, REVIEW: { label: '보류' }, TODO: { label: '대기' }, DONE: { label: '완료' }, CANCELLED: { label: '취소' }, }; type TaskWithRelations = Task & { files: FileRecord[]; details: TaskDetail[]; milestones: Milestone[]; }; function fmtDate(iso: string | null | undefined) { 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 fmtShort(iso: string | null | undefined) { if (!iso) return ''; const d = new Date(iso); return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; } function fmtStageRange(m: Milestone) { if (m.startDate && m.dueDate) return `${fmtShort(m.startDate)} ~ ${fmtShort(m.dueDate)}`; if (m.dueDate) return fmtShort(m.dueDate); return fmtShort(m.createdAt); } function fmtTimelineLabel(iso: string | null | undefined) { if (!iso) return ''; const d = new Date(iso); return `${d.getMonth() + 1}.${String(d.getDate()).padStart(2, '0')}`; } function sortByIsoDesc(items: T[], pick: (item: T) => string) { return [...items].sort((a, b) => new Date(pick(b)).getTime() - new Date(pick(a)).getTime()); } function milestoneProgress(m: Milestone) { if (m.completedAt) return 100; const p = m.progress ?? 0; return Math.min(100, Math.max(0, p)); } function parseContentLines(text: string | null | undefined) { if (!text) return []; return text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).filter(Boolean); } interface TimelineBar { id: string; label: string; left: number; width: number; progress: number; title: string; } function buildTimeline(task: Task, milestones: Milestone[]): { start: string; end: string; today: string; todayLeft: number; bars: TimelineBar[]; } | null { if (!task.startDate || !task.dueDate) return null; const startMs = new Date(task.startDate).getTime(); const endMs = new Date(task.dueDate).getTime(); const range = Math.max(endMs - startMs, 86400000); const now = Date.now(); const todayLeft = Math.min(100, Math.max(0, ((now - startMs) / range) * 100)); const ordered = [...milestones].sort((a, b) => a.order - b.order); const bars: TimelineBar[] = ordered.map((m, i) => { const barStart = m.startDate ? new Date(m.startDate).getTime() : i > 0 && ordered[i - 1].dueDate ? new Date(ordered[i - 1].dueDate!).getTime() : startMs; const barEnd = m.dueDate ? new Date(m.dueDate).getTime() : endMs; const left = Math.max(0, ((barStart - startMs) / range) * 100); const width = Math.max(4, ((barEnd - barStart) / range) * 100); return { id: m.id, label: `${i + 1}`, left, width, progress: milestoneProgress(m), title: m.title, }; }); const today = new Date(); return { start: fmtTimelineLabel(task.startDate), end: fmtTimelineLabel(task.dueDate), today: `${today.getMonth() + 1}.${String(today.getDate()).padStart(2, '0')}`, todayLeft, bars, }; } function Badge({ children, className = '' }: { children: React.ReactNode; className?: string }) { return ( {children} ); } function PanelLabel({ children, sub }: { children: React.ReactNode; sub?: string }) { return (

{children}

{sub && {sub}}
); } function LeftSection({ children }: { children: React.ReactNode }) { return (
{children}
); } function WaitingScreen() { return (

카드를 선택하세요

대시보드에서 업무 카드를 클릭하면
이곳에 상세 내용이 표시됩니다.

); } function DetailHeader({ task }: { task: Task }) { const status = STATUS_CONFIG[task.status] ?? STATUS_CONFIG.TODO; const period = task.startDate || task.dueDate ? `${fmtDate(task.startDate)} ~ ${fmtDate(task.dueDate)}` : '—'; return (

{task.title}

담당{' '} {task.assignee?.name ?? '—'} 수행기간{' '} {period} 부서{' '} {task.section ?? '—'} {status.label}
); } function DetailView({ task }: { task: TaskWithRelations }) { const qc = useQueryClient(); const { user } = useAuth(); const [stageSaving, setStageSaving] = useState(false); const [feedbackSaving, setFeedbackSaving] = useState(false); const [stageModal, setStageModal] = useState<{ mode: 'add' | 'edit'; milestone?: Milestone } | null>(null); const [feedbackModal, setFeedbackModal] = useState<{ mode: 'add' | 'edit'; detail?: TaskDetail } | null>(null); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: string } | null>(null); const [contentCtx, setContentCtx] = useState<{ x: number; y: number } | null>(null); const [feedbackCtx, setFeedbackCtx] = useState<{ x: number; y: number; detailId?: string } | null>(null); const milestones = task.milestones ?? []; const files = task.files ?? []; const details = task.details ?? []; const sortedStages = useMemo( () => sortByIsoDesc(milestones, (m) => m.updatedAt), [milestones], ); const [selectedId, setSelectedId] = useState(sortedStages[0]?.id ?? null); useEffect(() => { if (!selectedId || !sortedStages.some((s) => s.id === selectedId)) { setSelectedId(sortedStages[0]?.id ?? null); } }, [task.id, sortedStages, selectedId]); const selected = sortedStages.find((m) => m.id === selectedId) ?? sortedStages[0] ?? null; const stageContents = useMemo(() => { if (!selected?.description) return []; return parseContentLines(selected.description); }, [selected]); const stageDetails = useMemo( () => (selectedId ? details.filter((d) => d.milestoneId === selectedId) : []), [details, selectedId], ); const sortedFeedbacks = useMemo( () => sortByIsoDesc(stageDetails, (d) => d.createdAt), [stageDetails], ); const stageFiles = useMemo( () => sortFilesByOrder( selectedId ? files.filter((f) => f.milestoneId === selectedId) : [], ), [files, selectedId], ); const stageLinks = useMemo( () => (selected ? parseMilestoneLinks(selected.links) : []), [selected], ); const timeline = useMemo(() => buildTimeline(task, milestones), [task, milestones]); const deleteMs = useMutation({ mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`), onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }), }); const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => { for (const item of filePayload) { const form = new FormData(); form.append('file', item.file); form.append('milestoneId', milestoneId); form.append('sortOrder', String(item.sortOrder)); if (item.displayName.trim()) { form.append('displayName', item.displayName.trim()); } await apiClient.post(`/files/upload/${task.id}`, form); } }; const handleStageSave = async (data: StageFormData, filePayload: StageFileSavePayload) => { setStageSaving(true); try { const payload = { title: data.title.trim(), description: data.description.trim() || undefined, startDate: data.startDate || undefined, dueDate: data.dueDate || undefined, progress: data.progress, links: data.links.length > 0 ? JSON.stringify(data.links) : undefined, }; let milestoneId: string; if (stageModal?.mode === 'add') { const { data: created } = await apiClient.post(`/milestones/${task.id}`, payload); milestoneId = created.id; setSelectedId(created.id); } else if (stageModal?.milestone) { const { data: updated } = await apiClient.patch( `/milestones/item/${stageModal.milestone.id}`, payload, ); milestoneId = updated.id; } else { return; } try { for (const id of filePayload.deletedFileIds) { await apiClient.delete(`/files/${id}`); } for (const rep of filePayload.replacements) { const form = new FormData(); form.append('file', rep.file); await apiClient.post(`/files/${rep.id}/replace`, form); } for (const edit of filePayload.existingEdits) { const original = files.find((f) => f.id === edit.id); if (!original) continue; const prevName = (original.displayName ?? '').trim(); const nextName = edit.displayName.trim(); const prevOrder = original.sortOrder ?? 0; if (nextName !== prevName || edit.sortOrder !== prevOrder) { await apiClient.patch(`/files/${edit.id}`, { displayName: nextName || null, sortOrder: edit.sortOrder, }); } } if (filePayload.uploads.length > 0) { await uploadFiles(milestoneId, filePayload.uploads); } } catch (err: unknown) { alert(`단계는 저장됐지만 ${getApiErrorMessage(err, '파일 처리에 실패했습니다.')}`); } await qc.invalidateQueries({ queryKey: ['task', task.id] }); setStageModal(null); } catch (err: unknown) { alert(getApiErrorMessage(err, '단계 저장에 실패했습니다.')); } finally { setStageSaving(false); } }; const handleFeedbackSave = async (data: FeedbackFormData) => { if (!selectedId && feedbackModal?.mode === 'add') { alert('피드백을 추가할 업무 단계를 먼저 선택하세요.'); return; } setFeedbackSaving(true); try { if (feedbackModal?.mode === 'add') { await apiClient.post(`/details/${task.id}`, { content: data.content.trim(), authorName: data.authorName.trim(), milestoneId: selectedId, }); } else if (feedbackModal?.detail) { await apiClient.patch(`/details/item/${feedbackModal.detail.id}`, { content: data.content.trim(), }); } await qc.invalidateQueries({ queryKey: ['task', task.id] }); setFeedbackModal(null); } catch (err: unknown) { alert(getApiErrorMessage(err, '피드백 저장에 실패했습니다.')); } finally { setFeedbackSaving(false); } }; const deleteFeedback = useMutation({ mutationFn: (id: string) => apiClient.delete(`/details/item/${id}`), onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }), }); const overview = task.description?.split('\n')[0]?.trim() || '등록된 개요가 없습니다.'; return (
{/* 좌 1/4 — 1:2:2:1 세로 비율 */} {/* 우 3/4 */}
업무별 타임라인 {selected?.title ?? task.title}
{timeline ? ( <>
{timeline.start} {timeline.end}
TODAY ({timeline.today})
{timeline.bars.map((bar) => { const isSelected = bar.id === selectedId; return ( ); })}
) : (

수행기간이 설정되면 타임라인이 표시됩니다.

)}
{stageModal && ( f.milestoneId === stageModal.milestone!.id)) : [] } saving={stageSaving} onClose={() => setStageModal(null)} onSave={handleStageSave} /> )} {feedbackModal && ( setFeedbackModal(null)} onSave={handleFeedbackSave} /> )} {ctxMenu && ( setCtxMenu(null)} items={[ { label: '단계 수정', icon: '✏️', onClick: () => { const ms = milestones.find((m) => m.id === ctxMenu.stageId); if (ms) setStageModal({ mode: 'edit', milestone: ms }); }, }, { label: '단계 삭제', icon: '🗑', danger: true, onClick: () => { if (window.confirm('이 단계를 삭제하시겠습니까?')) { deleteMs.mutate(ctxMenu.stageId); if (selectedId === ctxMenu.stageId) setSelectedId(null); } }, }, ]} /> )} {contentCtx && selected && ( setContentCtx(null)} items={[ { label: '수정', icon: '✏️', onClick: () => setStageModal({ mode: 'edit', milestone: selected }), }, ]} /> )} {feedbackCtx && ( setFeedbackCtx(null)} items={ feedbackCtx.detailId ? [ { label: '피드백 수정', icon: '✏️', onClick: () => { const d = details.find((item) => item.id === feedbackCtx.detailId); if (d) setFeedbackModal({ mode: 'edit', detail: d }); }, }, { label: '피드백 삭제', icon: '🗑', danger: true, onClick: () => { if (window.confirm('이 피드백을 삭제하시겠습니까?')) { deleteFeedback.mutate(feedbackCtx.detailId!); } }, }, ] : [ { label: '피드백 추가', icon: '➕', onClick: () => setFeedbackModal({ mode: 'add' }), }, ] } /> )}
); } export default function DetailPage() { const { taskId: routeTaskId } = useParams<{ taskId?: string }>(); const [taskId, setTaskId] = useState( () => routeTaskId ?? getPersistedTaskId(), ); useEffect(() => { if (routeTaskId) setTaskId(routeTaskId); }, [routeTaskId]); useEffect(() => { const unsub = onDualMonitorEvent((evt) => { if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId); if (evt.type === 'TASK_DESELECTED') setTaskId(null); }); return unsub; }, []); const { data: task, isLoading, isError, error } = useQuery({ queryKey: ['task', taskId], queryFn: async () => { const { data } = await apiClient.get(`/tasks/${taskId}`); return data; }, enabled: !!taskId, staleTime: 10_000, retry: 2, }); return (
{task && }
{isLoading ? (
불러오는 중...
) : isError ? (

상세 정보를 불러오지 못했습니다.

{getApiErrorMessage(error, '서버 연결을 확인해 주세요.')}

) : !task ? ( ) : (
)}
); }