Files
eene_dashboard/frontend/src/pages/DetailPage.tsx
EENE Dashboard d53f82b044 fix: detail panel not opening after task selection
Open detail window synchronously to avoid popup blocking, persist selected task, and show API errors on the detail page.
2026-06-05 22:21:10 +09:00

756 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}