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:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "displayName" TEXT;
|
||||
@@ -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");
|
||||
@@ -138,6 +138,8 @@ model File {
|
||||
milestoneId String?
|
||||
filename String // 저장된 파일명 (UUID)
|
||||
originalName String // 원본 파일명
|
||||
displayName String? // 표시명 (없으면 originalName 사용)
|
||||
sortOrder Int @default(0)
|
||||
mimetype String
|
||||
size Int
|
||||
path String
|
||||
|
||||
82
backend/src/routes/details.ts
Normal file
82
backend/src/routes/details.ts
Normal 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;
|
||||
@@ -8,6 +8,15 @@ import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
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 — 파일 업로드
|
||||
router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
@@ -29,12 +38,17 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
|
||||
|
||||
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({
|
||||
data: {
|
||||
taskId,
|
||||
milestoneId,
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname,
|
||||
originalName: fixOriginalName(req.file.originalname),
|
||||
displayName,
|
||||
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
|
||||
mimetype: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
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 — 파일 삭제
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import fileRoutes from './files';
|
||||
import kpiRoutes from './kpi';
|
||||
import columnRoutes from './columns';
|
||||
import milestoneRoutes from './milestones';
|
||||
import detailRoutes from './details';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -16,5 +17,6 @@ router.use('/files', fileRoutes);
|
||||
router.use('/kpi', kpiRoutes);
|
||||
router.use('/columns', columnRoutes);
|
||||
router.use('/milestones', milestoneRoutes);
|
||||
router.use('/details', detailRoutes);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -38,7 +38,10 @@ router.get('/:id', async (req, res, next) => {
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, department: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
details: { orderBy: { createdAt: 'desc' } },
|
||||
details: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { author: { select: { id: true, name: true } } },
|
||||
},
|
||||
kpiMetrics: true,
|
||||
files: true,
|
||||
milestones: { orderBy: { order: 'asc' } },
|
||||
|
||||
104
frontend/package-lock.json
generated
104
frontend/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1678,6 +1679,15 @@
|
||||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -1789,6 +1799,28 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1808,6 +1840,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2059,6 +2103,15 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2840,6 +2893,18 @@
|
||||
"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": {
|
||||
"version": "4.3.0",
|
||||
"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": {
|
||||
"version": "8.20.1",
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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>
|
||||
<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,53 +401,112 @@ 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>
|
||||
<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
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
{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>
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFilePick} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
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={() => {
|
||||
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>
|
||||
</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>
|
||||
<p className="mb-2 text-xs text-slate-400">저장 후 결과물 프리뷰 우측에 클립 아이콘으로 표시됩니다</p>
|
||||
|
||||
{editTarget?.type !== 'link' && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={linkLabel}
|
||||
@@ -210,20 +528,48 @@ export function StageModal({ mode, milestone, onSave, onClose, saving }: StageMo
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
{form.links.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{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"
|
||||
>
|
||||
×
|
||||
)}
|
||||
|
||||
{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 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,9 +244,9 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
||||
);
|
||||
|
||||
const stageFiles = useMemo(
|
||||
() => sortByIsoDesc(
|
||||
() =>
|
||||
sortFilesByOrder(
|
||||
selectedId ? files.filter((f) => f.milestoneId === selectedId) : [],
|
||||
(f) => f.createdAt,
|
||||
),
|
||||
[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('단계는 저장됐지만 파일 업로드에 실패했습니다.');
|
||||
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"
|
||||
<ResultPreview
|
||||
files={stageFiles}
|
||||
links={stageLinks}
|
||||
hasSelectedStage={!!selectedId}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
src={`/api/files/${previewFile.id}/view`}
|
||||
title={previewFile.originalName}
|
||||
className="h-full w-full border-0"
|
||||
/>
|
||||
)}
|
||||
{stageFiles.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const prev = previewFileIndex > 0 ? previewFileIndex - 1 : stageFiles.length - 1;
|
||||
setPreviewFileId(stageFiles[prev].id);
|
||||
}}
|
||||
className="absolute left-4 flex h-10 w-10 items-center justify-center bg-black/40 text-xl text-white/70"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = previewFileIndex < stageFiles.length - 1 ? previewFileIndex + 1 : 0;
|
||||
setPreviewFileId(stageFiles[next].id);
|
||||
}}
|
||||
className="absolute right-4 flex h-10 w-10 items-center justify-center bg-black/40 text-xl text-white/70"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : previewLink ? (
|
||||
<iframe
|
||||
src={previewLink.url}
|
||||
title={previewLink.label}
|
||||
className="h-full w-full border-0 bg-white"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xl text-white/40">
|
||||
{selectedId ? '첨부된 결과물이 없습니다' : '단계를 선택하세요'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(previewFile || previewLink) && (
|
||||
<div className="shrink-0 border-t border-white/10 px-5 py-1.5 text-center text-sm text-white/40">
|
||||
{previewFile
|
||||
? `${previewFileIndex + 1} / ${stageFiles.length}`
|
||||
: `${previewLinkIndex + 1} / ${stageLinks.length} 링크`}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="shrink-0 border-t border-slate-300 bg-white px-5 py-4" style={{ height: '132px' }}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
name: eene-dashboard-backend
|
||||
runtime: node
|
||||
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
|
||||
envVars:
|
||||
- key: DATABASE_URL
|
||||
|
||||
57
서버시작.bat
57
서버시작.bat
@@ -1,14 +1,24 @@
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
title EENE Dashboard Start
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ================================
|
||||
echo EENE Dashboard - Server Start
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
:: 1. PostgreSQL 서비스 확인 (직접 설치 시 자동 실행됨)
|
||||
echo [1/3] Checking Database...
|
||||
:: 0. 기존 서버 종료 (title 설정 전에 실행 — 자기 자신 종료 방지)
|
||||
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
|
||||
if %errorlevel% neq 0 (
|
||||
echo Starting PostgreSQL service...
|
||||
@@ -17,29 +27,38 @@ if %errorlevel% neq 0 (
|
||||
echo Database is ready.
|
||||
echo.
|
||||
|
||||
:: 2. 백엔드 서버
|
||||
echo [2/3] Starting Backend Server...
|
||||
start "EENE-Backend" cmd /k "cd /d "%~dp0backend" && npm run dev"
|
||||
timeout /t 3 /nobreak > nul
|
||||
:: 2. DB 마이그레이션 + Prisma 클라이언트 갱신
|
||||
echo [3/4] DB migrate + Prisma generate...
|
||||
cd /d "%~dp0backend"
|
||||
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.
|
||||
|
||||
:: 3. 프론트엔드 서버
|
||||
echo [3/3] Starting Frontend Server...
|
||||
start "EENE-Frontend" cmd /k "cd /d "%~dp0frontend" && npm run dev"
|
||||
echo Done.
|
||||
:: 3. 백엔드 + 프론트엔드
|
||||
echo [4/4] Starting Backend + Frontend...
|
||||
echo.
|
||||
|
||||
echo ================================
|
||||
echo All servers are running!
|
||||
echo.
|
||||
echo [This PC]
|
||||
echo Dashboard : http://localhost:3000
|
||||
echo Detail : http://localhost:3000/detail
|
||||
echo Team : http://172.16.8.248:3000
|
||||
echo.
|
||||
echo [Team Access]
|
||||
echo Dashboard : http://172.16.8.248:3000
|
||||
echo Detail : http://172.16.8.248:3000/detail
|
||||
echo 종료: Ctrl+C
|
||||
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.
|
||||
echo Server stopped.
|
||||
pause
|
||||
|
||||
13
서버종료.bat
13
서버종료.bat
@@ -1,15 +1,20 @@
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
title EENE Dashboard Stop
|
||||
title EENE Dashboard - Stop
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ================================
|
||||
echo EENE Dashboard - Server Stop
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
echo [1/1] Stopping Frontend / Backend...
|
||||
taskkill /fi "WindowTitle eq EENE-Backend*" /f > nul 2>&1
|
||||
taskkill /fi "WindowTitle eq EENE-Frontend*" /f > nul 2>&1
|
||||
echo [1/2] Closing server window...
|
||||
taskkill /fi "WindowTitle eq EENE Dashboard - Running*" /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.
|
||||
|
||||
|
||||
19
윈도우시작등록.bat
19
윈도우시작등록.bat
@@ -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
|
||||
Reference in New Issue
Block a user