diff --git a/backend/prisma/migrations/20260605120000_milestone_stage_fields/migration.sql b/backend/prisma/migrations/20260605120000_milestone_stage_fields/migration.sql new file mode 100644 index 0000000..aca493a --- /dev/null +++ b/backend/prisma/migrations/20260605120000_milestone_stage_fields/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "startDate" TIMESTAMP(3); +ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "links" TEXT; + +-- AlterTable +ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "milestoneId" TEXT; +CREATE INDEX IF NOT EXISTS "files_milestoneId_idx" ON "files"("milestoneId"); +ALTER TABLE "files" ADD CONSTRAINT "files_milestoneId_fkey" + FOREIGN KEY ("milestoneId") REFERENCES "milestones"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "task_details" ADD COLUMN IF NOT EXISTS "milestoneId" TEXT; +CREATE INDEX IF NOT EXISTS "task_details_milestoneId_idx" ON "task_details"("milestoneId"); +ALTER TABLE "task_details" ADD CONSTRAINT "task_details_milestoneId_fkey" + FOREIGN KEY ("milestoneId") REFERENCES "milestones"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index aec7db9..9a10ec5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -95,17 +95,20 @@ enum Priority { // ─── 업무 상세 / 진행 기록 ──────────────────────────────────── model TaskDetail { - id String @id @default(cuid()) - taskId String - content String - updatedBy String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + taskId String + milestoneId String? + content String + updatedBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) - author User @relation(fields: [updatedBy], references: [id]) + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + milestone Milestone? @relation(fields: [milestoneId], references: [id], onDelete: Cascade) + author User @relation(fields: [updatedBy], references: [id]) @@index([taskId]) + @@index([milestoneId]) @@map("task_details") } @@ -132,6 +135,7 @@ model KpiMetric { model File { id String @id @default(cuid()) taskId String + milestoneId String? filename String // 저장된 파일명 (UUID) originalName String // 원본 파일명 mimetype String @@ -140,10 +144,12 @@ model File { uploadedBy String createdAt DateTime @default(now()) - task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) - uploader User @relation(fields: [uploadedBy], references: [id]) + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + milestone Milestone? @relation(fields: [milestoneId], references: [id], onDelete: SetNull) + uploader User @relation(fields: [uploadedBy], references: [id]) @@index([taskId]) + @@index([milestoneId]) @@map("files") } @@ -154,13 +160,17 @@ model Milestone { taskId String title String description String? + startDate DateTime? dueDate DateTime? + links String? // JSON: [{ "label": string, "url": string }] completedAt DateTime? order Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + details TaskDetail[] + files File[] @@index([taskId]) @@map("milestones") diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 8350035..23e0bc7 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -16,15 +16,26 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => const task = await prisma.task.findUnique({ where: { id: taskId } }); if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.'); + const body = req.body as Record; + const milestoneId = body.milestoneId?.trim() || null; + + if (milestoneId) { + const milestone = await prisma.milestone.findFirst({ + where: { id: milestoneId, taskId }, + }); + if (!milestone) throw new AppError(404, '단계를 찾을 수 없습니다.'); + } + const fileRecord = await prisma.file.create({ data: { taskId, + milestoneId, filename: req.file.filename, originalName: req.file.originalname, mimetype: req.file.mimetype, size: req.file.size, path: req.file.path, - uploadedBy: (req.body as Record).uploadedBy ?? 'system', + uploadedBy: body.uploadedBy ?? 'system', }, }); diff --git a/backend/src/routes/milestones.ts b/backend/src/routes/milestones.ts index 14a9c1c..1fa9f23 100644 --- a/backend/src/routes/milestones.ts +++ b/backend/src/routes/milestones.ts @@ -4,7 +4,29 @@ import { AppError } from '../middleware/errorHandler'; const router = Router(); -// GET /api/milestones/:taskId — 업무의 마일스톤 목록 +async function resolveUpdatedBy(taskId: string): Promise { + const task = await prisma.task.findUnique({ where: { id: taskId }, select: { creatorId: true } }); + if (task?.creatorId) return task.creatorId; + const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' }, select: { id: true } }); + if (!admin) throw new AppError(500, '피드백 작성자를 찾을 수 없습니다.'); + return admin.id; +} + +function normalizeLinks(links: unknown): string | null { + if (!links) return null; + if (typeof links === 'string') { + try { + JSON.parse(links); + return links; + } catch { + return null; + } + } + if (Array.isArray(links)) return JSON.stringify(links); + return null; +} + +// GET /api/milestones/:taskId router.get('/:taskId', async (req, res, next) => { try { const milestones = await prisma.milestone.findMany({ @@ -17,52 +39,90 @@ router.get('/:taskId', async (req, res, next) => { } }); -// POST /api/milestones/:taskId — 마일스톤 추가 +// POST /api/milestones/:taskId router.post('/:taskId', async (req, res, next) => { try { - const { title, description, dueDate } = req.body as Record; + const taskId = req.params.taskId; + const { title, description, startDate, dueDate, feedback, links } = + req.body as Record; - const count = await prisma.milestone.count({ where: { taskId: req.params.taskId } }); + if (!title?.trim()) throw new AppError(400, '단계 제목은 필수입니다.'); + + const count = await prisma.milestone.count({ where: { taskId } }); const milestone = await prisma.milestone.create({ data: { - taskId: req.params.taskId, - title, - description: description || null, + taskId, + title: title.trim(), + description: description?.trim() || null, + startDate: startDate ? new Date(startDate) : null, dueDate: dueDate ? new Date(dueDate) : null, + links: normalizeLinks(links), order: count, }, }); + + if (feedback?.trim()) { + const updatedBy = await resolveUpdatedBy(taskId); + await prisma.taskDetail.create({ + data: { + taskId, + milestoneId: milestone.id, + content: feedback.trim(), + updatedBy, + }, + }); + } + res.status(201).json(milestone); } catch (err) { next(err); } }); -// PATCH /api/milestones/item/:id — 마일스톤 수정 (완료 처리 포함) +// PATCH /api/milestones/item/:id router.patch('/item/:id', async (req, res, next) => { try { - const { title, description, dueDate, completed, order } = req.body as Record; + const { title, description, startDate, dueDate, feedback, links, completed, order } = + req.body as Record; + + const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } }); + if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.'); const milestone = await prisma.milestone.update({ where: { id: req.params.id }, data: { - ...(title !== undefined && { title: title as string }), - ...(description !== undefined && { description: description as string || null }), - ...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate as string) : null }), - ...(order !== undefined && { order: Number(order) }), - ...(completed !== undefined && { + ...(title !== undefined && { title: String(title).trim() }), + ...(description !== undefined && { description: description ? String(description).trim() : null }), + ...(startDate !== undefined && { startDate: startDate ? new Date(startDate as string) : null }), + ...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate as string) : null }), + ...(links !== undefined && { links: normalizeLinks(links) }), + ...(order !== undefined && { order: Number(order) }), + ...(completed !== undefined && { completedAt: completed ? new Date() : null, }), }, }); + + if (typeof feedback === 'string' && feedback.trim()) { + const updatedBy = await resolveUpdatedBy(existing.taskId); + await prisma.taskDetail.create({ + data: { + taskId: existing.taskId, + milestoneId: milestone.id, + content: feedback.trim(), + updatedBy, + }, + }); + } + res.json(milestone); } catch (err) { next(err); } }); -// DELETE /api/milestones/item/:id — 마일스톤 삭제 +// DELETE /api/milestones/item/:id router.delete('/item/:id', async (req, res, next) => { try { await prisma.milestone.delete({ where: { id: req.params.id } }); diff --git a/frontend/src/components/detail/StageModal.tsx b/frontend/src/components/detail/StageModal.tsx new file mode 100644 index 0000000..eacc067 --- /dev/null +++ b/frontend/src/components/detail/StageModal.tsx @@ -0,0 +1,241 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { Milestone, MilestoneLink } from '../../types'; + +export interface StageFormData { + title: string; + startDate: string; + dueDate: string; + description: string; + feedback: string; + links: MilestoneLink[]; +} + +interface StageModalProps { + mode: 'add' | 'edit'; + milestone?: Milestone; + onSave: (data: StageFormData, files: File[]) => Promise; + onClose: () => void; + saving?: boolean; +} + +function toDateInput(iso: string | null | undefined) { + if (!iso) return ''; + return new Date(iso).toISOString().slice(0, 10); +} + +function parseLinks(raw: string | null | undefined): MilestoneLink[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as MilestoneLink[]; + return Array.isArray(parsed) ? parsed.filter((l) => l.url) : []; + } catch { + return []; + } +} + +export function StageModal({ mode, milestone, onSave, onClose, saving }: StageModalProps) { + const [form, setForm] = useState({ + title: milestone?.title ?? '', + startDate: toDateInput(milestone?.startDate), + dueDate: toDateInput(milestone?.dueDate), + description: milestone?.description ?? '', + feedback: '', + links: parseLinks(milestone?.links), + }); + const [pendingFiles, setPendingFiles] = useState([]); + const [linkLabel, setLinkLabel] = useState(''); + const [linkUrl, setLinkUrl] = useState(''); + + const set = (key: K, value: StageFormData[K]) => + setForm((prev) => ({ ...prev, [key]: value })); + + const addLink = () => { + const url = linkUrl.trim(); + if (!url) return; + set('links', [...form.links, { label: linkLabel.trim() || url, url }]); + setLinkLabel(''); + setLinkUrl(''); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.title.trim()) return; + await onSave(form, pendingFiles); + }; + + return createPortal( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +
+

+ {mode === 'add' ? '업무 단계 추가' : '업무 단계 수정'} +

+
+ +
+ + +
+ + +
+ +