diff --git a/backend/prisma/migrations/20260605140000_file_display_name/migration.sql b/backend/prisma/migrations/20260605140000_file_display_name/migration.sql new file mode 100644 index 0000000..8450f07 --- /dev/null +++ b/backend/prisma/migrations/20260605140000_file_display_name/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "displayName" TEXT; diff --git a/backend/prisma/migrations/20260605150000_file_sort_order/migration.sql b/backend/prisma/migrations/20260605150000_file_sort_order/migration.sql new file mode 100644 index 0000000..7c99730 --- /dev/null +++ b/backend/prisma/migrations/20260605150000_file_sort_order/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9aaf292..cd6fe13 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -138,6 +138,8 @@ model File { milestoneId String? filename String // 저장된 파일명 (UUID) originalName String // 원본 파일명 + displayName String? // 표시명 (없으면 originalName 사용) + sortOrder Int @default(0) mimetype String size Int path String diff --git a/backend/src/routes/details.ts b/backend/src/routes/details.ts new file mode 100644 index 0000000..10783ad --- /dev/null +++ b/backend/src/routes/details.ts @@ -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 { + 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; + + 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; + 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; diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 89b0082..c0a1107 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -8,6 +8,15 @@ import { AppError } from '../middleware/errorHandler'; const router = Router(); +/** multer가 latin1로 전달하는 한글 파일명 복원 */ +function fixOriginalName(name: string): string { + try { + return Buffer.from(name, 'latin1').toString('utf8') || name; + } catch { + return name; + } +} + // POST /api/files/upload/:taskId — 파일 업로드 router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => { try { @@ -29,12 +38,17 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => const uploadedBy = await resolveTaskActorId(taskId); + const displayName = body.displayName?.trim() || null; + const sortOrder = body.sortOrder !== undefined ? Number(body.sortOrder) : 0; + const fileRecord = await prisma.file.create({ data: { taskId, milestoneId, filename: req.file.filename, - originalName: req.file.originalname, + originalName: fixOriginalName(req.file.originalname), + displayName, + sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder, mimetype: req.file.mimetype, size: req.file.size, path: req.file.path, @@ -77,6 +91,56 @@ router.get('/:id/download', async (req, res, next) => { } }); +// POST /api/files/:id/replace — 파일 교체 +router.post('/:id/replace', upload.single('file'), async (req, res, next) => { + try { + if (!req.file) throw new AppError(400, '파일이 없습니다.'); + + const existing = await prisma.file.findUnique({ where: { id: req.params.id } }); + if (!existing) throw new AppError(404, '파일을 찾을 수 없습니다.'); + + if (fs.existsSync(existing.path)) { + fs.unlinkSync(existing.path); + } + + const file = await prisma.file.update({ + where: { id: req.params.id }, + data: { + filename: req.file.filename, + originalName: fixOriginalName(req.file.originalname), + mimetype: req.file.mimetype, + size: req.file.size, + path: req.file.path, + }, + }); + + res.json(file); + } catch (err) { + next(err); + } +}); + +// PATCH /api/files/:id — 표시명·순서 수정 +router.patch('/:id', async (req, res, next) => { + try { + const { displayName, sortOrder } = req.body as { displayName?: string; sortOrder?: number }; + const existing = await prisma.file.findUnique({ where: { id: req.params.id } }); + if (!existing) throw new AppError(404, '파일을 찾을 수 없습니다.'); + + const file = await prisma.file.update({ + where: { id: req.params.id }, + data: { + ...(displayName !== undefined && { displayName: displayName?.trim() || null }), + ...(sortOrder !== undefined && { sortOrder: Number(sortOrder) }), + }, + }); + + res.json(file); + } catch (err) { + next(err); + } +}); + // DELETE /api/files/:id — 파일 삭제 router.delete('/:id', async (req, res, next) => { try { diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index ae27fd3..ee9ecc8 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -6,6 +6,7 @@ import fileRoutes from './files'; import kpiRoutes from './kpi'; import columnRoutes from './columns'; import milestoneRoutes from './milestones'; +import detailRoutes from './details'; const router = Router(); @@ -16,5 +17,6 @@ router.use('/files', fileRoutes); router.use('/kpi', kpiRoutes); router.use('/columns', columnRoutes); router.use('/milestones', milestoneRoutes); +router.use('/details', detailRoutes); export default router; diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index 70615e2..c801994 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -38,7 +38,10 @@ router.get('/:id', async (req, res, next) => { include: { assignee: { select: { id: true, name: true, department: true } }, creator: { select: { id: true, name: true } }, - details: { orderBy: { createdAt: 'desc' } }, + details: { + orderBy: { createdAt: 'desc' }, + include: { author: { select: { id: true, name: true } } }, + }, kpiMetrics: true, files: true, milestones: { orderBy: { order: 'asc' } }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c06b747..3a1f82f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "react-dom": "^18.3.0", "react-router-dom": "^6.26.0", "socket.io-client": "^4.8.0", + "xlsx": "^0.18.5", "zustand": "^5.0.0" }, "devDependencies": { @@ -1678,6 +1679,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -1789,6 +1799,28 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1808,6 +1840,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2059,6 +2103,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2840,6 +2893,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -3004,6 +3069,24 @@ } } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ws": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", @@ -3025,6 +3108,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 61f62a0..3f78204 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "react-dom": "^18.3.0", "react-router-dom": "^6.26.0", "socket.io-client": "^4.8.0", + "xlsx": "^0.18.5", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6272eb1..dad965c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,16 @@ import { BrowserRouter } from 'react-router-dom'; import { AppRouter } from './router'; +import { AuthProvider } from './contexts/AuthContext'; import { SocketProvider } from './contexts/SocketContext'; export default function App() { return ( - - - + + + + + ); } diff --git a/frontend/src/components/detail/ExcelPreview.tsx b/frontend/src/components/detail/ExcelPreview.tsx new file mode 100644 index 0000000..7841175 --- /dev/null +++ b/frontend/src/components/detail/ExcelPreview.tsx @@ -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(null); + const [html, setHtml] = useState(null); + const [error, setError] = useState(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 ( + + ); + } + + if (!html) { + return

엑셀 미리보기 로딩 중…

; + } + + const sheetNames = workbook?.SheetNames ?? []; + + return ( +
+ {sheetNames.length > 1 && ( +
+ {sheetNames.map((name, i) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/detail/FeedbackModal.tsx b/frontend/src/components/detail/FeedbackModal.tsx new file mode 100644 index 0000000..53f7cca --- /dev/null +++ b/frontend/src/components/detail/FeedbackModal.tsx @@ -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; + onClose: () => void; + saving?: boolean; +} + +export function FeedbackModal({ + mode, + detail, + defaultAuthorName = '', + onSave, + onClose, + saving, +}: FeedbackModalProps) { + const [form, setForm] = useState({ + 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( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +
+

+ {mode === 'add' ? '피드백 추가' : '피드백 수정'} +

+
+ +
+