Files
eene_dashboard/frontend/src/pages/DetailPage.tsx
2026-06-05 17:08:12 +09:00

656 lines
25 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, 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<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) {
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 (
<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 fileInputRef = useRef<HTMLInputElement>(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<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).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<string | null>(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<Milestone>(`/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<HTMLInputElement>) => {
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 (
<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="mb-1 flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-slate-400">{fmtStageRange(stage)}</span>
<span
className={`text-lg font-black ${
progress >= 100 ? 'text-emerald-600' : progress > 0 ? 'text-blue-500' : 'text-slate-300'
}`}
>
{progress}%
</span>
</div>
<div className="mb-1.5 h-1.5 overflow-hidden rounded-full bg-slate-200">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${progress}%` }} />
</div>
<p className={`truncate text-2xl font-black leading-snug ${isSelected ? 'text-emerald-800' : 'text-slate-800'}`}>
{stage.title}
</p>
</button>
);
})}
</div>
</LeftSection>
<LeftSection>
<PanelLabel sub={selected?.title ?? '전체'}></PanelLabel>
<ul className="min-h-0 flex-1 space-y-2 overflow-hidden">
{sortedContents.length === 0 ? (
<li className="text-lg text-slate-400"> </li>
) : (
sortedContents.map((item) => (
<li key={item.text + item.date} className="flex gap-2">
<span className="shrink-0 text-lg text-blue-400"></span>
<div className="min-w-0 flex-1">
<p className="truncate text-2xl font-black leading-snug text-slate-800">{item.text}</p>
<p className="text-sm font-semibold text-slate-400">{fmtShort(item.date)}</p>
</div>
</li>
))
)}
</ul>
</LeftSection>
<LeftSection>
<PanelLabel sub={selected?.title ?? '전체'}></PanelLabel>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{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">
<p className="mb-0.5 text-sm font-semibold text-slate-400">{fmtShort(f.createdAt)}</p>
<p className="truncate text-2xl font-black leading-snug text-slate-700">{f.content}</p>
</div>
))}
</div>
)}
</div>
</LeftSection>
</aside>
{/* 우 3/4 */}
<div className="flex h-full min-h-0 min-w-0 flex-col">
<main className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#0f1419]">
<div className="flex shrink-0 items-center justify-between border-b border-white/10 px-5 py-2">
<span className="text-lg font-bold text-white/75">
{previewFile && (
<span className="ml-2 text-base font-normal text-emerald-400/80">{previewFile.originalName}</span>
)}
</span>
<div className="flex items-center gap-2">
<input ref={fileInputRef} type="file" className="hidden" onChange={handleQuickUpload} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || !selectedId}
className="rounded bg-white/10 px-3 py-1 text-sm font-bold text-white/70 hover:bg-white/20 disabled:opacity-50"
>
{uploading ? '업로드 중…' : '+ 파일'}
</button>
</div>
</div>
<div className="relative flex min-h-0 flex-1 items-center justify-center">
{previewFile ? (
<>
{previewFile.mimetype.includes('image') ? (
<img
src={`/api/files/${previewFile.id}/view`}
alt={previewFile.originalName}
className="max-h-full max-w-full object-contain p-4"
/>
) : (
<iframe
src={`/api/files/${previewFile.id}/view`}
title={previewFile.originalName}
className="h-full w-full border-0"
/>
)}
{stageFiles.length > 1 && (
<>
<button
type="button"
onClick={() => {
const prev = previewFileIndex > 0 ? previewFileIndex - 1 : stageFiles.length - 1;
setPreviewFileId(stageFiles[prev].id);
}}
className="absolute left-4 flex h-10 w-10 items-center justify-center bg-black/40 text-xl text-white/70"
>
</button>
<button
type="button"
onClick={() => {
const next = previewFileIndex < stageFiles.length - 1 ? previewFileIndex + 1 : 0;
setPreviewFileId(stageFiles[next].id);
}}
className="absolute right-4 flex h-10 w-10 items-center justify-center bg-black/40 text-xl text-white/70"
>
</button>
</>
)}
</>
) : previewLink ? (
<iframe
src={previewLink.url}
title={previewLink.label}
className="h-full w-full border-0 bg-white"
/>
) : (
<p className="text-xl text-white/40">
{selectedId ? '첨부된 결과물이 없습니다' : '단계를 선택하세요'}
</p>
)}
</div>
{(previewFile || previewLink) && (
<div className="shrink-0 border-t border-white/10 px-5 py-1.5 text-center text-sm text-white/40">
{previewFile
? `${previewFileIndex + 1} / ${stageFiles.length}`
: `${previewLinkIndex + 1} / ${stageLinks.length} 링크`}
</div>
)}
</main>
<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}
saving={stageSaving}
onClose={() => setStageModal(null)}
onSave={handleStageSave}
/>
)}
{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);
}
},
},
]}
/>
)}
</div>
);
}
export default function DetailPage() {
const [taskId, setTaskId] = useState<string | null>(null);
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 } = useQuery({
queryKey: ['task', taskId],
queryFn: async () => {
const { data } = await apiClient.get<TaskWithRelations>(`/tasks/${taskId}`);
return data;
},
enabled: !!taskId,
staleTime: 10_000,
});
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>
) : !task ? (
<WaitingScreen />
) : (
<div className="h-full min-h-0 flex-1">
<DetailView task={task} />
</div>
)}
</div>
</div>
);
}