fix: production save errors and import HR dashboard data

Resolve invalid task creator IDs, fix API routing and file uploads on Vercel, and replace dummy seed data with HR_Dashboard import.
This commit is contained in:
EENE Dashboard
2026-06-05 22:08:56 +09:00
parent 9abb58e5c8
commit 6066b5682d
12 changed files with 488 additions and 188 deletions

View File

@@ -10,7 +10,8 @@
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:studio": "prisma studio", "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": { "dependencies": {
"@prisma/client": "^6.0.0", "@prisma/client": "^6.0.0",

View File

@@ -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<string, string> = {
: '인사관리',
: '학습성장',
: '운영지원',
: '전산관리',
};
const STATUS_MAP: Record<string, TaskStatus> = {
: 'IN_PROGRESS',
: 'IN_PROGRESS',
: 'TODO',
: 'DONE',
};
const PHASE_PROGRESS: Record<string, number> = {
완료: 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));
}

View File

@@ -1,13 +1,13 @@
import 'dotenv/config'; import 'dotenv/config';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { mapAllHrProjects } from './mapHrProjects';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
console.log('🌱 Seeding database...'); console.log('🌱 Seeding database...');
// ─── 사용자 ─────────────────────────────────────────────
const adminPw = await bcrypt.hash('admin1234!', 12); const adminPw = await bcrypt.hash('admin1234!', 12);
const memberPw = await bcrypt.hash('member1234!', 12); const memberPw = await bcrypt.hash('member1234!', 12);
@@ -19,141 +19,48 @@ async function main() {
const member = await prisma.user.upsert({ const member = await prisma.user.upsert({
where: { email: 'member@eene.com' }, where: { email: 'member@eene.com' },
update: {}, update: { name: '정성호' },
create: { email: 'member@eene.com', password: memberPw, name: '홍길동', role: 'MEMBER', department: 'EENE' }, create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' },
}); });
console.log(`✅ Users ready`); console.log('✅ Users ready');
// ─── 기존 업무 삭제 후 재생성 ───────────────────────── const mapped = mapAllHrProjects();
await prisma.kpiMetric.deleteMany({});
await prisma.file.deleteMany({});
await prisma.taskDetail.deleteMany({}); await prisma.taskDetail.deleteMany({});
await prisma.milestone.deleteMany({});
await prisma.kpiMetric.deleteMany({});
await prisma.task.deleteMany({}); await prisma.task.deleteMany({});
const allTasks = [ for (const t of mapped) {
// ─── 인사관리 ─────────────────────────────────────── const { milestones, detailContent, ...taskData } = t;
{ const task = await prisma.task.create({
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({
data: { data: {
...t, ...taskData,
quarter: '2026-Q2',
category: t.section,
creatorId: admin.id, creatorId: admin.id,
assigneeId: member.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!'); console.log('🎉 Seeding complete!');
} }

View File

@@ -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());

View File

@@ -12,14 +12,20 @@ app.use(helmet());
const allowedOrigins = [ const allowedOrigins = [
'http://localhost:3000', 'http://localhost:3000',
'http://172.16.8.248:3000', 'http://172.16.8.248:3000',
'https://eene-dashboard.vercel.app',
process.env.FRONTEND_URL, process.env.FRONTEND_URL,
].filter(Boolean) as string[]; ].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( app.use(
cors({ cors({
origin: (origin, callback) => { origin: (origin, callback) => {
// 같은 서버에서 직접 호출하거나 허용된 origin이면 통과 if (!origin || isAllowedOrigin(origin)) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true); callback(null, true);
} else { } else {
callback(new Error(`CORS 차단: ${origin}`)); callback(new Error(`CORS 차단: ${origin}`));

View File

@@ -1,6 +1,22 @@
import { prisma } from './prisma'; import { prisma } from './prisma';
import { AppError } from '../middleware/errorHandler'; import { AppError } from '../middleware/errorHandler';
/** 업무 생성 시 사용할 creatorId (클라이언트의 잘못된 값 무시) */
export async function resolveCreatorId(requested?: string): Promise<string> {
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 오류 방지) */ /** task 작성자 또는 관리자 등 유효한 user id 반환 (FK 오류 방지) */
export async function resolveTaskActorId(taskId: string): Promise<string> { export async function resolveTaskActorId(taskId: string): Promise<string> {
const task = await prisma.task.findUnique({ const task = await prisma.task.findUnique({

View File

@@ -1,5 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { prisma } from '../lib/prisma'; import { prisma } from '../lib/prisma';
import { resolveCreatorId } from '../lib/resolveUser';
import { AppError } from '../middleware/errorHandler'; import { AppError } from '../middleware/errorHandler';
const router = Router(); const router = Router();
@@ -67,6 +68,8 @@ router.post('/', async (req, res, next) => {
throw new AppError(400, '제목과 분기는 필수입니다.'); throw new AppError(400, '제목과 분기는 필수입니다.');
} }
const creatorId = await resolveCreatorId((req.body as Record<string, string>).creatorId);
const task = await prisma.task.create({ const task = await prisma.task.create({
data: { data: {
title, title,
@@ -89,7 +92,7 @@ router.post('/', async (req, res, next) => {
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true, showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
keywords: keywords || null, keywords: keywords || null,
assigneeId: assigneeId || null, assigneeId: assigneeId || null,
creatorId: (req.body as Record<string, string>).creatorId ?? 'system', creatorId,
}, },
}); });

View File

@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useDroppable } from '@dnd-kit/core'; import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; 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 { isProjectTask, isRoutineTask } from '../../lib/taskType';
import { SortableTaskCard } from './TaskCard'; import { SortableTaskCard } from './TaskCard';
import { ContextMenu } from '../common/ContextMenu'; import { ContextMenu } from '../common/ContextMenu';
@@ -181,28 +181,31 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const displayTitle = title.replace(/\s*부문$/, ''); const displayTitle = title.replace(/\s*부문$/, '');
const handleAdd = (data: TaskFormData) => { const handleAdd = async (data: TaskFormData) => {
create.mutate({ try {
title: data.title, await create.mutateAsync({
section: data.section || null, title: data.title,
taskType: data.taskType || null, section: data.section || null,
status: data.status as Task['status'], taskType: data.taskType || null,
progress: data.progress, status: data.status as Task['status'],
description: data.description || null, progress: data.progress,
issueNote: data.issueNote || null, description: data.description || null,
startDate: data.startDate || null, issueNote: data.issueNote || null,
dueDate: data.dueDate || null, startDate: data.startDate || null,
showDate: data.showDate, dueDate: data.dueDate || null,
showDescription: data.showDescription, showDate: data.showDate,
showStatus: data.showStatus, showDescription: data.showDescription,
showIssue: data.showIssue, showStatus: data.showStatus,
showProgress: data.showProgress, showIssue: data.showIssue,
keywords: data.keywords || null, showProgress: data.showProgress,
quarter: data.quarter, keywords: data.keywords || null,
priority: 'MEDIUM', quarter: data.quarter,
creatorId: 'system', priority: 'MEDIUM',
} as any); } as Partial<Task>);
setShowAddModal(false); setShowAddModal(false);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '업무 추가에 실패했습니다.'));
}
}; };
const handleEdit = (data: TaskFormData) => { const handleEdit = (data: TaskFormData) => {

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { apiClient } from '../../lib/apiClient'; import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { TaskModal } from '../common/TaskModal'; import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal'; import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types'; import type { Task } from '../../types';
@@ -61,23 +61,26 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
return true; return true;
}); });
const handleAdd = (data: TaskFormData) => { const handleAdd = async (data: TaskFormData) => {
create.mutate({ try {
title: data.title, section: data.section || null, tag: data.tag || null, await create.mutateAsync({
taskType: data.taskType || null, status: data.status, progress: data.progress, title: data.title, section: data.section || null, tag: data.tag || null,
description: data.description || null, issueNote: data.issueNote || null, taskType: data.taskType || null, status: data.status, progress: data.progress,
startDate: data.startDate || null, dueDate: data.dueDate || null, description: data.description || null, issueNote: data.issueNote || null,
showDate: data.showDate, startDate: data.startDate || null, dueDate: data.dueDate || null,
showDescription: data.showDescription, showDate: data.showDate,
showStatus: data.showStatus, showDescription: data.showDescription,
showIssue: data.showIssue, showStatus: data.showStatus,
showProgress: data.showProgress, showIssue: data.showIssue,
keywords: data.keywords || null, showProgress: data.showProgress,
quarter: data.quarter, keywords: data.keywords || null,
priority: 'MEDIUM', quarter: data.quarter,
creatorId: 'system', priority: 'MEDIUM',
}); });
setModalMode(null); setModalMode(null);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '업무 추가에 실패했습니다.'));
}
}; };
const handleEdit = (data: TaskFormData) => { const handleEdit = (data: TaskFormData) => {

View File

@@ -3,10 +3,13 @@ import { io, type Socket } from 'socket.io-client';
const SocketContext = createContext<Socket | null>(null); const SocketContext = createContext<Socket | null>(null);
// 같은 네트워크 팀원도 접속 가능: 백엔드 주소를 현재 페이지 호스트에서 자동 감지 const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
const SOCKET_URL = const SOCKET_URL =
import.meta.env.VITE_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 }) { export function SocketProvider({ children }: { children: ReactNode }) {
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);

View File

@@ -1,10 +1,13 @@
import axios from 'axios'; import axios from 'axios';
// 개발: Vite 프록시 → /api (localhost:4000) // 개발: 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 const baseURL = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/api` ? `${import.meta.env.VITE_API_URL}/api`
: '/api'; : import.meta.env.PROD
? `${RENDER_API}/api`
: '/api';
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL, 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( apiClient.interceptors.response.use(
(res) => res, (res) => res,
(error) => Promise.reject(error), (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;
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } 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, getApiErrorMessage } 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 { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal'; import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal';
@@ -272,9 +272,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
if (item.displayName.trim()) { if (item.displayName.trim()) {
form.append('displayName', 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' },
});
} }
}; };
@@ -313,9 +311,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
for (const rep of filePayload.replacements) { for (const rep of filePayload.replacements) {
const form = new FormData(); const form = new FormData();
form.append('file', rep.file); form.append('file', rep.file);
await apiClient.post(`/files/${rep.id}/replace`, form, { await apiClient.post(`/files/${rep.id}/replace`, form);
headers: { 'Content-Type': 'multipart/form-data' },
});
} }
for (const edit of filePayload.existingEdits) { for (const edit of filePayload.existingEdits) {
const original = files.find((f) => f.id === edit.id); const original = files.find((f) => f.id === edit.id);
@@ -334,17 +330,13 @@ function DetailView({ task }: { task: TaskWithRelations }) {
await uploadFiles(milestoneId, filePayload.uploads); await uploadFiles(milestoneId, filePayload.uploads);
} }
} catch (err: unknown) { } catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } }; message?: string }; alert(`단계는 저장됐지만 ${getApiErrorMessage(err, '파일 처리에 실패했습니다.')}`);
const msg = ax.response?.data?.message || ax.message || '파일 처리에 실패했습니다.';
alert(`단계는 저장됐지만 ${msg}`);
} }
await qc.invalidateQueries({ queryKey: ['task', task.id] }); await qc.invalidateQueries({ queryKey: ['task', task.id] });
setStageModal(null); setStageModal(null);
} catch (err: unknown) { } catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } }; message?: string }; alert(getApiErrorMessage(err, '단계 저장에 실패했습니다.'));
const msg = ax.response?.data?.message || ax.message || '단계 저장에 실패했습니다.';
alert(msg);
} finally { } finally {
setStageSaving(false); setStageSaving(false);
} }
@@ -372,9 +364,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
await qc.invalidateQueries({ queryKey: ['task', task.id] }); await qc.invalidateQueries({ queryKey: ['task', task.id] });
setFeedbackModal(null); setFeedbackModal(null);
} catch (err: unknown) { } catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string } }; message?: string }; alert(getApiErrorMessage(err, '피드백 저장에 실패했습니다.'));
const msg = ax.response?.data?.message || ax.message || '피드백 저장에 실패했습니다.';
alert(msg);
} finally { } finally {
setFeedbackSaving(false); setFeedbackSaving(false);
} }