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,13 +1,16 @@
import { BrowserRouter } from 'react-router-dom';
import { AppRouter } from './router';
import { AuthProvider } from './contexts/AuthContext';
import { SocketProvider } from './contexts/SocketContext';
export default function App() {
return (
<BrowserRouter>
<SocketProvider>
<AppRouter />
</SocketProvider>
<AuthProvider>
<SocketProvider>
<AppRouter />
</SocketProvider>
</AuthProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import * as XLSX from 'xlsx';
interface ExcelPreviewProps {
fileId: string;
fileName: string;
}
export function ExcelPreview({ fileId, fileName }: ExcelPreviewProps) {
const [workbook, setWorkbook] = useState<XLSX.WorkBook | null>(null);
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [activeSheet, setActiveSheet] = useState(0);
useEffect(() => {
let cancelled = false;
setWorkbook(null);
setHtml(null);
setError(null);
setActiveSheet(0);
(async () => {
try {
const res = await fetch(`/api/files/${fileId}/view`);
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
const buffer = await res.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array' });
if (cancelled) return;
setWorkbook(wb);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : '미리보기 실패');
}
})();
return () => {
cancelled = true;
};
}, [fileId]);
useEffect(() => {
if (!workbook || workbook.SheetNames.length === 0) return;
const sheet = workbook.Sheets[workbook.SheetNames[activeSheet]];
setHtml(XLSX.utils.sheet_to_html(sheet, { id: 'excel-preview-table' }));
}, [workbook, activeSheet]);
if (error) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-lg text-white/60">{error}</p>
<a
href={`/api/files/${fileId}/download`}
className="rounded bg-white/10 px-4 py-2 text-sm font-bold text-white hover:bg-white/20"
>
{fileName}
</a>
</div>
);
}
if (!html) {
return <p className="text-lg text-white/50"> </p>;
}
const sheetNames = workbook?.SheetNames ?? [];
return (
<div className="flex h-full w-full min-h-0 flex-col bg-white">
{sheetNames.length > 1 && (
<div className="flex shrink-0 gap-1 overflow-x-auto border-b border-slate-200 bg-slate-50 px-3 py-2">
{sheetNames.map((name, i) => (
<button
key={name}
type="button"
onClick={() => setActiveSheet(i)}
className={`shrink-0 rounded px-3 py-1 text-sm font-bold ${
i === activeSheet ? 'bg-emerald-600 text-white' : 'bg-white text-slate-600 hover:bg-slate-100'
}`}
>
{name}
</button>
))}
</div>
)}
<div
className="min-h-0 flex-1 overflow-auto p-2 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-slate-200 [&_td]:px-2 [&_td]:py-1 [&_td]:text-sm [&_th]:border [&_th]:border-slate-300 [&_th]:bg-slate-100 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:text-sm [&_th]:font-bold"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import type { TaskDetail } from '../../types';
export interface FeedbackFormData {
content: string;
authorName: string;
}
interface FeedbackModalProps {
mode: 'add' | 'edit';
detail?: TaskDetail;
defaultAuthorName?: string;
onSave: (data: FeedbackFormData) => Promise<void>;
onClose: () => void;
saving?: boolean;
}
export function FeedbackModal({
mode,
detail,
defaultAuthorName = '',
onSave,
onClose,
saving,
}: FeedbackModalProps) {
const [form, setForm] = useState<FeedbackFormData>({
content: detail?.content ?? '',
authorName: detail?.author?.name ?? defaultAuthorName,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.content.trim()) return;
if (mode === 'add' && !form.authorName.trim()) return;
await onSave(form);
};
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4"
onMouseDown={onClose}
>
<form
className="flex w-full max-w-md flex-col overflow-hidden rounded-2xl bg-white shadow-2xl"
onMouseDown={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<div className="border-b border-slate-100 px-6 py-4">
<h2 className="text-xl font-black text-slate-800">
{mode === 'add' ? '피드백 추가' : '피드백 수정'}
</h2>
</div>
<div className="space-y-4 px-6 py-4">
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"> *</span>
<textarea
required
autoFocus
value={form.content}
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
placeholder="피드백을 입력하세요"
/>
</label>
{mode === 'add' && (
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"> *</span>
<input
required
value={form.authorName}
onChange={(e) => setForm((p) => ({ ...p, authorName: e.target.value }))}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
placeholder="작성자 이름"
/>
</label>
)}
</div>
<div className="flex justify-end gap-2 border-t border-slate-100 px-6 py-4">
<button
type="button"
onClick={onClose}
disabled={saving}
className="rounded-lg px-4 py-2 text-sm font-bold text-slate-500 hover:bg-slate-50"
>
</button>
<button
type="submit"
disabled={saving || !form.content.trim() || (mode === 'add' && !form.authorName.trim())}
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? '저장 중…' : mode === 'add' ? '추가' : '저장'}
</button>
</div>
</form>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,254 @@
import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { ExcelPreview } from './ExcelPreview';
import { openLinkOnRightMonitor } from '../../lib/dualMonitor';
import { fileDisplayName, isExcelFile, isVideoFile } from '../../lib/fileDisplay';
import type { FileRecord, MilestoneLink } from '../../types';
interface ResultPreviewProps {
files: FileRecord[];
links: MilestoneLink[];
hasSelectedStage: boolean;
}
export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewProps) {
const [fileId, setFileId] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
if (files.length > 0) {
setFileId((prev) => (prev && files.some((f) => f.id === prev) ? prev : files[0].id));
} else {
setFileId(null);
}
setZoom(1);
}, [files]);
const activeFile = fileId ? files.find((f) => f.id === fileId) ?? null : null;
const fileIndex = activeFile ? files.findIndex((f) => f.id === activeFile.id) : -1;
const isImage = activeFile?.mimetype.includes('image') ?? false;
const isVideo = activeFile ? isVideoFile(activeFile) : false;
const isExcel = activeFile ? isExcelFile(activeFile) : false;
const goFile = useCallback(
(delta: number) => {
if (files.length === 0 || fileIndex < 0) return;
const next = (fileIndex + delta + files.length) % files.length;
setFileId(files[next].id);
setZoom(1);
},
[files, fileIndex],
);
const handleOpenLink = (link: MilestoneLink, index: number) => {
void openLinkOnRightMonitor(link.url, `eene_link_${index}`);
};
const headerTitle = activeFile ? fileDisplayName(activeFile) : '결과물 프리뷰';
const renderContent = () => {
if (activeFile) {
const label = fileDisplayName(activeFile);
if (isExcel) {
return <ExcelPreview fileId={activeFile.id} fileName={label} />;
}
const src = `/api/files/${activeFile.id}/view`;
if (isImage) {
return (
<img
src={src}
alt={label}
className="max-h-full max-w-full object-contain transition-transform duration-150"
style={{ transform: `scale(${zoom})` }}
draggable={false}
/>
);
}
if (isVideo) {
return (
<video src={src} controls className="max-h-full max-w-full" title={label} />
);
}
return (
<iframe src={src} title={label} className="h-full w-full border-0 bg-white" />
);
}
if (links.length > 0) {
return (
<p className="px-6 text-center text-xl text-white/40">
📎
</p>
);
}
return (
<p className="text-xl text-white/40">
{hasSelectedStage ? '첨부된 결과물이 없습니다' : '단계를 선택하세요'}
</p>
);
};
const toolbar = (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-white/10 px-5 py-2">
<span className="min-w-0 truncate text-lg font-bold text-white/75">{headerTitle}</span>
{links.length > 0 && (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
{links.map((link, i) => (
<button
key={link.url + i}
type="button"
title={`${link.label}\n${link.url}`}
onClick={() => handleOpenLink(link, i)}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/10 text-base text-white/70 transition-colors hover:bg-sky-500 hover:text-white"
>
📎
</button>
))}
</div>
)}
</div>
);
const controls = activeFile && (
<div className="absolute right-4 top-4 z-20 flex flex-col gap-1">
<button
type="button"
title="전체 보기"
onClick={() => setFullscreen(true)}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-black/50 text-sm font-bold text-white/80 hover:bg-black/70"
>
</button>
{isImage && (
<>
<button
type="button"
title="확대"
onClick={() => setZoom((z) => Math.min(3, +(z + 0.25).toFixed(2)))}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-black/50 text-lg font-bold text-white/80 hover:bg-black/70"
>
+
</button>
<button
type="button"
title="축소"
onClick={() => setZoom((z) => Math.max(0.5, +(z - 0.25).toFixed(2)))}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-black/50 text-lg font-bold text-white/80 hover:bg-black/70"
>
</button>
<button
type="button"
title="원본 크기"
onClick={() => setZoom(1)}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-black/50 text-xs font-bold text-white/80 hover:bg-black/70"
>
1:1
</button>
</>
)}
</div>
);
const navArrows = activeFile && files.length > 1 && (
<>
<button
type="button"
onClick={() => goFile(-1)}
className="absolute left-4 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center bg-black/40 text-xl text-white/70 hover:bg-black/60"
>
</button>
<button
type="button"
onClick={() => goFile(1)}
className="absolute right-4 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center bg-black/40 text-xl text-white/70 hover:bg-black/60"
>
</button>
</>
);
const footer = activeFile && (
<div className="shrink-0 border-t border-white/10 px-5 py-1.5 text-center text-sm text-white/40">
{`${fileIndex + 1} / ${files.length} 파일`}
{isImage && ` · ${Math.round(zoom * 100)}%`}
</div>
);
return (
<>
<main className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#0f1419]">
{toolbar}
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
{renderContent()}
{controls}
{navArrows}
</div>
{footer}
</main>
{fullscreen && activeFile &&
createPortal(
<div className="fixed inset-0 z-[10000] flex flex-col bg-black/95">
<div className="flex shrink-0 items-center justify-between border-b border-white/10 px-5 py-3">
<span className="truncate text-lg font-bold text-white/75">{headerTitle}</span>
<div className="flex items-center gap-2">
{isImage && (
<>
<button
type="button"
onClick={() => setZoom((z) => Math.max(0.5, +(z - 0.25).toFixed(2)))}
className="rounded bg-white/10 px-3 py-1 text-sm font-bold text-white hover:bg-white/20"
>
</button>
<span className="min-w-[3rem] text-center text-sm text-white/60">
{Math.round(zoom * 100)}%
</span>
<button
type="button"
onClick={() => setZoom((z) => Math.min(3, +(z + 0.25).toFixed(2)))}
className="rounded bg-white/10 px-3 py-1 text-sm font-bold text-white hover:bg-white/20"
>
+
</button>
</>
)}
<button
type="button"
onClick={() => setFullscreen(false)}
className="rounded bg-white/10 px-4 py-1 text-sm font-bold text-white hover:bg-white/20"
>
</button>
</div>
</div>
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
{renderContent()}
</div>
{files.length > 1 && (
<>
<button
type="button"
onClick={() => goFile(-1)}
className="fixed left-6 top-1/2 z-[10001] flex h-12 w-12 -translate-y-1/2 items-center justify-center bg-black/60 text-2xl text-white"
>
</button>
<button
type="button"
onClick={() => goFile(1)}
className="fixed right-6 top-1/2 z-[10001] flex h-12 w-12 -translate-y-1/2 items-center justify-center bg-black/60 text-2xl text-white"
>
</button>
</>
)}
</div>,
document.body,
)}
</>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useState, useRef, useMemo, useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { Milestone, MilestoneLink } from '../../types';
import { sortFilesByOrder } from '../../lib/fileDisplay';
import type { FileRecord, Milestone, MilestoneLink } from '../../types';
export interface StageFormData {
title: string;
@@ -8,18 +9,52 @@ export interface StageFormData {
dueDate: string;
progress: number;
description: string;
feedback: string;
links: MilestoneLink[];
}
export interface PendingFileUpload {
key: string;
file: File;
displayName: string;
sortOrder: number;
}
export interface ExistingFileEdit {
id: string;
displayName: string;
sortOrder: number;
}
export interface FileReplacement {
id: string;
file: File;
}
export interface StageFileSavePayload {
uploads: PendingFileUpload[];
existingEdits: ExistingFileEdit[];
deletedFileIds: string[];
replacements: FileReplacement[];
}
interface StageModalProps {
mode: 'add' | 'edit';
milestone?: Milestone;
onSave: (data: StageFormData, files: File[]) => Promise<void>;
existingFiles?: FileRecord[];
onSave: (data: StageFormData, files: StageFileSavePayload) => Promise<void>;
onClose: () => void;
saving?: boolean;
}
type FileRow =
| { kind: 'existing'; id: string; displayName: string; fileName: string; sortOrder: number }
| { kind: 'pending'; key: string; displayName: string; fileName: string; sortOrder: number; file: File };
type EditTarget =
| { type: 'file'; key: string }
| { type: 'link'; index: number }
| null;
function toDateInput(iso: string | null | undefined) {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
@@ -35,23 +70,189 @@ function parseLinks(raw: string | null | undefined): MilestoneLink[] {
}
}
export function StageModal({ mode, milestone, onSave, onClose, saving }: StageModalProps) {
function reindexSortOrders<T extends { sortOrder: number }>(items: T[]): T[] {
return items.map((item, i) => ({ ...item, sortOrder: i }));
}
function ListActions({
onEdit,
onDelete,
}: {
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
title="편집"
onClick={onEdit}
className="rounded px-2 py-0.5 text-xs font-bold text-slate-500 hover:bg-slate-100"
>
</button>
<button
type="button"
title="삭제"
onClick={onDelete}
className="rounded px-2 py-0.5 text-xs font-bold text-red-400 hover:bg-red-50 hover:text-red-600"
>
🗑
</button>
</div>
);
}
let pendingKeySeq = 0;
export function StageModal({
mode,
milestone,
existingFiles = [],
onSave,
onClose,
saving,
}: StageModalProps) {
const sortedExisting = useMemo(() => sortFilesByOrder(existingFiles), [existingFiles]);
const [form, setForm] = useState<StageFormData>({
title: milestone?.title ?? '',
startDate: toDateInput(milestone?.startDate),
dueDate: toDateInput(milestone?.dueDate),
progress: milestone?.progress ?? 0,
description: milestone?.description ?? '',
feedback: '',
links: parseLinks(milestone?.links),
});
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [fileRows, setFileRows] = useState<FileRow[]>([]);
const [deletedFileIds, setDeletedFileIds] = useState<string[]>([]);
const [replacements, setReplacements] = useState<Record<string, File>>({});
const [fileLabel, setFileLabel] = useState('');
const [linkLabel, setLinkLabel] = useState('');
const [linkUrl, setLinkUrl] = useState('');
const [editTarget, setEditTarget] = useState<EditTarget>(null);
const [editDisplayName, setEditDisplayName] = useState('');
const [editOrder, setEditOrder] = useState(1);
const [editLinkLabel, setEditLinkLabel] = useState('');
const [editLinkUrl, setEditLinkUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
const replaceTargetId = useRef<string | null>(null);
useEffect(() => {
setFileRows(
sortedExisting.map((f, i) => ({
kind: 'existing' as const,
id: f.id,
displayName: f.displayName ?? '',
fileName: f.originalName,
sortOrder: f.sortOrder ?? i,
})),
);
setDeletedFileIds([]);
setReplacements({});
}, [sortedExisting]);
const orderedFiles = useMemo(
() => [...fileRows].sort((a, b) => a.sortOrder - b.sortOrder),
[fileRows],
);
const set = <K extends keyof StageFormData>(key: K, value: StageFormData[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const clearEdit = () => {
setEditTarget(null);
setEditDisplayName('');
setEditOrder(1);
setEditLinkLabel('');
setEditLinkUrl('');
replaceTargetId.current = null;
};
const addFile = (file: File) => {
const key = `p-${++pendingKeySeq}`;
setFileRows((prev) =>
reindexSortOrders([
...prev,
{
kind: 'pending',
key,
file,
displayName: fileLabel.trim(),
fileName: file.name,
sortOrder: prev.length,
},
]),
);
setFileLabel('');
};
const handleFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
addFile(file);
e.target.value = '';
};
const handleReplacePick = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const targetKey = replaceTargetId.current;
if (!file || !targetKey) return;
setFileRows((prev) =>
prev.map((row) => {
const rowKey = row.kind === 'existing' ? row.id : row.key;
if (rowKey !== targetKey) return row;
if (row.kind === 'existing') {
setReplacements((r) => ({ ...r, [row.id]: file }));
return { ...row, fileName: file.name };
}
return { ...row, file, fileName: file.name };
}),
);
e.target.value = '';
replaceTargetId.current = null;
};
const removeFile = (key: string) => {
const row = fileRows.find((r) => (r.kind === 'existing' ? r.id : r.key) === key);
if (row?.kind === 'existing') {
setDeletedFileIds((prev) => [...prev, row.id]);
}
setFileRows((prev) => reindexSortOrders(prev.filter((r) => (r.kind === 'existing' ? r.id : r.key) !== key)));
if (editTarget?.type === 'file' && editTarget.key === key) clearEdit();
};
const startEditFile = (key: string) => {
const row = orderedFiles.find((r) => (r.kind === 'existing' ? r.id : r.key) === key);
if (!row) return;
const order = orderedFiles.findIndex((r) => (r.kind === 'existing' ? r.id : r.key) === key) + 1;
setEditTarget({ type: 'file', key });
setEditDisplayName(row.displayName);
setEditOrder(order);
};
const applyFileEdit = () => {
if (editTarget?.type !== 'file') return;
const key = editTarget.key;
const newOrder = Math.min(Math.max(1, editOrder), orderedFiles.length);
setFileRows((prev) => {
const sorted = [...prev].sort((a, b) => a.sortOrder - b.sortOrder);
const idx = sorted.findIndex((r) => (r.kind === 'existing' ? r.id : r.key) === key);
if (idx < 0) return prev;
const item = { ...sorted[idx], displayName: editDisplayName.trim() };
sorted.splice(idx, 1);
sorted.splice(newOrder - 1, 0, item);
return reindexSortOrders(sorted);
});
clearEdit();
};
const addLink = () => {
const url = linkUrl.trim();
if (!url) return;
@@ -60,12 +261,70 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo
setLinkUrl('');
};
const startEditLink = (index: number) => {
const link = form.links[index];
setEditTarget({ type: 'link', index });
setEditLinkLabel(link.label);
setEditLinkUrl(link.url);
};
const applyLinkEdit = () => {
if (editTarget?.type !== 'link') return;
const url = editLinkUrl.trim();
if (!url) return;
set('links', form.links.map((l, i) =>
i === editTarget.index
? { label: editLinkLabel.trim() || url, url }
: l,
));
clearEdit();
};
const removeLink = (index: number) => {
set('links', form.links.filter((_, i) => i !== index));
if (editTarget?.type === 'link' && editTarget.index === index) clearEdit();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.title.trim()) return;
await onSave(form, pendingFiles);
if (editTarget?.type === 'file') applyFileEdit();
if (editTarget?.type === 'link') applyLinkEdit();
const sorted = [...fileRows].sort((a, b) => a.sortOrder - b.sortOrder);
const uploads: PendingFileUpload[] = [];
const existingEdits: ExistingFileEdit[] = [];
sorted.forEach((row, i) => {
if (row.kind === 'pending') {
uploads.push({
key: row.key,
file: row.file,
displayName: row.displayName,
sortOrder: i,
});
} else {
existingEdits.push({
id: row.id,
displayName: row.displayName,
sortOrder: i,
});
}
});
await onSave(form, {
uploads,
existingEdits,
deletedFileIds,
replacements: Object.entries(replacements).map(([id, file]) => ({ id, file })),
});
};
const editingFileRow =
editTarget?.type === 'file'
? orderedFiles.find((r) => (r.kind === 'existing' ? r.id : r.key) === editTarget.key)
: null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4"
@@ -142,88 +401,175 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500">
{mode === 'edit' && <span className="font-normal text-slate-400">( )</span>}
</span>
<textarea
value={form.feedback}
onChange={(e) => set('feedback', e.target.value)}
rows={2}
className="w-full resize-none rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
placeholder="피드백 내용"
/>
</label>
{/* 첨부 자료 */}
<div>
<span className="mb-1 block text-sm font-bold text-slate-500"> </span>
<input
type="file"
multiple
accept=".xlsx,.xls,.ppt,.pptx,.pdf,.doc,.docx,.csv,.png,.jpg,.jpeg,.webp"
onChange={(e) => {
const list = e.target.files ? [...e.target.files] : [];
if (list.length) setPendingFiles((prev) => [...prev, ...list]);
e.target.value = '';
}}
className="w-full text-sm text-slate-600"
/>
{pendingFiles.length > 0 && (
<ul className="mt-2 space-y-1">
{pendingFiles.map((f, i) => (
<li key={`${f.name}-${i}`} className="flex items-center justify-between text-sm text-slate-600">
<span className="truncate">{f.name}</span>
<span className="mb-1 block text-sm font-bold text-slate-500"> </span>
<p className="mb-2 text-xs text-slate-400"> </p>
{editTarget?.type !== 'file' && (
<div className="flex gap-2">
<input
value={fileLabel}
onChange={(e) => setFileLabel(e.target.value)}
placeholder="표시명"
className="w-1/3 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFilePick} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="min-w-0 flex-1 rounded-lg bg-slate-100 px-3 text-sm font-bold text-slate-600 hover:bg-slate-200"
>
📎
</button>
</div>
)}
{editTarget?.type === 'file' && editingFileRow && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50/50 p-3 space-y-3">
<p className="text-sm font-bold text-emerald-800"> </p>
<label className="block">
<span className="mb-1 block text-xs font-bold text-slate-500"></span>
<input
value={editDisplayName}
onChange={(e) => setEditDisplayName(e.target.value)}
placeholder={editingFileRow.fileName}
className="w-full rounded-lg border border-slate-200 px-2 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
</label>
<div>
<span className="mb-1 block text-xs font-bold text-slate-500"> </span>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm text-slate-600">{editingFileRow.fileName}</span>
<input ref={replaceInputRef} type="file" className="hidden" onChange={handleReplacePick} />
<button
type="button"
onClick={() => setPendingFiles((prev) => prev.filter((_, idx) => idx !== i))}
className="text-red-400 hover:text-red-600"
onClick={() => {
replaceTargetId.current = editTarget.key;
replaceInputRef.current?.click();
}}
className="shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-bold text-slate-600 ring-1 ring-slate-200 hover:bg-slate-50"
>
×
</button>
</li>
))}
</div>
</div>
<label className="block">
<span className="mb-1 block text-xs font-bold text-slate-500"></span>
<input
type="number"
min={1}
max={orderedFiles.length}
value={editOrder}
onChange={(e) => setEditOrder(Number(e.target.value))}
className="w-20 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
<span className="ml-2 text-xs text-slate-400">/ {orderedFiles.length}</span>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={clearEdit} className="rounded-lg px-3 py-1.5 text-sm font-bold text-slate-500 hover:bg-white">
</button>
<button type="button" onClick={applyFileEdit} className="rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-bold text-white hover:bg-emerald-700">
</button>
</div>
</div>
)}
{orderedFiles.length > 0 && editTarget?.type !== 'file' && (
<ul className="mt-2 space-y-1 rounded-lg bg-slate-50 px-3 py-2">
{orderedFiles.map((row, i) => {
const key = row.kind === 'existing' ? row.id : row.key;
const label = row.displayName.trim() || row.fileName;
return (
<li key={key} className="flex items-center justify-between gap-2 text-sm">
<span className="flex min-w-0 items-center gap-1.5 truncate text-slate-700" title={row.fileName}>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-[10px] font-black text-emerald-700">
{i + 1}
</span>
<span className="shrink-0">📄</span>
<span className="truncate font-semibold">{label}</span>
</span>
<ListActions onEdit={() => startEditFile(key)} onDelete={() => removeFile(key)} />
</li>
);
})}
</ul>
)}
<p className="mt-1 text-xs text-slate-400">, PPT, PDF, </p>
<p className="mt-1 text-xs text-slate-400"> </p>
</div>
{/* 웹 링크 */}
<div>
<span className="mb-1 block text-sm font-bold text-slate-500"> </span>
<div className="flex gap-2">
<input
value={linkLabel}
onChange={(e) => setLinkLabel(e.target.value)}
placeholder="표시명"
className="w-1/3 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
/>
<input
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..."
className="min-w-0 flex-1 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
/>
<button
type="button"
onClick={addLink}
className="shrink-0 rounded-lg bg-slate-100 px-3 text-sm font-bold text-slate-600 hover:bg-slate-200"
>
</button>
</div>
{form.links.length > 0 && (
<ul className="mt-2 space-y-1">
<p className="mb-2 text-xs text-slate-400"> </p>
{editTarget?.type !== 'link' && (
<div className="flex gap-2">
<input
value={linkLabel}
onChange={(e) => setLinkLabel(e.target.value)}
placeholder="표시명"
className="w-1/3 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
/>
<input
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..."
className="min-w-0 flex-1 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
/>
<button
type="button"
onClick={addLink}
className="shrink-0 rounded-lg bg-slate-100 px-3 text-sm font-bold text-slate-600 hover:bg-slate-200"
>
</button>
</div>
)}
{editTarget?.type === 'link' && (
<div className="rounded-lg border border-sky-200 bg-sky-50/50 p-3 space-y-3">
<p className="text-sm font-bold text-sky-800"> </p>
<label className="block">
<span className="mb-1 block text-xs font-bold text-slate-500"></span>
<input
value={editLinkLabel}
onChange={(e) => setEditLinkLabel(e.target.value)}
className="w-full rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-bold text-slate-500">URL</span>
<input
value={editLinkUrl}
onChange={(e) => setEditLinkUrl(e.target.value)}
placeholder="https://..."
className="w-full rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={clearEdit} className="rounded-lg px-3 py-1.5 text-sm font-bold text-slate-500 hover:bg-white">
</button>
<button type="button" onClick={applyLinkEdit} className="rounded-lg bg-sky-600 px-4 py-1.5 text-sm font-bold text-white hover:bg-sky-700">
</button>
</div>
</div>
)}
{form.links.length > 0 && editTarget?.type !== 'link' && (
<ul className="mt-2 space-y-1 rounded-lg bg-slate-50 px-3 py-2">
{form.links.map((l, i) => (
<li key={l.url + i} className="flex items-center justify-between text-sm">
<a href={l.url} target="_blank" rel="noreferrer" className="truncate text-blue-600 hover:underline">
{l.label}
</a>
<button
type="button"
onClick={() => set('links', form.links.filter((_, idx) => idx !== i))}
className="text-red-400"
>
×
</button>
<li key={l.url + i} className="flex items-center justify-between gap-2 text-sm">
<span className="flex min-w-0 items-center gap-1.5 truncate text-slate-700">
<span className="shrink-0">🔗</span>
<span className="truncate font-semibold">{l.label}</span>
</span>
<ListActions onEdit={() => startEditLink(i)} onDelete={() => removeLink(i)} />
</li>
))}
</ul>
@@ -232,12 +578,7 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo
</div>
<div className="flex shrink-0 justify-end gap-2 border-t border-slate-100 px-6 py-4">
<button
type="button"
onClick={onClose}
disabled={saving}
className="rounded-lg px-4 py-2 text-sm font-bold text-slate-500 hover:bg-slate-50"
>
<button type="button" onClick={onClose} disabled={saving} className="rounded-lg px-4 py-2 text-sm font-bold text-slate-500 hover:bg-slate-50">
</button>
<button

View File

@@ -34,8 +34,8 @@ interface WindowWithScreenDetails extends Window {
getScreenDetails?: () => Promise<ScreenDetails>;
}
/** 참조 대시보드와 동일: 우측 모니터 좌표·크기 계산 */
async function getDetailWindowFeatures(): Promise<string> {
/** 우측 모니터 좌표·크기 계산 */
export async function getRightMonitorWindowFeatures(): Promise<string> {
let left = window.screenX + window.outerWidth;
let top = window.screenY;
let width = window.screen.availWidth;
@@ -92,7 +92,7 @@ export async function openDetailWindow(): Promise<Window | null> {
}
const detailUrl = `${window.location.origin}/detail`;
const features = await getDetailWindowFeatures();
const features = await getRightMonitorWindowFeatures();
detailWindow = window.open(detailUrl, DETAIL_WINDOW_NAME, features);
try {
@@ -137,3 +137,15 @@ export function closeChannel(): void {
channel?.close();
channel = null;
}
/** 웹 링크를 우측 모니터 새 창에서 열기 (같은 URL이면 기존 창 포커스) */
export async function openLinkOnRightMonitor(url: string, windowName: string): Promise<Window | null> {
const features = await getRightMonitorWindowFeatures();
const win = window.open(url, windowName, features);
try {
win?.focus();
} catch {
// popup-blocked 등
}
return win;
}

View File

@@ -0,0 +1,37 @@
import type { FileRecord } from '../types';
export function fileDisplayName(file: Pick<FileRecord, 'displayName' | 'originalName'>): string {
const custom = file.displayName?.trim();
return custom || file.originalName;
}
export function sortFilesByCreatedAsc(files: FileRecord[]): FileRecord[] {
return [...files].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
export function sortFilesByOrder(files: FileRecord[]): FileRecord[] {
return [...files].sort((a, b) => {
const orderDiff = (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
if (orderDiff !== 0) return orderDiff;
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
}
export function isVideoFile(file: Pick<FileRecord, 'mimetype' | 'originalName'>): boolean {
const name = file.originalName.toLowerCase();
return (
file.mimetype.startsWith('video/') ||
/\.(mp4|mov|avi|webm|mkv|m4v|wmv)$/i.test(name)
);
}
export function isExcelFile(file: Pick<FileRecord, 'mimetype' | 'originalName'>): boolean {
const name = file.originalName.toLowerCase();
return (
file.mimetype.includes('spreadsheet') ||
file.mimetype.includes('excel') ||
name.endsWith('.xlsx') ||
name.endsWith('.xls') ||
name.endsWith('.csv')
);
}

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>
);
}

View File

@@ -50,6 +50,7 @@ export interface TaskDetail {
updatedBy: string;
createdAt: string;
updatedAt: string;
author?: Pick<User, 'id' | 'name'>;
}
export interface MilestoneLink {
@@ -72,6 +73,8 @@ export interface FileRecord {
milestoneId: string | null;
filename: string;
originalName: string;
displayName: string | null;
sortOrder: number;
mimetype: string;
size: number;
path: string;