Initial commit - EENE Dashboard

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-05-29 18:07:10 +09:00
commit 22366dde72
64 changed files with 10483 additions and 0 deletions

3153
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
backend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "eene-dashboard-backend",
"version": "1.0.0",
"description": "EENE 인재성장팀 대시보드 - Backend API",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:generate": "prisma generate",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.0.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"socket.io": "^4.8.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,147 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'MANAGER', 'MEMBER');
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'REVIEW', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "Priority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'MEMBER',
"department" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tasks" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "TaskStatus" NOT NULL DEFAULT 'TODO',
"priority" "Priority" NOT NULL DEFAULT 'MEDIUM',
"quarter" TEXT NOT NULL,
"category" TEXT,
"dueDate" TIMESTAMP(3),
"creatorId" TEXT NOT NULL,
"assigneeId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "task_details" (
"id" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"updatedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "task_details_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "kpi_metrics" (
"id" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"quarter" TEXT NOT NULL,
"target" DOUBLE PRECISION NOT NULL,
"actual" DOUBLE PRECISION NOT NULL DEFAULT 0,
"unit" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "kpi_metrics_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "files" (
"id" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"mimetype" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"path" TEXT NOT NULL,
"uploadedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "files_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT,
"details" JSONB,
"ipAddress" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "tasks_quarter_idx" ON "tasks"("quarter");
-- CreateIndex
CREATE INDEX "tasks_status_idx" ON "tasks"("status");
-- CreateIndex
CREATE INDEX "tasks_assigneeId_idx" ON "tasks"("assigneeId");
-- CreateIndex
CREATE INDEX "task_details_taskId_idx" ON "task_details"("taskId");
-- CreateIndex
CREATE INDEX "kpi_metrics_quarter_idx" ON "kpi_metrics"("quarter");
-- CreateIndex
CREATE INDEX "files_taskId_idx" ON "files"("taskId");
-- CreateIndex
CREATE INDEX "audit_logs_userId_idx" ON "audit_logs"("userId");
-- CreateIndex
CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt");
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_details" ADD CONSTRAINT "task_details_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "task_details" ADD CONSTRAINT "task_details_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kpi_metrics" ADD CONSTRAINT "kpi_metrics_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "files" ADD CONSTRAINT "files_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "files" ADD CONSTRAINT "files_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "issueNote" TEXT,
ADD COLUMN "progress" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "section" TEXT,
ADD COLUMN "tag" TEXT,
ADD COLUMN "taskType" TEXT;
-- CreateIndex
CREATE INDEX "tasks_section_idx" ON "tasks"("section");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,193 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── 사용자 ──────────────────────────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
password String
name String
role Role @default(MEMBER)
department String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdTasks Task[] @relation("CreatedTasks")
assignedTasks Task[] @relation("AssignedTasks")
taskDetails TaskDetail[]
uploadedFiles File[]
auditLogs AuditLog[]
@@map("users")
}
enum Role {
ADMIN
MANAGER
MEMBER
}
// ─── 업무 ────────────────────────────────────────────────────
model Task {
id String @id @default(cuid())
title String
description String?
status TaskStatus @default(TODO)
priority Priority @default(MEDIUM)
quarter String // 예: "2026-Q2"
category String?
section String? // HR | 운영관리
tag String? // Growth | Policy | Performance | Culture | Asset | Space | Safety | Environment
taskType String? // 상시업무 | 프로젝트
progress Int @default(0)
issueNote String?
startDate DateTime?
dueDate DateTime?
creatorId String
assigneeId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation("CreatedTasks", fields: [creatorId], references: [id])
assignee User? @relation("AssignedTasks", fields: [assigneeId], references: [id])
details TaskDetail[]
kpiMetrics KpiMetric[]
files File[]
milestones Milestone[]
@@index([quarter])
@@index([status])
@@index([assigneeId])
@@index([section])
@@map("tasks")
}
enum TaskStatus {
TODO
IN_PROGRESS
REVIEW
DONE
CANCELLED
}
enum Priority {
LOW
MEDIUM
HIGH
URGENT
}
// ─── 업무 상세 / 진행 기록 ────────────────────────────────────
model TaskDetail {
id String @id @default(cuid())
taskId String
content String
updatedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
author User @relation(fields: [updatedBy], references: [id])
@@index([taskId])
@@map("task_details")
}
// ─── KPI 지표 ────────────────────────────────────────────────
model KpiMetric {
id String @id @default(cuid())
taskId String
quarter String
target Float
actual Float @default(0)
unit String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@index([quarter])
@@map("kpi_metrics")
}
// ─── 파일 ────────────────────────────────────────────────────
model File {
id String @id @default(cuid())
taskId String
filename String // 저장된 파일명 (UUID)
originalName String // 원본 파일명
mimetype String
size Int
path String
uploadedBy String
createdAt DateTime @default(now())
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
uploader User @relation(fields: [uploadedBy], references: [id])
@@index([taskId])
@@map("files")
}
// ─── 마일스톤 (프로세스 단계) ─────────────────────────────────
model Milestone {
id String @id @default(cuid())
taskId String
title String
description String?
dueDate DateTime?
completedAt DateTime?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@index([taskId])
@@map("milestones")
}
// ─── 컬럼 설정 ───────────────────────────────────────────────
model ColumnConfig {
key String @id // "HR" | "운영관리"
title String
titleEn String?
subtitle String?
cardOrder String? // JSON 배열: task id 순서
updatedAt DateTime @updatedAt
@@map("column_configs")
}
// ─── 감사 로그 ───────────────────────────────────────────────
model AuditLog {
id String @id @default(cuid())
userId String
action String // CREATE, UPDATE, DELETE, LOGIN 등
entity String // Task, User, File 등
entityId String?
details Json?
ipAddress String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([createdAt])
@@map("audit_logs")
}

