diff --git a/backend/package.json b/backend/package.json index 0c16566..0c8d063 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,8 @@ "db:migrate": "prisma migrate dev", "db:generate": "prisma generate", "db:studio": "prisma studio", - "db:seed": "tsx prisma/seed.ts" + "db:seed": "tsx prisma/seed.ts", + "db:import-hr": "tsx scripts/import-hr-data.ts" }, "dependencies": { "@prisma/client": "^6.0.0", diff --git a/backend/prisma/mapHrProjects.ts b/backend/prisma/mapHrProjects.ts new file mode 100644 index 0000000..def3639 --- /dev/null +++ b/backend/prisma/mapHrProjects.ts @@ -0,0 +1,211 @@ +import fs from 'fs'; +import path from 'path'; +import type { Priority, TaskStatus } from '@prisma/client'; + +export interface HrProject { + idx?: number; + name: string; + pm?: string; + priority: string; + startDate?: string; + endDate?: string; + status?: string; + category: string; + summary?: string; + content?: string; + briefIntro?: string; + progress?: number; + progressLog?: string; + progressStatus?: string; + issues?: string; + statusText?: string; + isIssue?: boolean; + keywords?: string[]; + refLinks?: { name: string; url: string }[]; + referenceUrl?: string; + subPhases?: { name: string; status?: string; text?: string }[]; + timelineItems?: { startDate?: string; endDate?: string; desc?: string }[]; + showOnDashboard?: boolean; +} + +export interface MappedTask { + title: string; + description: string | null; + status: TaskStatus; + priority: Priority; + quarter: string; + category: string; + section: string; + taskType: string; + progress: number; + issueNote: string | null; + startDate: Date | null; + dueDate: Date | null; + keywords: string | null; + showDate: boolean; + showDescription: boolean; + showStatus: boolean; + showIssue: boolean; + showProgress: boolean; + milestones: { + title: string; + description: string | null; + progress: number; + links: string | null; + }[]; + detailContent: string | null; +} + +const SECTION_MAP: Record = { + 인사관리: '인사관리', + 성장지원: '학습성장', + 운영지원: '운영지원', + 전산관리: '전산관리', +}; + +const STATUS_MAP: Record = { + 진행: 'IN_PROGRESS', + 진행중: 'IN_PROGRESS', + 대기: 'TODO', + 완료: 'DONE', +}; + +const PHASE_PROGRESS: Record = { + 완료: 100, + 진행중: 50, + 미착수: 0, +}; + +export function defaultHrDataPath(): string { + return path.resolve(__dirname, '../../../HR_Dashboard/data.json'); +} + +export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] { + const raw = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw) as { PROJECTS?: HrProject[] }; + return (data.PROJECTS ?? []).filter((p) => p.showOnDashboard !== false); +} + +function parseDate(value?: string): Date | null { + if (!value?.trim()) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; +} + +function mapSection(category: string): string { + return SECTION_MAP[category] ?? category; +} + +function mapStatus(status?: string, isRoutine = false): TaskStatus { + if (!status?.trim()) return isRoutine ? 'IN_PROGRESS' : 'TODO'; + return STATUS_MAP[status] ?? 'IN_PROGRESS'; +} + +function mapPriority(priority: string): Priority { + if (priority === '상시') return 'MEDIUM'; + if (priority === '높음') return 'HIGH'; + if (priority === '낮음') return 'LOW'; + return 'MEDIUM'; +} + +function mapProgress(value?: number): number { + if (value == null || Number.isNaN(value)) return 0; + if (value <= 1) return Math.round(value * 100); + return Math.min(100, Math.round(value)); +} + +function pickDescription(p: HrProject): string | null { + const candidates = [p.content, p.summary, p.briefIntro].map((v) => v?.trim()).filter(Boolean) as string[]; + const best = candidates.find((v) => v.length > 2 && v !== '12'); + return best ?? null; +} + +function pickIssueNote(p: HrProject): string | null { + const parts: string[] = []; + if (p.isIssue && p.statusText?.trim()) parts.push(p.statusText.trim()); + if (p.issues?.trim()) parts.push(p.issues.trim()); + if (p.progressLog?.trim() && p.progressLog !== '이슈사항') parts.push(p.progressLog.trim()); + return parts.length ? parts.join('\n') : null; +} + +function buildLinks(p: HrProject): string | null { + const links: { label: string; url: string }[] = []; + for (const ref of p.refLinks ?? []) { + if (ref.url?.trim()) links.push({ label: ref.name || '참고자료', url: ref.url.trim() }); + } + if (p.referenceUrl?.trim()) links.push({ label: '참고링크', url: p.referenceUrl.trim() }); + return links.length ? JSON.stringify(links) : null; +} + +function buildMilestones(p: HrProject): MappedTask['milestones'] { + const milestones: MappedTask['milestones'] = []; + + for (const [i, phase] of (p.subPhases ?? []).entries()) { + milestones.push({ + title: phase.name, + description: phase.text?.trim() || null, + progress: PHASE_PROGRESS[phase.status ?? ''] ?? 0, + links: null, + }); + } + + for (const item of p.timelineItems ?? []) { + if (!item.desc?.trim()) continue; + milestones.push({ + title: item.desc.trim(), + description: [item.startDate, item.endDate].filter(Boolean).join(' ~ ') || null, + progress: 0, + links: null, + }); + } + + if (milestones.length === 0 && buildLinks(p)) { + milestones.push({ + title: '참고자료', + description: null, + progress: 0, + links: buildLinks(p), + }); + } + + 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; +} + +export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTask { + const isRoutine = p.priority === '상시'; + const taskType = isRoutine ? '기반업무' : '실행과제'; + const visible = !isRoutine; + + return { + title: p.name.trim(), + description: pickDescription(p), + status: mapStatus(p.status, isRoutine), + priority: mapPriority(p.priority), + quarter, + category: mapSection(p.category), + section: mapSection(p.category), + taskType, + progress: mapProgress(p.progress), + issueNote: pickIssueNote(p), + startDate: parseDate(p.startDate), + dueDate: parseDate(p.endDate), + keywords: p.keywords?.length ? p.keywords.join(', ') : null, + showDate: visible, + showDescription: visible, + showStatus: visible, + showIssue: visible, + showProgress: visible, + milestones: buildMilestones(p), + detailContent: buildDetailContent(p), + }; +} + +export function mapAllHrProjects(filePath?: string, quarter = '2026-Q2'): MappedTask[] { + return loadHrProjects(filePath).map((p) => mapHrProjectToTask(p, quarter)); +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 839f160..1ff8671 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,13 +1,13 @@ import 'dotenv/config'; import bcrypt from 'bcrypt'; import { PrismaClient } from '@prisma/client'; +import { mapAllHrProjects } from './mapHrProjects'; const prisma = new PrismaClient(); async function main() { console.log('🌱 Seeding database...'); - // ─── 사용자 ───────────────────────────────────────────── const adminPw = await bcrypt.hash('admin1234!', 12); const memberPw = await bcrypt.hash('member1234!', 12); @@ -19,141 +19,48 @@ async function main() { const member = await prisma.user.upsert({ where: { email: 'member@eene.com' }, - update: {}, - create: { email: 'member@eene.com', password: memberPw, name: '홍길동', role: 'MEMBER', department: 'EENE' }, + update: { name: '정성호' }, + create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' }, }); - console.log(`✅ Users ready`); + console.log('✅ Users ready'); - // ─── 기존 업무 삭제 후 재생성 ───────────────────────── - await prisma.kpiMetric.deleteMany({}); + const mapped = mapAllHrProjects(); + + await prisma.file.deleteMany({}); await prisma.taskDetail.deleteMany({}); + await prisma.milestone.deleteMany({}); + await prisma.kpiMetric.deleteMany({}); await prisma.task.deleteMany({}); - const allTasks = [ - // ─── 인사관리 ─────────────────────────────────────── - { - title: '그룹 표준 취업규칙 개정안 (HRM)', - description: '유연근무제 및 근태 관리 프로세스 고도화\n노사협의회 안건 조율 및 근로조건 개선안 반영', - status: 'IN_PROGRESS' as const, - priority: 'HIGH' as const, - section: '인사관리', - tag: 'Policy', - taskType: '프로젝트', - progress: 40, - issueNote: '[5.28] 가족사 간 피드백 이견 조율 진행', - startDate: new Date('2026-04-01'), - dueDate: new Date('2026-06-30'), - }, - { - title: '상반기 평가 지표(KPI) 보완', - description: '1분기 피드백 기반 부서별 KPI 정렬\n상반기 업적 평가 시뮬레이션 기획', - status: 'IN_PROGRESS' as const, - priority: 'MEDIUM' as const, - section: '인사관리', - tag: 'Performance', - taskType: '상시업무', - progress: 70, - issueNote: null, - startDate: new Date('2026-04-01'), - dueDate: new Date('2026-06-30'), - }, - { - title: '가족사 시너지 조직문화 캠페인', - description: '임직원 만족도 조사(Engagement Survey) 설계', - status: 'TODO' as const, - priority: 'LOW' as const, - section: '인사관리', - tag: 'Culture', - taskType: '프로젝트', - progress: 0, - issueNote: null, - startDate: new Date('2026-05-01'), - dueDate: new Date('2026-06-30'), - }, - // ─── 학습성장 ─────────────────────────────────────── - { - title: '사내 핵심역량 교육체계 수립 (HRD)', - description: '직급별/직무별 필수 역량 가이드라인 도출\n사내 강사 제도 양성 및 콘텐츠 기획 단계', - status: 'IN_PROGRESS' as const, - priority: 'HIGH' as const, - section: '학습성장', - tag: 'Growth', - taskType: '프로젝트', - progress: 20, - issueNote: '[5.28] 사내 강사 풀 확보 및 보상안 검토', - startDate: new Date('2026-04-01'), - dueDate: new Date('2026-06-30'), - }, - // ─── 운영지원 ─────────────────────────────────────── - { - title: '기술센터 2F 세미나룸 브랜딩 기획', - description: '다목적 교육 공간 활용을 위한 운영 매뉴얼 수립\n공간 아이덴티티 반영 사인물(Signage) 기획', - status: 'REVIEW' as const, - priority: 'MEDIUM' as const, - section: '운영지원', - tag: 'Space', - taskType: '프로젝트', - progress: 80, - issueNote: null, - startDate: new Date('2026-04-01'), - dueDate: new Date('2026-06-30'), - }, - { - title: '중대재해법 대응 안전보건 매뉴얼 정비', - description: '현장 점검 기반 소방 및 MSDS 관리 체계 보완\n사내 안전사고 예방 가이드라인 표준화 기획', - status: 'IN_PROGRESS' as const, - priority: 'HIGH' as const, - section: '운영지원', - tag: 'Safety', - taskType: '상시업무', - progress: 30, - issueNote: null, - startDate: new Date('2026-04-01'), - dueDate: new Date('2026-06-30'), - }, - { - title: '공용 공간 인프라 개선', - description: '기술센터 로비 및 라운지 환경 정비 기획', - status: 'TODO' as const, - priority: 'LOW' as const, - section: '운영지원', - tag: 'Environment', - taskType: '프로젝트', - progress: 0, - issueNote: null, - startDate: new Date('2026-05-01'), - dueDate: new Date('2026-06-30'), - }, - // ─── 전산관리 ─────────────────────────────────────── - { - title: '全사 IT자산 수명주기 표준화 구축', - description: '자산 전수조사 데이터 기반 불용 기준 정립\n라이선스 최적화를 통한 비용 절감안 도출', - status: 'IN_PROGRESS' as const, - priority: 'HIGH' as const, - section: '전산관리', - tag: 'Asset', - taskType: '프로젝트', - progress: 60, - issueNote: null, - startDate: new Date('2026-04-01'), - dueDate: new Date('2026-06-30'), - }, - ]; - - for (const t of allTasks) { - await prisma.task.create({ + for (const t of mapped) { + const { milestones, detailContent, ...taskData } = t; + const task = await prisma.task.create({ data: { - ...t, - quarter: '2026-Q2', - category: t.section, + ...taskData, creatorId: admin.id, assigneeId: member.id, }, }); + + for (const [order, ms] of milestones.entries()) { + await prisma.milestone.create({ + data: { ...ms, taskId: task.id, order }, + }); + } + + if (detailContent) { + await prisma.taskDetail.create({ + data: { + taskId: task.id, + content: detailContent, + updatedBy: admin.id, + }, + }); + } } - console.log(`✅ Tasks created: ${allTasks.length}개`); + console.log(`✅ Tasks created: ${mapped.length}개 (HR_Dashboard 데이터)`); console.log('🎉 Seeding complete!'); } diff --git a/backend/scripts/import-hr-data.ts b/backend/scripts/import-hr-data.ts new file mode 100644 index 0000000..e7e1d46 --- /dev/null +++ b/backend/scripts/import-hr-data.ts @@ -0,0 +1,142 @@ +import 'dotenv/config'; +import { PrismaClient } from '@prisma/client'; +import { mapAllHrProjects } from '../prisma/mapHrProjects'; + +const prisma = new PrismaClient(); +const API_BASE = process.env.TARGET_API_URL?.replace(/\/$/, ''); + +async function importViaPrisma(adminId: string, memberId: string) { + const tasks = mapAllHrProjects(); + + await prisma.file.deleteMany({}); + await prisma.taskDetail.deleteMany({}); + await prisma.milestone.deleteMany({}); + await prisma.kpiMetric.deleteMany({}); + await prisma.task.deleteMany({}); + + for (const t of tasks) { + const { milestones, detailContent, ...taskData } = t; + const task = await prisma.task.create({ + data: { + ...taskData, + creatorId: adminId, + assigneeId: memberId, + }, + }); + + for (const [order, ms] of milestones.entries()) { + await prisma.milestone.create({ + data: { ...ms, taskId: task.id, order }, + }); + } + + if (detailContent) { + await prisma.taskDetail.create({ + data: { + taskId: task.id, + content: detailContent, + updatedBy: adminId, + }, + }); + } + } + + console.log(`✅ Prisma import complete: ${tasks.length} tasks`); +} + +async function importViaApi(adminId: string, memberId: string) { + const base = API_BASE!; + const tasks = mapAllHrProjects(); + + const existingRes = await fetch(`${base}/api/tasks`); + if (!existingRes.ok) throw new Error(`GET /api/tasks failed: ${existingRes.status}`); + const existing = (await existingRes.json()) as { id: string }[]; + + for (const task of existing) { + const del = await fetch(`${base}/api/tasks/${task.id}`, { method: 'DELETE' }); + if (!del.ok && del.status !== 204) throw new Error(`DELETE ${task.id} failed: ${del.status}`); + } + + for (const t of tasks) { + const { milestones, detailContent, ...taskData } = t; + const body = { + ...taskData, + startDate: taskData.startDate?.toISOString() ?? null, + dueDate: taskData.dueDate?.toISOString() ?? null, + creatorId: adminId, + assigneeId: memberId, + }; + + const createRes = await fetch(`${base}/api/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!createRes.ok) { + const err = await createRes.text(); + throw new Error(`POST task "${t.title}" failed: ${createRes.status} ${err}`); + } + const created = (await createRes.json()) as { id: string }; + + for (const [order, ms] of milestones.entries()) { + const msRes = await fetch(`${base}/api/milestones/${created.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...ms, order }), + }); + if (!msRes.ok) console.warn(` ⚠ milestone skip: ${t.title} / ${ms.title}`); + } + + if (detailContent) { + const detailRes = await fetch(`${base}/api/details/${created.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: detailContent, authorName: '관리자' }), + }); + if (!detailRes.ok) console.warn(` ⚠ detail skip: ${t.title}`); + } + } + + console.log(`✅ API import complete: ${tasks.length} tasks → ${base}`); +} + +async function main() { + const tasks = mapAllHrProjects(); + console.log(`📦 HR_Dashboard → ${tasks.length} tasks mapped`); + + let adminId: string; + let memberId: string; + + if (API_BASE) { + const res = await fetch(`${API_BASE}/api/tasks`); + const existing = res.ok ? ((await res.json()) as { creatorId: string; assigneeId: string | null }[]) : []; + if (existing.length > 0) { + adminId = existing[0].creatorId; + memberId = existing[0].assigneeId ?? existing[0].creatorId; + } else { + throw new Error('API에 기존 task가 없어 creatorId를 확인할 수 없습니다.'); + } + await importViaApi(adminId, memberId); + } else { + const admin = await prisma.user.upsert({ + where: { email: 'admin@eene.com' }, + update: {}, + create: { email: 'admin@eene.com', password: '!', name: '관리자', role: 'ADMIN', department: 'EENE' }, + }); + const member = await prisma.user.upsert({ + where: { email: 'member@eene.com' }, + update: { name: '정성호' }, + create: { email: 'member@eene.com', password: '!', name: '정성호', role: 'MEMBER', department: 'EENE' }, + }); + adminId = admin.id; + memberId = member.id; + await importViaPrisma(adminId, memberId); + } +} + +main() + .catch((err) => { + console.error('❌ Import failed:', err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/src/app.ts b/backend/src/app.ts index ffda4d6..1cafa99 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -12,14 +12,20 @@ app.use(helmet()); const allowedOrigins = [ 'http://localhost:3000', 'http://172.16.8.248:3000', + 'https://eene-dashboard.vercel.app', process.env.FRONTEND_URL, ].filter(Boolean) as string[]; +function isAllowedOrigin(origin: string): boolean { + if (allowedOrigins.includes(origin)) return true; + if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true; + return false; +} + app.use( cors({ origin: (origin, callback) => { - // 같은 서버에서 직접 호출하거나 허용된 origin이면 통과 - if (!origin || allowedOrigins.includes(origin)) { + if (!origin || isAllowedOrigin(origin)) { callback(null, true); } else { callback(new Error(`CORS 차단: ${origin}`)); diff --git a/backend/src/lib/resolveUser.ts b/backend/src/lib/resolveUser.ts index 842ae7c..c35f5df 100644 --- a/backend/src/lib/resolveUser.ts +++ b/backend/src/lib/resolveUser.ts @@ -1,6 +1,22 @@ import { prisma } from './prisma'; import { AppError } from '../middleware/errorHandler'; +/** 업무 생성 시 사용할 creatorId (클라이언트의 잘못된 값 무시) */ +export async function resolveCreatorId(requested?: string): Promise { + if (requested?.trim() && requested !== 'system') { + const user = await prisma.user.findUnique({ where: { id: requested.trim() } }); + if (user) return user.id; + } + + const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' }, select: { id: true } }); + if (admin) return admin.id; + + const anyUser = await prisma.user.findFirst({ select: { id: true } }); + if (anyUser) return anyUser.id; + + throw new AppError(500, '사용자를 찾을 수 없습니다. 관리자 계정을 먼저 생성해 주세요.'); +} + /** task 작성자 또는 관리자 등 유효한 user id 반환 (FK 오류 방지) */ export async function resolveTaskActorId(taskId: string): Promise { const task = await prisma.task.findUnique({ diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index c801994..0de1a89 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { prisma } from '../lib/prisma'; +import { resolveCreatorId } from '../lib/resolveUser'; import { AppError } from '../middleware/errorHandler'; const router = Router(); @@ -67,6 +68,8 @@ router.post('/', async (req, res, next) => { throw new AppError(400, '제목과 분기는 필수입니다.'); } + const creatorId = await resolveCreatorId((req.body as Record).creatorId); + const task = await prisma.task.create({ data: { title, @@ -89,7 +92,7 @@ router.post('/', async (req, res, next) => { showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true, keywords: keywords || null, assigneeId: assigneeId || null, - creatorId: (req.body as Record).creatorId ?? 'system', + creatorId, }, }); diff --git a/frontend/src/components/dashboard/DepartmentColumn.tsx b/frontend/src/components/dashboard/DepartmentColumn.tsx index 983481c..97c6793 100644 --- a/frontend/src/components/dashboard/DepartmentColumn.tsx +++ b/frontend/src/components/dashboard/DepartmentColumn.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { apiClient } from '../../lib/apiClient'; +import { apiClient, getApiErrorMessage } from '../../lib/apiClient'; import { isProjectTask, isRoutineTask } from '../../lib/taskType'; import { SortableTaskCard } from './TaskCard'; import { ContextMenu } from '../common/ContextMenu'; @@ -181,28 +181,31 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi const displayTitle = title.replace(/\s*부문$/, ''); - const handleAdd = (data: TaskFormData) => { - create.mutate({ - title: data.title, - section: data.section || null, - taskType: data.taskType || null, - status: data.status as Task['status'], - progress: data.progress, - description: data.description || null, - issueNote: data.issueNote || null, - startDate: data.startDate || null, - dueDate: data.dueDate || null, - showDate: data.showDate, - showDescription: data.showDescription, - showStatus: data.showStatus, - showIssue: data.showIssue, - showProgress: data.showProgress, - keywords: data.keywords || null, - quarter: data.quarter, - priority: 'MEDIUM', - creatorId: 'system', - } as any); - setShowAddModal(false); + const handleAdd = async (data: TaskFormData) => { + try { + await create.mutateAsync({ + title: data.title, + section: data.section || null, + taskType: data.taskType || null, + status: data.status as Task['status'], + progress: data.progress, + description: data.description || null, + issueNote: data.issueNote || null, + startDate: data.startDate || null, + dueDate: data.dueDate || null, + showDate: data.showDate, + showDescription: data.showDescription, + showStatus: data.showStatus, + showIssue: data.showIssue, + showProgress: data.showProgress, + keywords: data.keywords || null, + quarter: data.quarter, + priority: 'MEDIUM', + } as Partial); + setShowAddModal(false); + } catch (err: unknown) { + alert(getApiErrorMessage(err, '업무 추가에 실패했습니다.')); + } }; const handleEdit = (data: TaskFormData) => { diff --git a/frontend/src/components/dashboard/TaskManager.tsx b/frontend/src/components/dashboard/TaskManager.tsx index 3552f35..3f28828 100644 --- a/frontend/src/components/dashboard/TaskManager.tsx +++ b/frontend/src/components/dashboard/TaskManager.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { createPortal } from 'react-dom'; -import { apiClient } from '../../lib/apiClient'; +import { apiClient, getApiErrorMessage } from '../../lib/apiClient'; import { TaskModal } from '../common/TaskModal'; import type { TaskFormData } from '../common/TaskModal'; import type { Task } from '../../types'; @@ -61,23 +61,26 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan return true; }); - const handleAdd = (data: TaskFormData) => { - create.mutate({ - title: data.title, section: data.section || null, tag: data.tag || null, - taskType: data.taskType || null, status: data.status, progress: data.progress, - description: data.description || null, issueNote: data.issueNote || null, - startDate: data.startDate || null, dueDate: data.dueDate || null, - showDate: data.showDate, - showDescription: data.showDescription, - showStatus: data.showStatus, - showIssue: data.showIssue, - showProgress: data.showProgress, - keywords: data.keywords || null, - quarter: data.quarter, - priority: 'MEDIUM', - creatorId: 'system', - }); - setModalMode(null); + const handleAdd = async (data: TaskFormData) => { + try { + await create.mutateAsync({ + title: data.title, section: data.section || null, tag: data.tag || null, + taskType: data.taskType || null, status: data.status, progress: data.progress, + description: data.description || null, issueNote: data.issueNote || null, + startDate: data.startDate || null, dueDate: data.dueDate || null, + showDate: data.showDate, + showDescription: data.showDescription, + showStatus: data.showStatus, + showIssue: data.showIssue, + showProgress: data.showProgress, + keywords: data.keywords || null, + quarter: data.quarter, + priority: 'MEDIUM', + }); + setModalMode(null); + } catch (err: unknown) { + alert(getApiErrorMessage(err, '업무 추가에 실패했습니다.')); + } }; const handleEdit = (data: TaskFormData) => { diff --git a/frontend/src/contexts/SocketContext.tsx b/frontend/src/contexts/SocketContext.tsx index d71edb4..998171f 100644 --- a/frontend/src/contexts/SocketContext.tsx +++ b/frontend/src/contexts/SocketContext.tsx @@ -3,10 +3,13 @@ import { io, type Socket } from 'socket.io-client'; const SocketContext = createContext(null); -// 같은 네트워크 팀원도 접속 가능: 백엔드 주소를 현재 페이지 호스트에서 자동 감지 +const RENDER_API = 'https://eene-dashboard-backend.onrender.com'; + const SOCKET_URL = import.meta.env.VITE_SOCKET_URL || - `${window.location.protocol}//${window.location.hostname}:4000`; + (import.meta.env.PROD + ? RENDER_API + : `${window.location.protocol}//${window.location.hostname}:4000`); export function SocketProvider({ children }: { children: ReactNode }) { const socketRef = useRef(null); diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index 13b2a13..572c8f8 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -1,10 +1,13 @@ import axios from 'axios'; // 개발: Vite 프록시 → /api (localhost:4000) -// 배포: VITE_API_URL=https://xxx.onrender.com 설정 시 그 주소 사용 +// 배포: VITE_API_URL 미설정 시 Render 백엔드 기본값 사용 +const RENDER_API = 'https://eene-dashboard-backend.onrender.com'; const baseURL = import.meta.env.VITE_API_URL ? `${import.meta.env.VITE_API_URL}/api` - : '/api'; + : import.meta.env.PROD + ? `${RENDER_API}/api` + : '/api'; export const apiClient = axios.create({ baseURL, @@ -13,7 +16,19 @@ export const apiClient = axios.create({ }, }); +apiClient.interceptors.request.use((config) => { + if (config.data instanceof FormData) { + delete config.headers['Content-Type']; + } + return config; +}); + apiClient.interceptors.response.use( (res) => res, (error) => Promise.reject(error), ); + +export function getApiErrorMessage(err: unknown, fallback: string): string { + const ax = err as { response?: { data?: { message?: string }; status?: number }; message?: string }; + return ax.response?.data?.message || ax.message || fallback; +} diff --git a/frontend/src/pages/DetailPage.tsx b/frontend/src/pages/DetailPage.tsx index 943e4c2..63b450f 100644 --- a/frontend/src/pages/DetailPage.tsx +++ b/frontend/src/pages/DetailPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { apiClient } from '../lib/apiClient'; +import { apiClient, getApiErrorMessage } from '../lib/apiClient'; import { onDualMonitorEvent } from '../lib/dualMonitor'; import { ContextMenu } from '../components/common/ContextMenu'; import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal'; @@ -272,9 +272,7 @@ function DetailView({ task }: { task: TaskWithRelations }) { if (item.displayName.trim()) { form.append('displayName', item.displayName.trim()); } - await apiClient.post(`/files/upload/${task.id}`, form, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); + await apiClient.post(`/files/upload/${task.id}`, form); } }; @@ -313,9 +311,7 @@ function DetailView({ task }: { task: TaskWithRelations }) { 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' }, - }); + await apiClient.post(`/files/${rep.id}/replace`, form); } for (const edit of filePayload.existingEdits) { const original = files.find((f) => f.id === edit.id); @@ -334,17 +330,13 @@ function DetailView({ task }: { task: TaskWithRelations }) { 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}`); + alert(`단계는 저장됐지만 ${getApiErrorMessage(err, '파일 처리에 실패했습니다.')}`); } await qc.invalidateQueries({ queryKey: ['task', task.id] }); setStageModal(null); } catch (err: unknown) { - const ax = err as { response?: { data?: { message?: string } }; message?: string }; - const msg = ax.response?.data?.message || ax.message || '단계 저장에 실패했습니다.'; - alert(msg); + alert(getApiErrorMessage(err, '단계 저장에 실패했습니다.')); } finally { setStageSaving(false); } @@ -372,9 +364,7 @@ function DetailView({ task }: { task: TaskWithRelations }) { 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); + alert(getApiErrorMessage(err, '피드백 저장에 실패했습니다.')); } finally { setFeedbackSaving(false); }