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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
90
frontend/src/components/detail/ExcelPreview.tsx
Normal file
90
frontend/src/components/detail/ExcelPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
frontend/src/components/detail/FeedbackModal.tsx
Normal file
104
frontend/src/components/detail/FeedbackModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
254
frontend/src/components/detail/ResultPreview.tsx
Normal file
254
frontend/src/components/detail/ResultPreview.tsx
Normal 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
37
frontend/src/lib/fileDisplay.ts
Normal file
37
frontend/src/lib/fileDisplay.ts
Normal 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')
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user