Open detail window synchronously to avoid popup blocking, persist selected task, and show API errors on the detail page.
756 lines
28 KiB
TypeScript
756 lines
28 KiB
TypeScript
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<string, { label: string }> = {
|
||
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<T>(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 (
|
||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${className}`}>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function PanelLabel({ children, sub }: { children: React.ReactNode; sub?: string }) {
|
||
return (
|
||
<div className="mb-2 flex shrink-0 items-baseline justify-between gap-2 border-b border-slate-200 pb-2">
|
||
<h3 className="text-sm font-black uppercase tracking-widest text-slate-500">{children}</h3>
|
||
{sub && <span className="truncate text-sm font-bold text-emerald-600">{sub}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LeftSection({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<section className="flex min-h-0 flex-col overflow-hidden border-b border-slate-200 px-4 py-3 last:border-b-0">
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function WaitingScreen() {
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-6 bg-[#eef2f5]">
|
||
<div className="flex h-20 w-20 animate-pulse items-center justify-center rounded-full bg-emerald-100 text-4xl">←</div>
|
||
<div className="text-center">
|
||
<p className="text-2xl font-black text-slate-700">카드를 선택하세요</p>
|
||
<p className="mt-2 text-lg font-medium text-slate-400">
|
||
대시보드에서 업무 카드를 클릭하면<br />이곳에 상세 내용이 표시됩니다.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<header className="relative flex h-12 shrink-0 items-center overflow-hidden bg-[linear-gradient(180deg,#37a184_0%,#29724f_20%,#07412e_100%)] px-5 text-white shadow-[0_2px_10px_rgba(0,0,0,0.20)]">
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[45%] bg-white/10" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-emerald-200/50" />
|
||
|
||
<h1 className="relative z-10 min-w-0 truncate text-[20px] font-bold leading-normal text-[#bad8ca]">
|
||
{task.title}
|
||
</h1>
|
||
|
||
<div className="relative z-10 ml-auto flex shrink-0 items-center gap-4 text-sm">
|
||
<span className="whitespace-nowrap">
|
||
<span className="font-semibold text-white/55">담당</span>{' '}
|
||
<span className="font-bold text-white/90">{task.assignee?.name ?? '—'}</span>
|
||
</span>
|
||
<span className="h-4 w-px bg-white/25" />
|
||
<span className="whitespace-nowrap">
|
||
<span className="font-semibold text-white/55">수행기간</span>{' '}
|
||
<span className="font-bold text-white/90">{period}</span>
|
||
</span>
|
||
<span className="h-4 w-px bg-white/25" />
|
||
<span className="whitespace-nowrap">
|
||
<span className="font-semibold text-white/55">부서</span>{' '}
|
||
<span className="font-bold text-white/90">{task.section ?? '—'}</span>
|
||
</span>
|
||
<Badge className="border border-white/20 bg-white/10 text-white/90">{status.label}</Badge>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
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<string | null>(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<Milestone>(`/milestones/${task.id}`, payload);
|
||
milestoneId = created.id;
|
||
setSelectedId(created.id);
|
||
} else if (stageModal?.milestone) {
|
||
const { data: updated } = await apiClient.patch<Milestone>(
|
||
`/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 (
|
||
<div className="grid h-full min-h-0 grid-cols-[1fr_3fr] grid-rows-1">
|
||
{/* 좌 1/4 — 1:2:2:1 세로 비율 */}
|
||
<aside className="grid h-full min-h-0 grid-rows-[1fr_2fr_2fr_1fr] overflow-hidden border-r border-slate-300 bg-white">
|
||
<LeftSection>
|
||
<PanelLabel>개요</PanelLabel>
|
||
<p className="line-clamp-4 min-h-0 flex-1 overflow-hidden text-xl leading-snug text-slate-600">
|
||
{overview}
|
||
</p>
|
||
{task.issueNote && (
|
||
<p className="mt-1 truncate text-sm font-bold text-red-500">▶ {task.issueNote}</p>
|
||
)}
|
||
</LeftSection>
|
||
|
||
<LeftSection>
|
||
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-2">
|
||
<h3 className="text-sm font-black uppercase tracking-widest text-slate-500">업무 단계</h3>
|
||
<button
|
||
type="button"
|
||
title="업무 단계 추가"
|
||
onClick={() => setStageModal({ mode: 'add' })}
|
||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-lg font-bold leading-none text-white hover:bg-emerald-600"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||
{sortedStages.length === 0 && (
|
||
<p className="text-lg text-slate-400">+ 버튼으로 단계를 추가하세요.</p>
|
||
)}
|
||
{sortedStages.map((stage) => {
|
||
const isSelected = stage.id === selectedId;
|
||
const progress = milestoneProgress(stage);
|
||
return (
|
||
<button
|
||
key={stage.id}
|
||
type="button"
|
||
onClick={() => setSelectedId(stage.id)}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
setCtxMenu({ x: e.clientX, y: e.clientY, stageId: stage.id });
|
||
}}
|
||
className={`shrink-0 rounded-lg border px-3 py-2 text-left transition-colors ${
|
||
isSelected
|
||
? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-300'
|
||
: 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-white'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<p className={`truncate text-2xl font-black leading-snug ${isSelected ? 'text-emerald-800' : 'text-slate-800'}`}>
|
||
{stage.title}
|
||
</p>
|
||
<p className="mt-0.5 text-sm font-semibold text-slate-400">{fmtStageRange(stage)}</p>
|
||
</div>
|
||
<span
|
||
className={`shrink-0 text-lg font-black ${
|
||
progress >= 100 ? 'text-emerald-600' : progress > 0 ? 'text-blue-500' : 'text-slate-300'
|
||
}`}
|
||
>
|
||
{progress}%
|
||
</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</LeftSection>
|
||
|
||
<LeftSection>
|
||
<PanelLabel>업무내용</PanelLabel>
|
||
<ul
|
||
className="min-h-0 flex-1 space-y-2 overflow-hidden"
|
||
onContextMenu={(e) => {
|
||
if (!selected) return;
|
||
e.preventDefault();
|
||
setContentCtx({ x: e.clientX, y: e.clientY });
|
||
}}
|
||
>
|
||
{stageContents.length === 0 ? (
|
||
<li className="text-lg text-slate-400">
|
||
{selected ? '우클릭으로 업무내용을 수정하세요.' : '단계를 선택하세요.'}
|
||
</li>
|
||
) : (
|
||
stageContents.map((text) => (
|
||
<li
|
||
key={text}
|
||
className="flex gap-2"
|
||
onContextMenu={(e) => {
|
||
if (!selected) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setContentCtx({ x: e.clientX, y: e.clientY });
|
||
}}
|
||
>
|
||
<span className="shrink-0 text-lg text-blue-400">•</span>
|
||
<p className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-800">{text}</p>
|
||
</li>
|
||
))
|
||
)}
|
||
</ul>
|
||
</LeftSection>
|
||
|
||
<LeftSection>
|
||
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-2">
|
||
<h3 className="text-sm font-black uppercase tracking-widest text-slate-500">피드백</h3>
|
||
<button
|
||
type="button"
|
||
title="피드백 추가"
|
||
disabled={!selectedId}
|
||
onClick={() => setFeedbackModal({ mode: 'add' })}
|
||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-lg font-bold leading-none text-white hover:bg-emerald-600 disabled:opacity-40"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<div
|
||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||
onContextMenu={(e) => {
|
||
if (!selectedId) return;
|
||
e.preventDefault();
|
||
setFeedbackCtx({ x: e.clientX, y: e.clientY });
|
||
}}
|
||
>
|
||
{sortedFeedbacks.length === 0 ? (
|
||
<p className="text-lg text-slate-400">우클릭 또는 + 버튼으로 피드백을 추가하세요.</p>
|
||
) : (
|
||
<div className="mb-2 min-h-0 flex-1 space-y-2 overflow-hidden">
|
||
{sortedFeedbacks.map((f) => (
|
||
<div
|
||
key={f.id}
|
||
className="rounded-lg bg-slate-50 px-3 py-2"
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setFeedbackCtx({ x: e.clientX, y: e.clientY, detailId: f.id });
|
||
}}
|
||
>
|
||
<p className="truncate text-2xl font-black leading-snug text-slate-700">
|
||
{f.content}
|
||
<span className="font-bold text-slate-400"> — {f.author?.name ?? '—'}</span>
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</LeftSection>
|
||
</aside>
|
||
|
||
{/* 우 3/4 */}
|
||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||
<ResultPreview
|
||
files={stageFiles}
|
||
links={stageLinks}
|
||
hasSelectedStage={!!selectedId}
|
||
/>
|
||
|
||
<footer className="shrink-0 border-t border-slate-300 bg-white px-5 py-4" style={{ height: '132px' }}>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="text-lg font-black text-slate-700">업무별 타임라인</span>
|
||
<span className="truncate text-base font-bold text-emerald-600">{selected?.title ?? task.title}</span>
|
||
</div>
|
||
|
||
{timeline ? (
|
||
<>
|
||
<div className="mb-1.5 flex justify-between text-sm font-semibold text-slate-400">
|
||
<span>{timeline.start}</span>
|
||
<span>{timeline.end}</span>
|
||
</div>
|
||
<div className="relative h-10 bg-slate-100">
|
||
<div
|
||
className="pointer-events-none absolute top-0 z-10 flex h-full flex-col items-center"
|
||
style={{ left: `${timeline.todayLeft}%` }}
|
||
>
|
||
<span className="mb-0.5 bg-emerald-500 px-1.5 py-0.5 text-[10px] font-black text-white">
|
||
TODAY ({timeline.today})
|
||
</span>
|
||
<div className="h-full w-0.5 bg-emerald-500" />
|
||
</div>
|
||
{timeline.bars.map((bar) => {
|
||
const isSelected = bar.id === selectedId;
|
||
return (
|
||
<button
|
||
key={bar.id}
|
||
type="button"
|
||
onClick={() => setSelectedId(bar.id)}
|
||
className={`absolute top-1/2 h-5 -translate-y-1/2 overflow-hidden transition-all ${
|
||
isSelected ? 'z-20 ring-2 ring-emerald-400' : 'z-0 opacity-85 hover:opacity-100'
|
||
}`}
|
||
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
|
||
title={bar.title}
|
||
>
|
||
<div className="absolute inset-0 bg-slate-300" />
|
||
<div
|
||
className="absolute inset-y-0 left-0 bg-emerald-500"
|
||
style={{ width: `${bar.progress}%` }}
|
||
/>
|
||
<span className="relative flex h-full items-center justify-center truncate px-1 text-[10px] font-bold text-white">
|
||
{bar.label}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-slate-400">수행기간이 설정되면 타임라인이 표시됩니다.</p>
|
||
)}
|
||
</footer>
|
||
</div>
|
||
|
||
{stageModal && (
|
||
<StageModal
|
||
mode={stageModal.mode}
|
||
milestone={stageModal.milestone}
|
||
existingFiles={
|
||
stageModal.milestone
|
||
? sortFilesByOrder(files.filter((f) => f.milestoneId === stageModal.milestone!.id))
|
||
: []
|
||
}
|
||
saving={stageSaving}
|
||
onClose={() => setStageModal(null)}
|
||
onSave={handleStageSave}
|
||
/>
|
||
)}
|
||
|
||
{feedbackModal && (
|
||
<FeedbackModal
|
||
mode={feedbackModal.mode}
|
||
detail={feedbackModal.detail}
|
||
defaultAuthorName={user?.name ?? ''}
|
||
saving={feedbackSaving}
|
||
onClose={() => setFeedbackModal(null)}
|
||
onSave={handleFeedbackSave}
|
||
/>
|
||
)}
|
||
|
||
{ctxMenu && (
|
||
<ContextMenu
|
||
x={ctxMenu.x}
|
||
y={ctxMenu.y}
|
||
onClose={() => 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 && (
|
||
<ContextMenu
|
||
x={contentCtx.x}
|
||
y={contentCtx.y}
|
||
onClose={() => setContentCtx(null)}
|
||
items={[
|
||
{
|
||
label: '수정',
|
||
icon: '✏️',
|
||
onClick: () => setStageModal({ mode: 'edit', milestone: selected }),
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
|
||
{feedbackCtx && (
|
||
<ContextMenu
|
||
x={feedbackCtx.x}
|
||
y={feedbackCtx.y}
|
||
onClose={() => 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' }),
|
||
},
|
||
]
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function DetailPage() {
|
||
const { taskId: routeTaskId } = useParams<{ taskId?: string }>();
|
||
const [taskId, setTaskId] = useState<string | null>(
|
||
() => 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<TaskWithRelations>(`/tasks/${taskId}`);
|
||
return data;
|
||
},
|
||
enabled: !!taskId,
|
||
staleTime: 10_000,
|
||
retry: 2,
|
||
});
|
||
|
||
return (
|
||
<div className="flex h-screen flex-col overflow-hidden bg-[#eef2f5] text-slate-800" style={{ fontSize: '18px' }}>
|
||
{task && <DetailHeader task={task} />}
|
||
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
{isLoading ? (
|
||
<div className="flex h-full items-center justify-center text-xl text-slate-400">불러오는 중...</div>
|
||
) : isError ? (
|
||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||
<p className="text-xl font-bold text-red-500">상세 정보를 불러오지 못했습니다.</p>
|
||
<p className="text-base text-slate-500">{getApiErrorMessage(error, '서버 연결을 확인해 주세요.')}</p>
|
||
</div>
|
||
) : !task ? (
|
||
<WaitingScreen />
|
||
) : (
|
||
<div className="h-full min-h-0 flex-1">
|
||
<DetailView task={task} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|