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:
@@ -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",
|
||||
|
||||
211
backend/prisma/mapHrProjects.ts
Normal file
211
backend/prisma/mapHrProjects.ts
Normal 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));
|
||||
}
|
||||
@@ -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!');
|
||||
}
|
||||
|
||||
|
||||
142
backend/scripts/import-hr-data.ts
Normal file
142
backend/scripts/import-hr-data.ts
Normal 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());
|
||||
@@ -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}`));
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { prisma } from './prisma';
|
||||
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 오류 방지) */
|
||||
export async function resolveTaskActorId(taskId: string): Promise<string> {
|
||||
const task = await prisma.task.findUnique({
|
||||
|
||||
@@ -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<string, string>).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<string, string>).creatorId ?? 'system',
|
||||
creatorId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Task>);
|
||||
setShowAddModal(false);
|
||||
} catch (err: unknown) {
|
||||
alert(getApiErrorMessage(err, '업무 추가에 실패했습니다.'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (data: TaskFormData) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -3,10 +3,13 @@ import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
const SocketContext = createContext<Socket | null>(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<Socket | null>(null);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user