145
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,145 @@
import 'dotenv/config';
import bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';
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);
const admin = await prisma.user.upsert({
where: { email: 'admin@eene.com' },
update: {},
create: { email: 'admin@eene.com', password: adminPw, name: '관리자', role: 'ADMIN', department: 'EENE' },
});
const member = await prisma.user.upsert({
where: { email: 'member@eene.com' },
update: {},
create: { email: 'member@eene.com', password: memberPw, name: '홍길동', role: 'MEMBER', department: 'EENE' },
});
console.log(`✅ Users ready`);
// ─── 기존 업무 삭제 후 재생성 ─────────────────────────
await prisma.kpiMetric.deleteMany({});
await prisma.taskDetail.deleteMany({});
await prisma.task.deleteMany({});
// ─── HR 부문 업무 ──────────────────────────────────────
const hrTasks = [
{
title: '사내 핵심역량 교육체계 수립 (HRD)',
description: '직급별/직무별 필수 역량 가이드라인 도출\n사내 강사 제도 양성 및 콘텐츠 기획 단계',
status: 'IN_PROGRESS' as const,
priority: 'HIGH' as const,
section: 'HR',
tag: 'Growth',
taskType: '프로젝트',
progress: 20,
issueNote: '[5.28] 사내 강사 풀 확보 및 보상안 검토',
},
{
title: '그룹 표준 취업규칙 개정안 (HRM)',
description: '유연근무제 및 근태 관리 프로세스 고도화\n노사협의회 안건 조율 및 근로조건 개선안 반영',
status: 'IN_PROGRESS' as const,
priority: 'HIGH' as const,
section: 'HR',
tag: 'Policy',
taskType: '프로젝트',
progress: 40,
issueNote: '[5.28] 가족사 간 피드백 이견 조율 진행',
},
{
title: '상반기 평가 지표(KPI) 보완',
description: '1분기 피드백 기반 부서별 KPI 정렬\n상반기 업적 평가 시뮬레이션 기획',
status: 'IN_PROGRESS' as const,
priority: 'MEDIUM' as const,
section: 'HR',
tag: 'Performance',
taskType: '상시업무',
progress: 70,
issueNote: null,
},
{
title: '가족사 시너지 조직문화 캠페인',
description: '임직원 만족도 조사(Engagement Survey) 설계',
status: 'TODO' as const,
priority: 'LOW' as const,
section: 'HR',
tag: 'Culture',
taskType: '프로젝트',
progress: 0,
issueNote: null,
},
];
// ─── 운영관리 부문 업무 ────────────────────────────────
const opsTasks = [
{
title: '全사 IT자산 수명주기 표준화 구축',
description: '자산 전수조사 데이터 기반 불용 기준 정립\n라이선스 최적화를 통한 비용 절감안 도출',
status: 'IN_PROGRESS' as const,
priority: 'HIGH' as const,
section: '운영관리',
tag: 'Asset',
taskType: '프로젝트',
progress: 60,
issueNote: null,
},
{
title: '기술센터 2F 세미나룸 브랜딩 기획',
description: '다목적 교육 공간 활용을 위한 운영 매뉴얼 수립\n공간 아이덴티티 반영 사인물(Signage) 기획',
status: 'REVIEW' as const,
priority: 'MEDIUM' as const,
section: '운영관리',
tag: 'Space',
taskType: '프로젝트',
progress: 80,
issueNote: null,
},
{
title: '중대재해법 대응 안전보건 매뉴얼 정비',
description: '현장 점검 기반 소방 및 MSDS 관리 체계 보완\n사내 안전사고 예방 가이드라인 표준화 기획',
status: 'IN_PROGRESS' as const,
priority: 'HIGH' as const,
section: '운영관리',
tag: 'Safety',
taskType: '상시업무',
progress: 30,
issueNote: null,
},
{
title: '공용 공간 인프라 개선',
description: '기술센터 로비 및 라운지 환경 정비 기획',
status: 'TODO' as const,
priority: 'LOW' as const,
section: '운영관리',
tag: 'Environment',
taskType: '프로젝트',
progress: 0,
issueNote: null,
},
];
for (const t of [...hrTasks, ...opsTasks]) {
await prisma.task.create({
data: {
...t,
quarter: '2026-Q2',
category: t.section,
creatorId: admin.id,
assigneeId: member.id,
},
});
}
console.log(`✅ Tasks created: ${hrTasks.length + opsTasks.length}`);
console.log('🎉 Seeding complete!');
}
main().catch(console.error).finally(() => prisma.$disconnect());

