diff --git a/backend/prisma/mapHrProjects.ts b/backend/prisma/mapHrProjects.ts index d7d522f..8a985dc 100644 --- a/backend/prisma/mapHrProjects.ts +++ b/backend/prisma/mapHrProjects.ts @@ -261,10 +261,9 @@ function buildMilestones(p: HrProject): MappedTask['milestones'] { return milestones; } -function buildDetailContent(p: HrProject): string | null { - const content = p.progressStatus?.trim() || p.progressLog?.trim(); - if (!content || content === '이슈사항' || content === '12') return null; - return content; +/** @deprecated progressStatus는 milestone·periodEntries로 이관 — TaskDetail(피드백)에 넣지 않음 */ +function buildDetailContent(_p: HrProject): string | null { + return null; } export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTask { diff --git a/backend/prisma/migrations/20260611120000_task_detail_description/migration.sql b/backend/prisma/migrations/20260611120000_task_detail_description/migration.sql new file mode 100644 index 0000000..a110217 --- /dev/null +++ b/backend/prisma/migrations/20260611120000_task_detail_description/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "tasks" ADD COLUMN "detailDescription" TEXT; + +-- 기존 description(첫 줄=개요, 이후=상세) → 분리 (실행과제·프로젝트만) +UPDATE "tasks" +SET + "detailDescription" = CASE + WHEN position(E'\n' in "description") > 0 + THEN NULLIF(trim(substring("description" from position(E'\n' in "description") + 1)), '') + ELSE NULL + END, + "description" = NULLIF(trim(split_part("description", E'\n', 1)), '') +WHERE "description" IS NOT NULL + AND trim("description") != '' + AND ("taskType" = '실행과제' OR "taskType" = '프로젝트'); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8285141..88d1c48 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -66,6 +66,7 @@ model Task { id String @id @default(cuid()) title String description String? + detailDescription String? status TaskStatus @default(TODO) priority Priority @default(MEDIUM) quarter String // 예: "2026-Q2" diff --git a/backend/scripts/cleanup-legacy-task-details.ts b/backend/scripts/cleanup-legacy-task-details.ts new file mode 100644 index 0000000..741f884 --- /dev/null +++ b/backend/scripts/cleanup-legacy-task-details.ts @@ -0,0 +1,54 @@ +/** + * HR import 시 TaskDetail(피드백)에 잘못 들어간 progressStatus 레거시 삭제 + * — milestoneId 없고 authorName 없는 행 (seed 자동 생성분) + * + * npx tsx scripts/cleanup-legacy-task-details.ts + * npx tsx scripts/cleanup-legacy-task-details.ts --dry-run + */ +import 'dotenv/config'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); +const dryRun = process.argv.includes('--dry-run'); + +async function main() { + const legacy = await prisma.taskDetail.findMany({ + where: { + milestoneId: null, + OR: [{ authorName: null }, { authorName: '' }], + }, + include: { task: { select: { title: true } } }, + orderBy: { createdAt: 'asc' }, + }); + + if (legacy.length === 0) { + console.log('✅ 삭제할 레거시 피드백 없음'); + return; + } + + console.log(`레거시 TaskDetail ${legacy.length}건 (일정 미연결 · 작성자 없음):`); + for (const row of legacy) { + const preview = row.content.replace(/\s+/g, ' ').slice(0, 72); + console.log(` · [${row.task.title}] ${preview}${row.content.length > 72 ? '…' : ''}`); + } + + if (dryRun) { + console.log('\n(dry-run — 삭제하지 않음. 적용: npx tsx scripts/cleanup-legacy-task-details.ts)'); + return; + } + + const result = await prisma.taskDetail.deleteMany({ + where: { + id: { in: legacy.map((r) => r.id) }, + }, + }); + + console.log(`\n✅ ${result.count}건 삭제 완료`); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/normalize-task-sections.ts b/backend/scripts/normalize-task-sections.ts new file mode 100644 index 0000000..0bdbb09 --- /dev/null +++ b/backend/scripts/normalize-task-sections.ts @@ -0,0 +1,77 @@ +/** + * task.section 레거시 값 정규화 + 조직문화(EX) 재배치 + * npx tsx scripts/normalize-task-sections.ts + */ +import 'dotenv/config'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const EX_TITLE = /회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i; + +const SECTION_MAP: Record = { + 성장지원: '학습성장', + HR: '인사관리', + 운영지원: '운영관리', + 전산관리: '운영관리', +}; + +async function main() { + const tasks = await prisma.task.findMany({ + select: { id: true, title: true, section: true, category: true, taskType: true }, + }); + + let renamed = 0; + let exMoved = 0; + + for (const task of tasks) { + const section = task.section?.trim() ?? ''; + let nextSection = SECTION_MAP[section] ?? section; + + if (nextSection !== '조직문화' && EX_TITLE.test(task.title.trim())) { + nextSection = '조직문화'; + exMoved += 1; + } + + const patch: { section?: string; category?: string | null } = {}; + if (nextSection && nextSection !== section) { + patch.section = nextSection; + renamed += 1; + } + if (nextSection === '조직문화' && task.category !== '조직문화') { + patch.category = '조직문화'; + } + + if (Object.keys(patch).length > 0) { + await prisma.task.update({ where: { id: task.id }, data: patch }); + console.log(` section: ${section || '(empty)'} → ${nextSection} | ${task.title}`); + } + } + + const col = await prisma.columnConfig.findUnique({ where: { key: '운영관리' } }); + if (col && (col.title === '운영관리' || col.title === '운영관리 부문' || col.titleEn === 'Operations')) { + await prisma.columnConfig.update({ + where: { key: '운영관리' }, + data: { title: '총무관리', titleEn: 'GA' }, + }); + console.log(' columnConfig 운영관리 → 총무관리'); + } + + const hrd = await prisma.columnConfig.findUnique({ where: { key: '학습성장' } }); + if (hrd && (hrd.title === '학습성장' || hrd.title === '성장지원' || hrd.titleEn === 'Learning & Growth')) { + await prisma.columnConfig.update({ + where: { key: '학습성장' }, + data: { title: '인재육성', titleEn: 'HRD' }, + }); + console.log(' columnConfig 학습성장 → 인재육성'); + } + + console.log(`\n✅ normalize-task-sections complete (${renamed} renamed, ${exMoved} → 조직문화)`); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/src/lib/taskIssues.ts b/backend/src/lib/taskIssues.ts index 89c5539..0937c9c 100644 --- a/backend/src/lib/taskIssues.ts +++ b/backend/src/lib/taskIssues.ts @@ -2,12 +2,20 @@ export interface TaskIssueEntry { id: string; text: string; showOnCard: boolean; + occurredOn?: string | null; } function newIssueId() { return `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } +function normalizeOccurredOn(raw: unknown): string | null { + if (typeof raw !== 'string' || !raw.trim()) return null; + const iso = raw.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null; + return iso; +} + export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] { if (!Array.isArray(raw)) return []; const entries: TaskIssueEntry[] = []; @@ -20,6 +28,7 @@ export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] { id: typeof row.id === 'string' && row.id ? row.id : newIssueId(), text, showOnCard: row.showOnCard !== false, + occurredOn: normalizeOccurredOn(row.occurredOn), }); } return entries; @@ -34,7 +43,7 @@ export function parseIssueEntriesFromTask(task: { if (fromJson.length > 0) return fromJson; const legacy = task.issueNote?.trim(); if (!legacy) return []; - return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false }]; + return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false, occurredOn: null }]; } export function deriveIssueFields(entries: TaskIssueEntry[]) { diff --git a/backend/src/lib/taskQuery.ts b/backend/src/lib/taskQuery.ts index a920d4b..3500d6f 100644 --- a/backend/src/lib/taskQuery.ts +++ b/backend/src/lib/taskQuery.ts @@ -25,6 +25,18 @@ export const taskInclude = { taskAssignees: { include: { member: { select: teamMemberSelect } }, }, + milestones: { + orderBy: { order: 'asc' as const }, + select: { + id: true, + title: true, + progress: true, + startDate: true, + dueDate: true, + periodEntries: true, + order: true, + }, + }, _count: { select: { files: true, details: true } }, } as const; diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index e7a8e78..a938d2b 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -72,7 +72,7 @@ router.get('/:id', async (req, res, next) => { router.post('/', async (req, res, next) => { try { const body = req.body as Record; - const { title, description, status, priority, quarter, category, + const { title, description, detailDescription, status, priority, quarter, category, section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate, showDescription, showStatus, showIssue, showProgress, pmMemberId } = body; @@ -88,6 +88,7 @@ router.post('/', async (req, res, next) => { data: { title, description, + detailDescription: detailDescription ?? null, status: (status as any) || 'TODO', priority: (priority as any) || 'MEDIUM', quarter, @@ -135,7 +136,7 @@ router.patch('/:id', async (req, res, next) => { if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.'); const body = req.body as Record; - const { title, description, status, priority, quarter, category, + const { title, description, detailDescription, status, priority, quarter, category, section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate, showDescription, showStatus, showIssue, showProgress, pmMemberId } = body; @@ -147,6 +148,7 @@ router.patch('/:id', async (req, res, next) => { data: { ...(title && { title }), ...(description !== undefined && { description }), + ...(detailDescription !== undefined && { detailDescription: detailDescription || null }), ...(status && { status: status as any }), ...(priority && { priority: priority as any }), ...(quarter && { quarter }), diff --git a/data/postgres/base/16384/1249 b/data/postgres/base/16384/1249 index 500e30d..4b885d5 100644 Binary files a/data/postgres/base/16384/1249 and b/data/postgres/base/16384/1249 differ diff --git a/data/postgres/base/16384/1259 b/data/postgres/base/16384/1259 index 950aa1d..33ab7ec 100644 Binary files a/data/postgres/base/16384/1259 and b/data/postgres/base/16384/1259 differ diff --git a/data/postgres/base/16384/24576 b/data/postgres/base/16384/24576 index e639b2f..3aa9345 100644 Binary files a/data/postgres/base/16384/24576 and b/data/postgres/base/16384/24576 differ diff --git a/data/postgres/base/16384/24583 b/data/postgres/base/16384/24583 index ae1b19d..536a552 100644 Binary files a/data/postgres/base/16384/24583 and b/data/postgres/base/16384/24583 differ diff --git a/data/postgres/base/16384/24625 b/data/postgres/base/16384/24625 index d07c132..796fa39 100644 Binary files a/data/postgres/base/16384/24625 and b/data/postgres/base/16384/24625 differ diff --git a/data/postgres/base/16384/24625_fsm b/data/postgres/base/16384/24625_fsm new file mode 100644 index 0000000..9b569b6 Binary files /dev/null and b/data/postgres/base/16384/24625_fsm differ diff --git a/data/postgres/base/16384/24633 b/data/postgres/base/16384/24633 index f347396..9f5f54c 100644 Binary files a/data/postgres/base/16384/24633 and b/data/postgres/base/16384/24633 differ diff --git a/data/postgres/base/16384/24635 b/data/postgres/base/16384/24635 index ebb3acd..425c255 100644 Binary files a/data/postgres/base/16384/24635 and b/data/postgres/base/16384/24635 differ diff --git a/data/postgres/base/16384/24669 b/data/postgres/base/16384/24669 index fa94f4b..d6fd97e 100644 Binary files a/data/postgres/base/16384/24669 and b/data/postgres/base/16384/24669 differ diff --git a/data/postgres/base/16384/24670 b/data/postgres/base/16384/24670 index 9dde9de..52a0633 100644 Binary files a/data/postgres/base/16384/24670 and b/data/postgres/base/16384/24670 differ diff --git a/data/postgres/base/16384/24671 b/data/postgres/base/16384/24671 index 35215e5..6e7b4b7 100644 Binary files a/data/postgres/base/16384/24671 and b/data/postgres/base/16384/24671 differ diff --git a/data/postgres/base/16384/24718 b/data/postgres/base/16384/24718 index f0af234..a7d40ee 100644 Binary files a/data/postgres/base/16384/24718 and b/data/postgres/base/16384/24718 differ diff --git a/data/postgres/base/16384/24719 b/data/postgres/base/16384/24719 index 4283728..77f698f 100644 Binary files a/data/postgres/base/16384/24719 and b/data/postgres/base/16384/24719 differ diff --git a/data/postgres/base/16384/24754 b/data/postgres/base/16384/24754 index 317ca52..721117f 100644 Binary files a/data/postgres/base/16384/24754 and b/data/postgres/base/16384/24754 differ diff --git a/data/postgres/base/16384/24764 b/data/postgres/base/16384/24764 index 8f283ee..caa93dd 100644 Binary files a/data/postgres/base/16384/24764 and b/data/postgres/base/16384/24764 differ diff --git a/data/postgres/base/16384/24769 b/data/postgres/base/16384/24769 index 8a0888e..a7cd722 100644 Binary files a/data/postgres/base/16384/24769 and b/data/postgres/base/16384/24769 differ diff --git a/data/postgres/base/16384/24788 b/data/postgres/base/16384/24788 index 0036af9..8386148 100644 Binary files a/data/postgres/base/16384/24788 and b/data/postgres/base/16384/24788 differ diff --git a/data/postgres/base/16384/24789 b/data/postgres/base/16384/24789 index 0b1dde2..377e6cc 100644 Binary files a/data/postgres/base/16384/24789 and b/data/postgres/base/16384/24789 differ diff --git a/data/postgres/base/16384/2658 b/data/postgres/base/16384/2658 index f48449c..2bc1787 100644 Binary files a/data/postgres/base/16384/2658 and b/data/postgres/base/16384/2658 differ diff --git a/data/postgres/base/16384/2659 b/data/postgres/base/16384/2659 index f2b1cce..5454d28 100644 Binary files a/data/postgres/base/16384/2659 and b/data/postgres/base/16384/2659 differ diff --git a/data/postgres/base/16384/pg_internal.init b/data/postgres/base/16384/pg_internal.init index f97a3ba..10103ae 100644 Binary files a/data/postgres/base/16384/pg_internal.init and b/data/postgres/base/16384/pg_internal.init differ diff --git a/data/postgres/global/pg_control b/data/postgres/global/pg_control index 7b85ce3..0eebbcd 100644 Binary files a/data/postgres/global/pg_control and b/data/postgres/global/pg_control differ diff --git a/data/postgres/global/pg_internal.init b/data/postgres/global/pg_internal.init index 4d566f2..f675586 100644 Binary files a/data/postgres/global/pg_internal.init and b/data/postgres/global/pg_internal.init differ diff --git a/data/postgres/pg_wal/000000010000000000000001 b/data/postgres/pg_wal/000000010000000000000001 index 4b9e08a..e58a140 100644 Binary files a/data/postgres/pg_wal/000000010000000000000001 and b/data/postgres/pg_wal/000000010000000000000001 differ diff --git a/data/postgres/pg_xact/0000 b/data/postgres/pg_xact/0000 index bf7d02f..65c892a 100644 Binary files a/data/postgres/pg_xact/0000 and b/data/postgres/pg_xact/0000 differ diff --git a/frontend/src/components/common/StageFormFields.tsx b/frontend/src/components/common/StageFormFields.tsx new file mode 100644 index 0000000..d7f17d7 --- /dev/null +++ b/frontend/src/components/common/StageFormFields.tsx @@ -0,0 +1,262 @@ +import type { StageFormData } from '../detail/stageFormTypes'; +import { newPeriodEntry } from '../../lib/milestonePeriods'; +import type { TeamMember } from '../../types'; + +interface StageFormFieldsProps { + variant: 'project' | 'routine'; + form: StageFormData; + onChange: (next: StageFormData) => void; + teamMembers?: TeamMember[]; + idPrefix?: string; +} + +export function StageFormFields({ + variant, + form, + onChange, + teamMembers = [], + idPrefix = 'stage-form', +}: StageFormFieldsProps) { + const isRoutine = variant === 'routine'; + + const set = (field: K, value: StageFormData[K]) => + onChange({ ...form, [field]: value }); + + const updatePeriodEntry = (id: string, patch: Partial<(typeof form.periodEntries)[0]>) => { + onChange({ + ...form, + periodEntries: form.periodEntries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)), + }); + }; + + const toggleAssignee = (memberId: string) => { + const has = form.assigneeMemberIds.includes(memberId); + onChange({ + ...form, + assigneeMemberIds: has + ? form.assigneeMemberIds.filter((id) => id !== memberId) + : [...form.assigneeMemberIds, memberId], + }); + }; + + return ( +
+
+ + set('title', e.target.value)} + className="task-form-input task-form-input--title" + placeholder="업무 일정 제목" + /> +
+ + {isRoutine && ( +
+ + set('subtitle', e.target.value)} + className="task-form-input" + placeholder="업무명 아래 표시 (선택)" + /> +
+ )} + +
+ + set('progress', Number(e.target.value))} + className="task-form-range" + /> +
+ +
+
+ 수행 기간 + +
+ {form.periodEntries.length === 0 ? ( +

등록된 기간이 없습니다. 보류 후 재개·분기별 수행 등 기간을 추가하세요.

+ ) : ( +
+ {form.periodEntries.map((entry, index) => ( +
+
+ 기간 {index + 1} + +
+
+ updatePeriodEntry(entry.id, { startDate: e.target.value })} + className="task-form-input" + aria-label={`기간 ${index + 1} 시작일`} + /> + updatePeriodEntry(entry.id, { dueDate: e.target.value })} + className="task-form-input" + aria-label={`기간 ${index + 1} 종료일`} + /> +
+