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?
|
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
|
||||||
|
|||||||
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();
|
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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' } },
|
||||||
|
|||||||
104
frontend/package-lock.json
generated
104
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
63
서버시작.bat
63
서버시작.bat
@@ -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
|
||||||
|
|||||||
13
서버종료.bat
13
서버종료.bat
@@ -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.
|
||||||
|
|
||||||
|
|||||||
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