import { useState, useEffect, useMemo, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '../lib/apiClient'; import { onDualMonitorEvent } from '../lib/dualMonitor'; import { ContextMenu } from '../components/common/ContextMenu'; import { StageModal, parseMilestoneLinks, type StageFormData } from '../components/detail/StageModal'; 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) { return m.completedAt ? 100 : 0; } 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 fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); const [stageSaving, setStageSaving] = useState(false); const [stageModal, setStageModal] = useState<{ mode: 'add' | 'edit'; milestone?: Milestone } | null>(null); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: 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).map((text) => ({ text, date: selected.updatedAt, })); }, [selected]); const sortedContents = useMemo( () => sortByIsoDesc(stageContents, (c) => c.date), [stageContents], ); const stageDetails = useMemo( () => (selectedId ? details.filter((d) => d.milestoneId === selectedId) : []), [details, selectedId], ); const sortedFeedbacks = useMemo( () => sortByIsoDesc(stageDetails, (d) => d.createdAt), [stageDetails], ); const stageFiles = useMemo( () => sortByIsoDesc( selectedId ? files.filter((f) => f.milestoneId === selectedId) : [], (f) => f.createdAt, ), [files, selectedId], ); const stageLinks = useMemo( () => (selected ? parseMilestoneLinks(selected.links) : []), [selected], ); const [previewFileId, setPreviewFileId] = useState(null); const [previewLinkIndex, setPreviewLinkIndex] = useState(0); useEffect(() => { setPreviewFileId(stageFiles[0]?.id ?? null); setPreviewLinkIndex(0); }, [selectedId, stageFiles]); const previewFile = stageFiles.find((f) => f.id === previewFileId) ?? stageFiles[0] ?? null; const previewFileIndex = previewFile ? stageFiles.findIndex((f) => f.id === previewFile.id) : -1; const previewLink = !previewFile && stageLinks.length > 0 ? stageLinks[previewLinkIndex] : null; 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, fileList: File[]) => { for (const file of fileList) { const form = new FormData(); form.append('file', file); form.append('milestoneId', milestoneId); form.append('uploadedBy', task.creatorId); await apiClient.post(`/files/upload/${task.id}`, form, { headers: { 'Content-Type': 'multipart/form-data' }, }); } }; const handleStageSave = async (data: StageFormData, fileList: File[]) => { setStageSaving(true); try { const payload = { title: data.title.trim(), description: data.description.trim() || undefined, startDate: data.startDate || undefined, dueDate: data.dueDate || undefined, feedback: data.feedback.trim() || undefined, links: JSON.stringify(data.links), }; if (stageModal?.mode === 'add') { const { data: created } = await apiClient.post(`/milestones/${task.id}`, payload); if (fileList.length) await uploadFiles(created.id, fileList); setSelectedId(created.id); } else if (stageModal?.milestone) { await apiClient.patch(`/milestones/item/${stageModal.milestone.id}`, payload); if (fileList.length) await uploadFiles(stageModal.milestone.id, fileList); } await qc.invalidateQueries({ queryKey: ['task', task.id] }); setStageModal(null); } finally { setStageSaving(false); } }; const handleQuickUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !selectedId) return; setUploading(true); try { await uploadFiles(selectedId, [file]); await qc.invalidateQueries({ queryKey: ['task', task.id] }); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const overview = task.description?.split('\n')[0]?.trim() || '등록된 개요가 없습니다.'; return (
{/* 좌 1/4 — 1:2:2:1 세로 비율 */} {/* 우 3/4 */}
결과물 프리뷰 {previewFile && ( {previewFile.originalName} )}
{previewFile ? ( <> {previewFile.mimetype.includes('image') ? ( {previewFile.originalName} ) : (