feat: detail page attachments, preview, and file metadata

Add file displayName/sortOrder APIs, result preview with Excel/video support,
unified attachment/link editing, feedback modal, and AuthProvider fix.
Run prisma migrate deploy on Render builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-05 18:32:56 +09:00
parent c3f620a7ac
commit 9abb58e5c8
22 changed files with 1477 additions and 292 deletions

View File

@@ -1,9 +1,18 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useState, useEffect, useMemo } 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 { 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 }> = {
@@ -191,11 +200,14 @@ function DetailHeader({ task }: { task: Task }) {
function DetailView({ task }: { task: TaskWithRelations }) {
const qc = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
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 ?? [];
@@ -218,17 +230,9 @@ function DetailView({ task }: { task: TaskWithRelations }) {
const stageContents = useMemo(() => {
if (!selected?.description) return [];
return parseContentLines(selected.description).map((text) => ({
text,
date: selected.updatedAt,
}));
return parseContentLines(selected.description);
}, [selected]);
const sortedContents = useMemo(
() => sortByIsoDesc(stageContents, (c) => c.date),
[stageContents],
);
const stageDetails = useMemo(
() => (selectedId ? details.filter((d) => d.milestoneId === selectedId) : []),
[details, selectedId],
@@ -240,10 +244,10 @@ function DetailView({ task }: { task: TaskWithRelations }) {
);
const stageFiles = useMemo(
() => sortByIsoDesc(
selectedId ? files.filter((f) => f.milestoneId === selectedId) : [],
(f) => f.createdAt,
),
() =>
sortFilesByOrder(
selectedId ? files.filter((f) => f.milestoneId === selectedId) : [],
),
[files, selectedId],
);
@@ -252,18 +256,6 @@ function DetailView({ task }: { task: TaskWithRelations }) {
[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({
@@ -271,18 +263,22 @@ function DetailView({ task }: { task: TaskWithRelations }) {
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
});
const uploadFiles = async (milestoneId: string, fileList: File[]) => {
for (const file of fileList) {
const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
for (const item of filePayload) {
const form = new FormData();
form.append('file', file);
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, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
};
const handleStageSave = async (data: StageFormData, fileList: File[]) => {
const handleStageSave = async (data: StageFormData, filePayload: StageFileSavePayload) => {
setStageSaving(true);
try {
const payload = {
@@ -291,7 +287,6 @@ function DetailView({ task }: { task: TaskWithRelations }) {
startDate: data.startDate || undefined,
dueDate: data.dueDate || undefined,
progress: data.progress,
feedback: data.feedback.trim() || undefined,
links: data.links.length > 0 ? JSON.stringify(data.links) : undefined,
};
@@ -311,12 +306,37 @@ function DetailView({ task }: { task: TaskWithRelations }) {
return;
}
if (fileList.length > 0) {
try {
await uploadFiles(milestoneId, fileList);
} catch {
alert('단계는 저장됐지만 파일 업로드에 실패했습니다.');
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, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
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) {
const ax = err as { response?: { data?: { message?: string } }; message?: string };
const msg = ax.response?.data?.message || ax.message || '파일 처리에 실패했습니다.';
alert(`단계는 저장됐지만 ${msg}`);
}
await qc.invalidateQueries({ queryKey: ['task', task.id] });
@@ -330,19 +350,41 @@ function DetailView({ task }: { task: TaskWithRelations }) {
}
};
const handleQuickUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !selectedId) return;
setUploading(true);
const handleFeedbackSave = async (data: FeedbackFormData) => {
if (!selectedId && feedbackModal?.mode === 'add') {
alert('피드백을 추가할 업무 단계를 먼저 선택하세요.');
return;
}
setFeedbackSaving(true);
try {
await uploadFiles(selectedId, [file]);
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) {
const ax = err as { response?: { data?: { message?: string } }; message?: string };
const msg = ax.response?.data?.message || ax.message || '피드백 저장에 실패했습니다.';
alert(msg);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
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 (
@@ -393,22 +435,21 @@ function DetailView({ task }: { task: TaskWithRelations }) {
: '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>
<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={`text-lg font-black ${
className={`shrink-0 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>
);
})}
@@ -416,18 +457,33 @@ function DetailView({ task }: { task: TaskWithRelations }) {
</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>
<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>
) : (
sortedContents.map((item) => (
<li key={item.text + item.date} className="flex gap-2">
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>
<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>
<p className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-800">{text}</p>
</li>
))
)}
@@ -435,16 +491,44 @@ function DetailView({ task }: { task: TaskWithRelations }) {
</LeftSection>
<LeftSection>
<PanelLabel sub={selected?.title ?? '전체'}></PanelLabel>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<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>
<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
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>
@@ -455,89 +539,11 @@ function DetailView({ task }: { task: TaskWithRelations }) {
{/* 우 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>
<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">
@@ -597,12 +603,28 @@ function DetailView({ task }: { task: TaskWithRelations }) {
<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}
@@ -631,6 +653,59 @@ function DetailView({ task }: { task: TaskWithRelations }) {
]}
/>
)}
{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>
);
}