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

@@ -0,0 +1 @@
ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "displayName" TEXT;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "sortOrder" INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS "files_milestoneId_sortOrder_idx" ON "files"("milestoneId", "sortOrder");

View File

@@ -138,6 +138,8 @@ model File {
milestoneId String? milestoneId String?
filename String // 저장된 파일명 (UUID) filename String // 저장된 파일명 (UUID)
originalName String // 원본 파일명 originalName String // 원본 파일명
displayName String? // 표시명 (없으면 originalName 사용)
sortOrder Int @default(0)
mimetype String mimetype String
size Int size Int
path String path String

View File

@@ -0,0 +1,82 @@
import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { resolveTaskActorId } from '../lib/resolveUser';
import { AppError } from '../middleware/errorHandler';
const router = Router();
async function resolveAuthorId(taskId: string, authorName?: string): Promise<string> {
const name = authorName?.toString().trim();
if (name) {
const user = await prisma.user.findFirst({ where: { name } });
if (user) return user.id;
}
return resolveTaskActorId(taskId);
}
// POST /api/details/:taskId
router.post('/:taskId', async (req, res, next) => {
try {
const taskId = req.params.taskId;
const { content, milestoneId, authorName } = req.body as Record<string, string>;
if (!content?.toString().trim()) throw new AppError(400, '피드백 내용은 필수입니다.');
const task = await prisma.task.findUnique({ where: { id: taskId }, select: { id: true } });
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
if (milestoneId) {
const ms = await prisma.milestone.findFirst({ where: { id: milestoneId, taskId } });
if (!ms) throw new AppError(400, '유효하지 않은 업무 단계입니다.');
}
const updatedBy = await resolveAuthorId(taskId, authorName);
const detail = await prisma.taskDetail.create({
data: {
taskId,
milestoneId: milestoneId || null,
content: content.toString().trim(),
updatedBy,
},
include: { author: { select: { id: true, name: true } } },
});
res.status(201).json(detail);
} catch (err) {
next(err);
}
});
// PATCH /api/details/item/:id
router.patch('/item/:id', async (req, res, next) => {
try {
const { content } = req.body as Record<string, string>;
if (!content?.toString().trim()) throw new AppError(400, '피드백 내용은 필수입니다.');
const existing = await prisma.taskDetail.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '피드백을 찾을 수 없습니다.');
const detail = await prisma.taskDetail.update({
where: { id: req.params.id },
data: { content: content.toString().trim() },
include: { author: { select: { id: true, name: true } } },
});
res.json(detail);
} catch (err) {
next(err);
}
});
// DELETE /api/details/item/:id
router.delete('/item/:id', async (req, res, next) => {
try {
await prisma.taskDetail.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -8,6 +8,15 @@ import { AppError } from '../middleware/errorHandler';
const router = Router(); const router = Router();
/** multer가 latin1로 전달하는 한글 파일명 복원 */
function fixOriginalName(name: string): string {
try {
return Buffer.from(name, 'latin1').toString('utf8') || name;
} catch {
return name;
}
}
// POST /api/files/upload/:taskId — 파일 업로드 // POST /api/files/upload/:taskId — 파일 업로드
router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => { router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => {
try { try {
@@ -29,12 +38,17 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
const uploadedBy = await resolveTaskActorId(taskId); const uploadedBy = await resolveTaskActorId(taskId);
const displayName = body.displayName?.trim() || null;
const sortOrder = body.sortOrder !== undefined ? Number(body.sortOrder) : 0;
const fileRecord = await prisma.file.create({ const fileRecord = await prisma.file.create({
data: { data: {
taskId, taskId,
milestoneId, milestoneId,
filename: req.file.filename, filename: req.file.filename,
originalName: req.file.originalname, originalName: fixOriginalName(req.file.originalname),
displayName,
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
mimetype: req.file.mimetype, mimetype: req.file.mimetype,
size: req.file.size, size: req.file.size,
path: req.file.path, path: req.file.path,
@@ -77,6 +91,56 @@ router.get('/:id/download', async (req, res, next) => {
} }
}); });
// POST /api/files/:id/replace — 파일 교체
router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
try {
if (!req.file) throw new AppError(400, '파일이 없습니다.');
const existing = await prisma.file.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '파일을 찾을 수 없습니다.');
if (fs.existsSync(existing.path)) {
fs.unlinkSync(existing.path);
}
const file = await prisma.file.update({
where: { id: req.params.id },
data: {
filename: req.file.filename,
originalName: fixOriginalName(req.file.originalname),
mimetype: req.file.mimetype,
size: req.file.size,
path: req.file.path,
},
});
res.json(file);
} catch (err) {
next(err);
}
});
// PATCH /api/files/:id — 표시명·순서 수정
router.patch('/:id', async (req, res, next) => {
try {
const { displayName, sortOrder } = req.body as { displayName?: string; sortOrder?: number };
const existing = await prisma.file.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '파일을 찾을 수 없습니다.');
const file = await prisma.file.update({
where: { id: req.params.id },
data: {
...(displayName !== undefined && { displayName: displayName?.trim() || null }),
...(sortOrder !== undefined && { sortOrder: Number(sortOrder) }),
},
});
res.json(file);
} catch (err) {
next(err);
}
});
// DELETE /api/files/:id — 파일 삭제 // DELETE /api/files/:id — 파일 삭제
router.delete('/:id', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
try { try {

View File

@@ -6,6 +6,7 @@ import fileRoutes from './files';
import kpiRoutes from './kpi'; import kpiRoutes from './kpi';
import columnRoutes from './columns'; import columnRoutes from './columns';
import milestoneRoutes from './milestones'; import milestoneRoutes from './milestones';
import detailRoutes from './details';
const router = Router(); const router = Router();
@@ -16,5 +17,6 @@ router.use('/files', fileRoutes);
router.use('/kpi', kpiRoutes); router.use('/kpi', kpiRoutes);
router.use('/columns', columnRoutes); router.use('/columns', columnRoutes);
router.use('/milestones', milestoneRoutes); router.use('/milestones', milestoneRoutes);
router.use('/details', detailRoutes);
export default router; export default router;

View File

@@ -38,7 +38,10 @@ router.get('/:id', async (req, res, next) => {
include: { include: {
assignee: { select: { id: true, name: true, department: true } }, assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } }, creator: { select: { id: true, name: true } },
details: { orderBy: { createdAt: 'desc' } }, details: {
orderBy: { createdAt: 'desc' },
include: { author: { select: { id: true, name: true } } },
},
kpiMetrics: true, kpiMetrics: true,
files: true, files: true,
milestones: { orderBy: { order: 'asc' } }, milestones: { orderBy: { order: 'asc' } },

View File

@@ -17,6 +17,7 @@
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -1678,6 +1679,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -1789,6 +1799,28 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1808,6 +1840,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2059,6 +2103,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2840,6 +2893,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -3004,6 +3069,24 @@
} }
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.1", "version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
@@ -3025,6 +3108,27 @@
} }
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xmlhttprequest-ssl": { "node_modules/xmlhttprequest-ssl": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",

View File

@@ -18,6 +18,7 @@
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,13 +1,16 @@
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { AppRouter } from './router'; import { AppRouter } from './router';
import { AuthProvider } from './contexts/AuthContext';
import { SocketProvider } from './contexts/SocketContext'; import { SocketProvider } from './contexts/SocketContext';
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<SocketProvider> <AuthProvider>
<AppRouter /> <SocketProvider>
</SocketProvider> <AppRouter />
</SocketProvider>
</AuthProvider>
</BrowserRouter> </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 { 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 { export interface StageFormData {
title: string; title: string;
@@ -8,18 +9,52 @@ export interface StageFormData {
dueDate: string; dueDate: string;
progress: number; progress: number;
description: string; description: string;
feedback: string;
links: MilestoneLink[]; 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 { interface StageModalProps {
mode: 'add' | 'edit'; mode: 'add' | 'edit';
milestone?: Milestone; milestone?: Milestone;
onSave: (data: StageFormData, files: File[]) => Promise<void>; existingFiles?: FileRecord[];
onSave: (data: StageFormData, files: StageFileSavePayload) => Promise<void>;
onClose: () => void; onClose: () => void;
saving?: boolean; 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) { function toDateInput(iso: string | null | undefined) {
if (!iso) return ''; if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10); 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>({ const [form, setForm] = useState<StageFormData>({
title: milestone?.title ?? '', title: milestone?.title ?? '',
startDate: toDateInput(milestone?.startDate), startDate: toDateInput(milestone?.startDate),
dueDate: toDateInput(milestone?.dueDate), dueDate: toDateInput(milestone?.dueDate),
progress: milestone?.progress ?? 0, progress: milestone?.progress ?? 0,
description: milestone?.description ?? '', description: milestone?.description ?? '',
feedback: '',
links: parseLinks(milestone?.links), 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 [linkLabel, setLinkLabel] = useState('');
const [linkUrl, setLinkUrl] = 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]) => const set = <K extends keyof StageFormData>(key: K, value: StageFormData[K]) =>
setForm((prev) => ({ ...prev, [key]: value })); 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 addLink = () => {
const url = linkUrl.trim(); const url = linkUrl.trim();
if (!url) return; if (!url) return;
@@ -60,12 +261,70 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo
setLinkUrl(''); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!form.title.trim()) return; 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( return createPortal(
<div <div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4" 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>
<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> <div>
<span className="mb-1 block text-sm font-bold text-slate-500"> </span> <span className="mb-1 block text-sm font-bold text-slate-500"> </span>
<input <p className="mb-2 text-xs text-slate-400"> </p>
type="file"
multiple {editTarget?.type !== 'file' && (
accept=".xlsx,.xls,.ppt,.pptx,.pdf,.doc,.docx,.csv,.png,.jpg,.jpeg,.webp" <div className="flex gap-2">
onChange={(e) => { <input
const list = e.target.files ? [...e.target.files] : []; value={fileLabel}
if (list.length) setPendingFiles((prev) => [...prev, ...list]); onChange={(e) => setFileLabel(e.target.value)}
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"
className="w-full text-sm text-slate-600" />
/> <input ref={fileInputRef} type="file" className="hidden" onChange={handleFilePick} />
{pendingFiles.length > 0 && ( <button
<ul className="mt-2 space-y-1"> type="button"
{pendingFiles.map((f, i) => ( onClick={() => fileInputRef.current?.click()}
<li key={`${f.name}-${i}`} className="flex items-center justify-between text-sm text-slate-600"> className="min-w-0 flex-1 rounded-lg bg-slate-100 px-3 text-sm font-bold text-slate-600 hover:bg-slate-200"
<span className="truncate">{f.name}</span> >
📎
</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 <button
type="button" type="button"
onClick={() => setPendingFiles((prev) => prev.filter((_, idx) => idx !== i))} onClick={() => {
className="text-red-400 hover:text-red-600" 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> </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> </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>
{/* 웹 링크 */}
<div> <div>
<span className="mb-1 block text-sm font-bold text-slate-500"> </span> <span className="mb-1 block text-sm font-bold text-slate-500"> </span>
<div className="flex gap-2"> <p className="mb-2 text-xs text-slate-400"> </p>
<input
value={linkLabel} {editTarget?.type !== 'link' && (
onChange={(e) => setLinkLabel(e.target.value)} <div className="flex gap-2">
placeholder="표시명" <input
className="w-1/3 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none" value={linkLabel}
/> onChange={(e) => setLinkLabel(e.target.value)}
<input placeholder="표시명"
value={linkUrl} className="w-1/3 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
onChange={(e) => setLinkUrl(e.target.value)} />
placeholder="https://..." <input
className="min-w-0 flex-1 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none" value={linkUrl}
/> onChange={(e) => setLinkUrl(e.target.value)}
<button placeholder="https://..."
type="button" className="min-w-0 flex-1 rounded-lg border border-slate-200 px-2 py-2 text-sm focus:outline-none"
onClick={addLink} />
className="shrink-0 rounded-lg bg-slate-100 px-3 text-sm font-bold text-slate-600 hover:bg-slate-200" <button
> type="button"
onClick={addLink}
</button> className="shrink-0 rounded-lg bg-slate-100 px-3 text-sm font-bold text-slate-600 hover:bg-slate-200"
</div> >
{form.links.length > 0 && (
<ul className="mt-2 space-y-1"> </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) => ( {form.links.map((l, i) => (
<li key={l.url + i} className="flex items-center justify-between text-sm"> <li key={l.url + i} className="flex items-center justify-between gap-2 text-sm">
<a href={l.url} target="_blank" rel="noreferrer" className="truncate text-blue-600 hover:underline"> <span className="flex min-w-0 items-center gap-1.5 truncate text-slate-700">
{l.label} <span className="shrink-0">🔗</span>
</a> <span className="truncate font-semibold">{l.label}</span>
<button </span>
type="button" <ListActions onEdit={() => startEditLink(i)} onDelete={() => removeLink(i)} />
onClick={() => set('links', form.links.filter((_, idx) => idx !== i))}
className="text-red-400"
>
×
</button>
</li> </li>
))} ))}
</ul> </ul>
@@ -232,12 +578,7 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo
</div> </div>
<div className="flex shrink-0 justify-end gap-2 border-t border-slate-100 px-6 py-4"> <div className="flex shrink-0 justify-end gap-2 border-t border-slate-100 px-6 py-4">
<button <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">
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>
<button <button

View File

@@ -34,8 +34,8 @@ interface WindowWithScreenDetails extends Window {
getScreenDetails?: () => Promise<ScreenDetails>; getScreenDetails?: () => Promise<ScreenDetails>;
} }
/** 참조 대시보드와 동일: 우측 모니터 좌표·크기 계산 */ /** 우측 모니터 좌표·크기 계산 */
async function getDetailWindowFeatures(): Promise<string> { export async function getRightMonitorWindowFeatures(): Promise<string> {
let left = window.screenX + window.outerWidth; let left = window.screenX + window.outerWidth;
let top = window.screenY; let top = window.screenY;
let width = window.screen.availWidth; let width = window.screen.availWidth;
@@ -92,7 +92,7 @@ export async function openDetailWindow(): Promise<Window | null> {
} }
const detailUrl = `${window.location.origin}/detail`; const detailUrl = `${window.location.origin}/detail`;
const features = await getDetailWindowFeatures(); const features = await getRightMonitorWindowFeatures();
detailWindow = window.open(detailUrl, DETAIL_WINDOW_NAME, features); detailWindow = window.open(detailUrl, DETAIL_WINDOW_NAME, features);
try { try {
@@ -137,3 +137,15 @@ export function closeChannel(): void {
channel?.close(); channel?.close();
channel = null; 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient'; import { apiClient } from '../lib/apiClient';
import { onDualMonitorEvent } from '../lib/dualMonitor'; import { onDualMonitorEvent } from '../lib/dualMonitor';
import { ContextMenu } from '../components/common/ContextMenu'; 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'; import type { Task, Milestone, FileRecord, TaskDetail } from '../types';
const STATUS_CONFIG: Record<string, { label: string }> = { const STATUS_CONFIG: Record<string, { label: string }> = {
@@ -191,11 +200,14 @@ function DetailHeader({ task }: { task: Task }) {
function DetailView({ task }: { task: TaskWithRelations }) { function DetailView({ task }: { task: TaskWithRelations }) {
const qc = useQueryClient(); const qc = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const { user } = useAuth();
const [uploading, setUploading] = useState(false);
const [stageSaving, setStageSaving] = useState(false); const [stageSaving, setStageSaving] = useState(false);
const [feedbackSaving, setFeedbackSaving] = useState(false);
const [stageModal, setStageModal] = useState<{ mode: 'add' | 'edit'; milestone?: Milestone } | null>(null); 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 [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 milestones = task.milestones ?? [];
const files = task.files ?? []; const files = task.files ?? [];
@@ -218,17 +230,9 @@ function DetailView({ task }: { task: TaskWithRelations }) {
const stageContents = useMemo(() => { const stageContents = useMemo(() => {
if (!selected?.description) return []; if (!selected?.description) return [];
return parseContentLines(selected.description).map((text) => ({ return parseContentLines(selected.description);
text,
date: selected.updatedAt,
}));
}, [selected]); }, [selected]);
const sortedContents = useMemo(
() => sortByIsoDesc(stageContents, (c) => c.date),
[stageContents],
);
const stageDetails = useMemo( const stageDetails = useMemo(
() => (selectedId ? details.filter((d) => d.milestoneId === selectedId) : []), () => (selectedId ? details.filter((d) => d.milestoneId === selectedId) : []),
[details, selectedId], [details, selectedId],
@@ -240,10 +244,10 @@ function DetailView({ task }: { task: TaskWithRelations }) {
); );
const stageFiles = useMemo( const stageFiles = useMemo(
() => sortByIsoDesc( () =>
selectedId ? files.filter((f) => f.milestoneId === selectedId) : [], sortFilesByOrder(
(f) => f.createdAt, selectedId ? files.filter((f) => f.milestoneId === selectedId) : [],
), ),
[files, selectedId], [files, selectedId],
); );
@@ -252,18 +256,6 @@ function DetailView({ task }: { task: TaskWithRelations }) {
[selected], [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 timeline = useMemo(() => buildTimeline(task, milestones), [task, milestones]);
const deleteMs = useMutation({ const deleteMs = useMutation({
@@ -271,18 +263,22 @@ function DetailView({ task }: { task: TaskWithRelations }) {
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
}); });
const uploadFiles = async (milestoneId: string, fileList: File[]) => { const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
for (const file of fileList) { for (const item of filePayload) {
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', item.file);
form.append('milestoneId', milestoneId); 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, { await apiClient.post(`/files/upload/${task.id}`, form, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
} }
}; };
const handleStageSave = async (data: StageFormData, fileList: File[]) => { const handleStageSave = async (data: StageFormData, filePayload: StageFileSavePayload) => {
setStageSaving(true); setStageSaving(true);
try { try {
const payload = { const payload = {
@@ -291,7 +287,6 @@ function DetailView({ task }: { task: TaskWithRelations }) {
startDate: data.startDate || undefined, startDate: data.startDate || undefined,
dueDate: data.dueDate || undefined, dueDate: data.dueDate || undefined,
progress: data.progress, progress: data.progress,
feedback: data.feedback.trim() || undefined,
links: data.links.length > 0 ? JSON.stringify(data.links) : undefined, links: data.links.length > 0 ? JSON.stringify(data.links) : undefined,
}; };
@@ -311,12 +306,37 @@ function DetailView({ task }: { task: TaskWithRelations }) {
return; return;
} }
if (fileList.length > 0) { try {
try { for (const id of filePayload.deletedFileIds) {
await uploadFiles(milestoneId, fileList); await apiClient.delete(`/files/${id}`);
} catch {
alert('단계는 저장됐지만 파일 업로드에 실패했습니다.');
} }
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] }); await qc.invalidateQueries({ queryKey: ['task', task.id] });
@@ -330,19 +350,41 @@ function DetailView({ task }: { task: TaskWithRelations }) {
} }
}; };
const handleQuickUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFeedbackSave = async (data: FeedbackFormData) => {
const file = e.target.files?.[0]; if (!selectedId && feedbackModal?.mode === 'add') {
if (!file || !selectedId) return; alert('피드백을 추가할 업무 단계를 먼저 선택하세요.');
setUploading(true); return;
}
setFeedbackSaving(true);
try { 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] }); 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 { } finally {
setUploading(false); setFeedbackSaving(false);
if (fileInputRef.current) fileInputRef.current.value = '';
} }
}; };
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() || '등록된 개요가 없습니다.'; const overview = task.description?.split('\n')[0]?.trim() || '등록된 개요가 없습니다.';
return ( return (
@@ -393,22 +435,21 @@ function DetailView({ task }: { task: TaskWithRelations }) {
: 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-white' : 'border-slate-200 bg-slate-50 hover:border-slate-300 hover:bg-white'
}`} }`}
> >
<div className="mb-1 flex items-center justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<span className="text-sm font-semibold text-slate-400">{fmtStageRange(stage)}</span> <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 <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 >= 100 ? 'text-emerald-600' : progress > 0 ? 'text-blue-500' : 'text-slate-300'
}`} }`}
> >
{progress}% {progress}%
</span> </span>
</div> </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> </button>
); );
})} })}
@@ -416,18 +457,33 @@ function DetailView({ task }: { task: TaskWithRelations }) {
</LeftSection> </LeftSection>
<LeftSection> <LeftSection>
<PanelLabel sub={selected?.title ?? '전체'}></PanelLabel> <PanelLabel></PanelLabel>
<ul className="min-h-0 flex-1 space-y-2 overflow-hidden"> <ul
{sortedContents.length === 0 ? ( className="min-h-0 flex-1 space-y-2 overflow-hidden"
<li className="text-lg text-slate-400"> </li> 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) => ( stageContents.map((text) => (
<li key={item.text + item.date} className="flex gap-2"> <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> <span className="shrink-0 text-lg text-blue-400"></span>
<div className="min-w-0 flex-1"> <p className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-800">{text}</p>
<p className="truncate text-2xl font-black leading-snug text-slate-800">{item.text}</p>
<p className="text-sm font-semibold text-slate-400">{fmtShort(item.date)}</p>
</div>
</li> </li>
)) ))
)} )}
@@ -435,16 +491,44 @@ function DetailView({ task }: { task: TaskWithRelations }) {
</LeftSection> </LeftSection>
<LeftSection> <LeftSection>
<PanelLabel sub={selected?.title ?? '전체'}></PanelLabel> <div className="mb-2 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-2">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden"> <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 ? ( {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"> <div className="mb-2 min-h-0 flex-1 space-y-2 overflow-hidden">
{sortedFeedbacks.map((f) => ( {sortedFeedbacks.map((f) => (
<div key={f.id} className="rounded-lg bg-slate-50 px-3 py-2"> <div
<p className="mb-0.5 text-sm font-semibold text-slate-400">{fmtShort(f.createdAt)}</p> key={f.id}
<p className="truncate text-2xl font-black leading-snug text-slate-700">{f.content}</p> className="rounded-lg bg-slate-50 px-3 py-2"
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setFeedbackCtx({ x: e.clientX, y: e.clientY, detailId: f.id });
}}
>
<p className="truncate text-2xl font-black leading-snug text-slate-700">
{f.content}
<span className="font-bold text-slate-400"> {f.author?.name ?? '—'}</span>
</p>
</div> </div>
))} ))}
</div> </div>
@@ -455,89 +539,11 @@ function DetailView({ task }: { task: TaskWithRelations }) {
{/* 우 3/4 */} {/* 우 3/4 */}
<div className="flex h-full min-h-0 min-w-0 flex-col"> <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]"> <ResultPreview
<div className="flex shrink-0 items-center justify-between border-b border-white/10 px-5 py-2"> files={stageFiles}
<span className="text-lg font-bold text-white/75"> links={stageLinks}
hasSelectedStage={!!selectedId}
{previewFile && ( />
<span className="ml-2 text-base font-normal text-emerald-400/80">{previewFile.originalName}</span>
)}
</span>
<div className="flex items-center gap-2">
<input ref={fileInputRef} type="file" className="hidden" onChange={handleQuickUpload} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || !selectedId}
className="rounded bg-white/10 px-3 py-1 text-sm font-bold text-white/70 hover:bg-white/20 disabled:opacity-50"
>
{uploading ? '업로드 중…' : '+ 파일'}
</button>
</div>
</div>
<div className="relative flex min-h-0 flex-1 items-center justify-center">
{previewFile ? (
<>
{previewFile.mimetype.includes('image') ? (
<img
src={`/api/files/${previewFile.id}/view`}
alt={previewFile.originalName}
className="max-h-full max-w-full object-contain p-4"
/>
) : (
<iframe
src={`/api/files/${previewFile.id}/view`}
title={previewFile.originalName}
className="h-full w-full border-0"
/>
)}
{stageFiles.length > 1 && (
<>
<button
type="button"
onClick={() => {
const prev = previewFileIndex > 0 ? previewFileIndex - 1 : stageFiles.length - 1;
setPreviewFileId(stageFiles[prev].id);
}}
className="absolute left-4 flex h-10 w-10 items-center justify-center bg-black/40 text-xl text-white/70"
>
</button>
<button
type="button"
onClick={() => {
const next = previewFileIndex < stageFiles.length - 1 ? previewFileIndex + 1 : 0;
setPreviewFileId(stageFiles[next].id);
}}
className="absolute right-4 flex h-10 w-10 items-center justify-center bg-black/40 text-xl text-white/70"
>
</button>
</>
)}
</>
) : previewLink ? (
<iframe
src={previewLink.url}
title={previewLink.label}
className="h-full w-full border-0 bg-white"
/>
) : (
<p className="text-xl text-white/40">
{selectedId ? '첨부된 결과물이 없습니다' : '단계를 선택하세요'}
</p>
)}
</div>
{(previewFile || previewLink) && (
<div className="shrink-0 border-t border-white/10 px-5 py-1.5 text-center text-sm text-white/40">
{previewFile
? `${previewFileIndex + 1} / ${stageFiles.length}`
: `${previewLinkIndex + 1} / ${stageLinks.length} 링크`}
</div>
)}
</main>
<footer className="shrink-0 border-t border-slate-300 bg-white px-5 py-4" style={{ height: '132px' }}> <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"> <div className="mb-2 flex items-center justify-between">
@@ -597,12 +603,28 @@ function DetailView({ task }: { task: TaskWithRelations }) {
<StageModal <StageModal
mode={stageModal.mode} mode={stageModal.mode}
milestone={stageModal.milestone} milestone={stageModal.milestone}
existingFiles={
stageModal.milestone
? sortFilesByOrder(files.filter((f) => f.milestoneId === stageModal.milestone!.id))
: []
}
saving={stageSaving} saving={stageSaving}
onClose={() => setStageModal(null)} onClose={() => setStageModal(null)}
onSave={handleStageSave} onSave={handleStageSave}
/> />
)} )}
{feedbackModal && (
<FeedbackModal
mode={feedbackModal.mode}
detail={feedbackModal.detail}
defaultAuthorName={user?.name ?? ''}
saving={feedbackSaving}
onClose={() => setFeedbackModal(null)}
onSave={handleFeedbackSave}
/>
)}
{ctxMenu && ( {ctxMenu && (
<ContextMenu <ContextMenu
x={ctxMenu.x} 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> </div>
); );
} }

View File

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

View File

@@ -3,7 +3,7 @@ services:
name: eene-dashboard-backend name: eene-dashboard-backend
runtime: node runtime: node
rootDir: backend rootDir: backend
buildCommand: npm install --include=dev && npx prisma generate && npm run build buildCommand: npm install --include=dev && npx prisma migrate deploy && npx prisma generate && npm run build
startCommand: npm start startCommand: npm start
envVars: envVars:
- key: DATABASE_URL - key: DATABASE_URL

View File

@@ -1,14 +1,24 @@
@echo off @echo off
chcp 65001 > nul chcp 65001 > nul
title EENE Dashboard Start cd /d "%~dp0"
echo ================================ echo ================================
echo EENE Dashboard - Server Start echo EENE Dashboard - Server Start
echo ================================ echo ================================
echo. echo.
:: 1. PostgreSQL 서비스 확인 (직접 설치 시 자동 실행됨) :: 0. 기존 서버 종료 (title 설정 전에 실행 — 자기 자신 종료 방지)
echo [1/3] Checking Database... echo [1/4] Stopping old servers...
taskkill /fi "WindowTitle eq EENE Dashboard - Running*" /f > nul 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { @(4000,3000,3001) -contains $_.LocalPort } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }" > nul 2>&1
timeout /t 2 /nobreak > nul
echo Done.
echo.
title EENE Dashboard - Running
:: 1. PostgreSQL 서비스 확인
echo [2/4] Checking Database...
sc query postgresql-x64-16 | find "RUNNING" > nul 2>&1 sc query postgresql-x64-16 | find "RUNNING" > nul 2>&1
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo Starting PostgreSQL service... echo Starting PostgreSQL service...
@@ -17,29 +27,38 @@ if %errorlevel% neq 0 (
echo Database is ready. echo Database is ready.
echo. echo.
:: 2. 백엔드 서버 :: 2. DB 마이그레이션 + Prisma 클라이언트 갱신
echo [2/3] Starting Backend Server... echo [3/4] DB migrate + Prisma generate...
start "EENE-Backend" cmd /k "cd /d "%~dp0backend" && npm run dev" cd /d "%~dp0backend"
timeout /t 3 /nobreak > nul call npx prisma migrate deploy
if errorlevel 1 (
echo Migration failed. backend\.env DATABASE_URL 확인
pause
exit /b 1
)
call npx prisma generate
if errorlevel 1 (
echo Prisma generate failed. 다른 터미널/서버가 켜져 있으면 종료 후 다시 시도하세요.
pause
exit /b 1
)
cd /d "%~dp0"
echo Done. echo Done.
echo. echo.
:: 3. 프론트엔드 서버 :: 3. 백엔드 + 프론트엔드
echo [3/3] Starting Frontend Server... echo [4/4] Starting Backend + Frontend...
start "EENE-Frontend" cmd /k "cd /d "%~dp0frontend" && npm run dev" echo.
echo Done. echo Dashboard : http://localhost:3000
echo Detail : http://localhost:3000/detail
echo Team : http://172.16.8.248:3000
echo.
echo 종료: Ctrl+C
echo ================================
echo. echo.
echo ================================ npx --yes concurrently -k -n "API,WEB" -c "cyan,green" "cd /d %~dp0backend && npm run dev" "cd /d %~dp0frontend && npm run dev"
echo All servers are running!
echo.
echo [This PC]
echo Dashboard : http://localhost:3000
echo Detail : http://localhost:3000/detail
echo.
echo [Team Access]
echo Dashboard : http://172.16.8.248:3000
echo Detail : http://172.16.8.248:3000/detail
echo ================================
echo. echo.
echo Server stopped.
pause pause

View File

@@ -1,15 +1,20 @@
@echo off @echo off
chcp 65001 > nul chcp 65001 > nul
title EENE Dashboard Stop title EENE Dashboard - Stop
cd /d "%~dp0"
echo ================================ echo ================================
echo EENE Dashboard - Server Stop echo EENE Dashboard - Server Stop
echo ================================ echo ================================
echo. echo.
echo [1/1] Stopping Frontend / Backend... echo [1/2] Closing server window...
taskkill /fi "WindowTitle eq EENE-Backend*" /f > nul 2>&1 taskkill /fi "WindowTitle eq EENE Dashboard - Running*" /f > nul 2>&1
taskkill /fi "WindowTitle eq EENE-Frontend*" /f > nul 2>&1 echo Done.
echo.
echo [2/2] Stopping API / WEB (ports 4000, 3000, 3001)...
powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { @(4000,3000,3001) -contains $_.LocalPort } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }" > nul 2>&1
echo Done. echo Done.
echo. echo.

View File

@@ -1,19 +0,0 @@
@echo off
chcp 65001 > nul
title Auto Start Registration
set STARTUP=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup
set TARGET=%~dp0서버시작.bat
echo Registering EENE Dashboard for Windows startup...
echo.
powershell -Command "$ws = New-Object -ComObject WScript.Shell; $lnk = $ws.CreateShortcut('%STARTUP%\EENE-Dashboard.lnk'); $lnk.TargetPath = '%TARGET%'; $lnk.WorkingDirectory = '%~dp0'; $lnk.Description = 'EENE Dashboard Auto Start'; $lnk.Save()"
if %errorlevel% equ 0 (
echo [Done] Server will auto-start when Windows boots.
) else (
echo [Error] Please run as Administrator.
)
echo.
pause