48
backend/src/app.ts Normal file
View File

@@ -0,0 +1,48 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import path from 'path';
import { errorHandler } from './middleware/errorHandler';
import routes from './routes';
const app = express();
app.use(helmet());
const allowedOrigins = [
'http://localhost:3000',
'http://172.16.8.248:3000',
process.env.FRONTEND_URL,
].filter(Boolean) as string[];
app.use(
cors({
origin: (origin, callback) => {
// 같은 서버에서 직접 호출하거나 허용된 origin이면 통과
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS 차단: ${origin}`));
}
},
credentials: true,
}),
);
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 업로드 파일 정적 서빙
const uploadDir = path.resolve(process.env.UPLOAD_DIR || '../uploads');
app.use('/uploads', express.static(uploadDir));
// API Routes
app.use('/api', routes);
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.use(errorHandler);
export default app;

35
backend/src/index.ts Normal file
View File

@@ -0,0 +1,35 @@
import 'dotenv/config';
import { createServer } from 'http';
import { Server } from 'socket.io';
import app from './app';
import { setupSocketHandlers } from './socket';
import { prisma } from './lib/prisma';
const PORT = Number(process.env.PORT) || 4000;
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: FRONTEND_URL,
credentials: true,
},
});
setupSocketHandlers(io);
async function main() {
await prisma.$connect();
console.log('✅ Database connected');
httpServer.listen(PORT, '0.0.0.0', () => {
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);
console.log(`✅ Socket.io ready`);
});
}
main().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});

14
backend/src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,14 @@
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined;
}
export const prisma = global.__prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma;
}

View File

@@ -0,0 +1,43 @@
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface JwtPayload {
userId: string;
email: string;
role: string;
}
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ message: '인증 토큰이 없습니다.' });
return;
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = payload;
next();
} catch {
res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (req.user?.role !== 'ADMIN') {
res.status(403).json({ message: '관리자 권한이 필요합니다.' });
return;
}
next();
}

View File

@@ -0,0 +1,26 @@
import type { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction,
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({ message: err.message });
return;
}
console.error('[Error]', err);
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
}

View File

@@ -0,0 +1,26 @@
import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
const MAX_SIZE_MB = Number(process.env.MAX_FILE_SIZE_MB) || 20;
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
const storage = multer.diskStorage({
destination(_req, _file, cb) {
cb(null, UPLOAD_DIR);
},
filename(_req, file, cb) {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
export const upload = multer({
storage,
limits: { fileSize: MAX_SIZE_MB * 1024 * 1024 },
});

View File

@@ -0,0 +1,70 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { prisma } from '../lib/prisma';
import { authenticate } from '../middleware/auth';
import { AppError } from '../middleware/errorHandler';
const router = Router();
// POST /api/auth/login
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body as { email: string; password: string };
if (!email || !password) {
throw new AppError(400, '이메일과 비밀번호를 입력해주세요.');
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) {
throw new AppError(401, '이메일 또는 비밀번호가 올바르지 않습니다.');
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
throw new AppError(401, '이메일 또는 비밀번호가 올바르지 않습니다.');
}
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' } as jwt.SignOptions,
);
res.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
department: user.department,
},
});
} catch (err) {
next(err);
}
});
// GET /api/auth/me
router.get('/me', authenticate, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.userId },
select: { id: true, email: true, name: true, role: true, department: true },
});
if (!user) throw new AppError(404, '사용자를 찾을 수 없습니다.');
res.json(user);
} catch (err) {
next(err);
}
});
// POST /api/auth/logout (클라이언트 토큰 삭제용 — 서버는 stateless)
router.post('/logout', authenticate, (_req, res) => {
res.json({ message: '로그아웃 되었습니다.' });
});
export default router;

View File

@@ -0,0 +1,67 @@
import { Router } from 'express';
import { prisma } from '../lib/prisma';
const router = Router();
const DEFAULTS: Record<string, { title: string; titleEn: string; subtitle: string }> = {
HR: {
title: 'HR 부문',
titleEn: 'Human Resources',
subtitle: '임직원의 몰입(Engagement)과 성장(Education)',
},
'운영관리': {
title: '운영관리 부문',
titleEn: 'Operations',
subtitle: '인프라 고도화와 자산 라이프사이클 표준화',
},
};
// GET /api/columns/:key
router.get('/:key', async (req, res, next) => {
try {
const { key } = req.params;
let config = await prisma.columnConfig.findUnique({ where: { key } });
if (!config) {
const def = DEFAULTS[key];
config = await prisma.columnConfig.create({
data: { key, title: def?.title ?? key, titleEn: def?.titleEn ?? '', subtitle: def?.subtitle ?? '' },
});
}
res.json(config);
} catch (err) {
next(err);
}
});
// PATCH /api/columns/:key
router.patch('/:key', async (req, res, next) => {
try {
const { key } = req.params;
const { title, titleEn, subtitle, cardOrder } = req.body as Record<string, string>;
const config = await prisma.columnConfig.upsert({
where: { key },
create: {
key,
title: title ?? DEFAULTS[key]?.title ?? key,
titleEn: titleEn ?? DEFAULTS[key]?.titleEn ?? '',
subtitle: subtitle ?? DEFAULTS[key]?.subtitle ?? '',
cardOrder: cardOrder ?? null,
},
update: {
...(title !== undefined && { title }),
...(titleEn !== undefined && { titleEn }),
...(subtitle !== undefined && { subtitle }),
...(cardOrder !== undefined && { cardOrder }),
},
});
res.json(config);
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,84 @@
import { Router } from 'express';
import path from 'path';
import fs from 'fs';
import { prisma } from '../lib/prisma';
import { upload } from '../middleware/upload';
import { AppError } from '../middleware/errorHandler';
const router = Router();
// POST /api/files/upload/:taskId — 파일 업로드
router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => {
try {
if (!req.file) throw new AppError(400, '파일이 없습니다.');
const taskId = String(req.params.taskId);
const task = await prisma.task.findUnique({ where: { id: taskId } });
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
const fileRecord = await prisma.file.create({
data: {
taskId,
filename: req.file.filename,
originalName: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
path: req.file.path,
uploadedBy: (req.body as Record<string, string>).uploadedBy ?? 'system',
},
});
res.status(201).json(fileRecord);
} catch (err) {
next(err);
}
});
// GET /api/files/:id/view — 파일 미리보기 (브라우저에서 바로 열기)
router.get('/:id/view', async (req, res, next) => {
try {
const file = await prisma.file.findUnique({ where: { id: req.params.id } });
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
res.setHeader('Content-Type', file.mimetype);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
fs.createReadStream(file.path).pipe(res);
} catch (err) {
next(err);
}
});
// GET /api/files/:id/download — 파일 다운로드
router.get('/:id/download', async (req, res, next) => {
try {
const file = await prisma.file.findUnique({ where: { id: req.params.id } });
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.originalName)}"`);
res.download(file.path, file.originalName);
} catch (err) {
next(err);
}
});
// DELETE /api/files/:id — 파일 삭제
router.delete('/:id', async (req, res, next) => {
try {
const file = await prisma.file.findUnique({ where: { id: req.params.id } });
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
// 실제 파일 삭제
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
await prisma.file.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,20 @@
import { Router } from 'express';
import authRoutes from './auth';
import taskRoutes from './tasks';
import userRoutes from './users';
import fileRoutes from './files';
import kpiRoutes from './kpi';
import columnRoutes from './columns';
import milestoneRoutes from './milestones';
const router = Router();
router.use('/auth', authRoutes);
router.use('/tasks', taskRoutes);
router.use('/users', userRoutes);
router.use('/files', fileRoutes);
router.use('/kpi', kpiRoutes);
router.use('/columns', columnRoutes);
router.use('/milestones', milestoneRoutes);
export default router;

75
backend/src/routes/kpi.ts Normal file
View File

@@ -0,0 +1,75 @@
import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { AppError } from '../middleware/errorHandler';
const router = Router();
// GET /api/kpi?quarter=2026-Q2 — 분기별 KPI 전체 조회
router.get('/', async (req, res, next) => {
try {
const { quarter } = req.query as { quarter?: string };
const metrics = await prisma.kpiMetric.findMany({
where: quarter ? { quarter } : undefined,
include: {
task: { select: { id: true, title: true, status: true, assigneeId: true } },
},
orderBy: { createdAt: 'desc' },
});
res.json(metrics);
} catch (err) {
next(err);
}
});
// POST /api/kpi — KPI 등록
router.post('/', async (req, res, next) => {
try {
const { taskId, quarter, target, actual, unit } = req.body as {
taskId: string;
quarter: string;
target: number;
actual?: number;
unit?: string;
};
if (!taskId || !quarter || target === undefined) {
throw new AppError(400, 'taskId, quarter, target 은 필수입니다.');
}
const metric = await prisma.kpiMetric.create({
data: { taskId, quarter, target: Number(target), actual: Number(actual ?? 0), unit },
});
res.status(201).json(metric);
} catch (err) {
next(err);
}
});
// PATCH /api/kpi/:id — KPI 수정 (실적 업데이트 등)
router.patch('/:id', async (req, res, next) => {
try {
const { target, actual, unit } = req.body as {
target?: number;
actual?: number;
unit?: string;
};
const metric = await prisma.kpiMetric.update({
where: { id: req.params.id },
data: {
...(target !== undefined && { target: Number(target) }),
...(actual !== undefined && { actual: Number(actual) }),
...(unit !== undefined && { unit }),
},
});
res.json(metric);
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,75 @@
import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { AppError } from '../middleware/errorHandler';
const router = Router();
// GET /api/milestones/:taskId — 업무의 마일스톤 목록
router.get('/:taskId', async (req, res, next) => {
try {
const milestones = await prisma.milestone.findMany({
where: { taskId: req.params.taskId },
orderBy: { order: 'asc' },
});
res.json(milestones);
} catch (err) {
next(err);
}
});
// POST /api/milestones/:taskId — 마일스톤 추가
router.post('/:taskId', async (req, res, next) => {
try {
const { title, description, dueDate } = req.body as Record<string, string>;
const count = await prisma.milestone.count({ where: { taskId: req.params.taskId } });
const milestone = await prisma.milestone.create({
data: {
taskId: req.params.taskId,
title,
description: description || null,
dueDate: dueDate ? new Date(dueDate) : null,
order: count,
},
});
res.status(201).json(milestone);
} catch (err) {
next(err);
}
});
// PATCH /api/milestones/item/:id — 마일스톤 수정 (완료 처리 포함)
router.patch('/item/:id', async (req, res, next) => {
try {
const { title, description, dueDate, completed, order } = req.body as Record<string, string | boolean | number>;
const milestone = await prisma.milestone.update({
where: { id: req.params.id },
data: {
...(title !== undefined && { title: title as string }),
...(description !== undefined && { description: description as string || null }),
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate as string) : null }),
...(order !== undefined && { order: Number(order) }),
...(completed !== undefined && {
completedAt: completed ? new Date() : null,
}),
},
});
res.json(milestone);
} catch (err) {
next(err);
}
});
// DELETE /api/milestones/item/:id — 마일스톤 삭제
router.delete('/item/:id', async (req, res, next) => {
try {
await prisma.milestone.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

141
backend/src/routes/tasks.ts Normal file
View File

@@ -0,0 +1,141 @@
import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { AppError } from '../middleware/errorHandler';
const router = Router();
// GET /api/tasks — 목록 조회 (필터: status, quarter, assigneeId)
router.get('/', async (req, res, next) => {
try {
const { status, quarter, assigneeId, category } = req.query as Record<string, string>;
const tasks = await prisma.task.findMany({
where: {
...(status && { status: status as any }),
...(quarter && { quarter }),
...(assigneeId && { assigneeId }),
...(category && { category }),
},
include: {
assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } },
_count: { select: { files: true, details: true } },
},
orderBy: { updatedAt: 'desc' },
});
res.json(tasks);
} catch (err) {
next(err);
}
});
// GET /api/tasks/:id — 단건 상세 조회
router.get('/:id', async (req, res, next) => {
try {
const task = await prisma.task.findUnique({
where: { id: req.params.id },
include: {
assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } },
details: { orderBy: { createdAt: 'desc' } },
kpiMetrics: true,
files: true,
milestones: { orderBy: { order: 'asc' } },
},
});
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
res.json(task);
} catch (err) {
next(err);
}
});
// POST /api/tasks — 업무 등록
router.post('/', async (req, res, next) => {
try {
const { title, description, status, priority, quarter, category,
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId } =
req.body as Record<string, string>;
if (!title || !quarter) {
throw new AppError(400, '제목과 분기는 필수입니다.');
}
const task = await prisma.task.create({
data: {
title,
description,
status: (status as any) || 'TODO',
priority: (priority as any) || 'MEDIUM',
quarter,
category,
section,
tag,
taskType,
progress: progress ? Number(progress) : 0,
issueNote: issueNote || null,
startDate: startDate ? new Date(startDate) : undefined,
dueDate: dueDate ? new Date(dueDate) : undefined,
assigneeId: assigneeId || null,
creatorId: (req.body as Record<string, string>).creatorId ?? 'system',
},
});
res.status(201).json(task);
} catch (err) {
next(err);
}
});
// PATCH /api/tasks/:id — 업무 수정
router.patch('/:id', async (req, res, next) => {
try {
const existing = await prisma.task.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
const { title, description, status, priority, quarter, category,
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId } =
req.body as Record<string, string>;
const task = await prisma.task.update({
where: { id: req.params.id },
data: {
...(title && { title }),
...(description !== undefined && { description }),
...(status && { status: status as any }),
...(priority && { priority: priority as any }),
...(quarter && { quarter }),
...(category !== undefined && { category }),
...(section !== undefined && { section }),
...(tag !== undefined && { tag }),
...(taskType !== undefined && { taskType }),
...(progress !== undefined && { progress: Number(progress) }),
...(issueNote !== undefined && { issueNote: issueNote || null }),
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
},
});
res.json(task);
} catch (err) {
next(err);
}
});
// DELETE /api/tasks/:id — 업무 삭제
router.delete('/:id', async (req, res, next) => {
try {
const existing = await prisma.task.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
await prisma.task.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,78 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { prisma } from '../lib/prisma';
import { authenticate, requireAdmin } from '../middleware/auth';
import { AppError } from '../middleware/errorHandler';
const router = Router();
router.use(authenticate);
// GET /api/users — 전체 목록 (관리자)
router.get('/', requireAdmin, async (_req, res, next) => {
try {
const users = await prisma.user.findMany({
select: { id: true, email: true, name: true, role: true, department: true, isActive: true, createdAt: true },
orderBy: { name: 'asc' },
});
res.json(users);
} catch (err) {
next(err);
}
});
// POST /api/users — 사용자 생성 (관리자)
router.post('/', requireAdmin, async (req, res, next) => {
try {
const { email, password, name, role, department } = req.body as Record<string, string>;
if (!email || !password || !name) {
throw new AppError(400, '이메일, 비밀번호, 이름은 필수입니다.');
}
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) throw new AppError(409, '이미 사용 중인 이메일입니다.');
const hashed = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, password: hashed, name, role: (role as any) || 'MEMBER', department },
select: { id: true, email: true, name: true, role: true, department: true },
});
res.status(201).json(user);
} catch (err) {
next(err);
}
});
// PATCH /api/users/:id — 정보 수정
router.patch('/:id', async (req, res, next) => {
try {
const isAdmin = req.user!.role === 'ADMIN';
const isSelf = req.user!.userId === req.params.id;
if (!isAdmin && !isSelf) {
throw new AppError(403, '권한이 없습니다.');
}
const { name, department, password, role, isActive } = req.body as Record<string, string>;
const data: Record<string, unknown> = {};
if (name) data.name = name;
if (department !== undefined) data.department = department;
if (password) data.password = await bcrypt.hash(password, 12);
if (isAdmin && role) data.role = role;
if (isAdmin && isActive !== undefined) data.isActive = isActive === 'true';
const user = await prisma.user.update({
where: { id: req.params.id },
data,
select: { id: true, email: true, name: true, role: true, department: true, isActive: true },
});
res.json(user);
} catch (err) {
next(err);
}
});
export default router;

29
backend/src/socket.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { Server, Socket } from 'socket.io';
export function setupSocketHandlers(io: Server): void {
io.on('connection', (socket: Socket) => {
console.log(`[Socket] Connected: ${socket.id}`);
// 특정 업무 상세 방에 참여 (우측 모니터 패널용)
socket.on('join:task', (taskId: string) => {
socket.join(`task:${taskId}`);
});
socket.on('leave:task', (taskId: string) => {
socket.leave(`task:${taskId}`);
});
socket.on('disconnect', () => {
console.log(`[Socket] Disconnected: ${socket.id}`);
});
});
}
// 업무 변경 시 해당 방에 브로드캐스트 (라우터에서 호출)
export function emitTaskUpdated(io: Server, taskId: string, data: unknown): void {
io.to(`task:${taskId}`).emit('task:updated', data);
}
export function emitTaskListRefresh(io: Server): void {
io.emit('tasks:refresh');
}

19
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}