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

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');
}