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

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# ─── Database ───────────────────────────────────────────────
DB_USER=eee_admin
DB_PASSWORD=eee_password
DB_NAME=eee_dashboard
DB_PORT=5432
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard"
# ─── Backend ─────────────────────────────────────────────────
PORT=4000
FRONTEND_URL=http://172.16.8.248:3000
# JWT 시크릿 (운영 시 반드시 강력한 랜덤 문자열로 교체)
JWT_SECRET=change_this_secret_in_production
JWT_EXPIRES_IN=7d
# ─── File Upload ─────────────────────────────────────────────
UPLOAD_DIR=../uploads
MAX_FILE_SIZE_MB=20

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
build/
.env
data/
uploads/*
!uploads/.gitkeep
*.log
.DS_Store
Thumbs.db

162
README.md Normal file
View File

@@ -0,0 +1,162 @@
# EENE 인재성장팀 대시보드
## 사전 설치 필요
- [Node.js 20+](https://nodejs.org)
- [Docker Desktop](https://www.docker.com/products/docker-desktop/)
---
## 최초 실행 순서
### 1. 환경 변수 설정
```bash
# 루트에 .env 파일이 없을 경우 복사
copy .env.example .env
# 백엔드 .env 확인
# backend\.env 파일은 이미 기본값으로 생성되어 있음
```
### 2. PostgreSQL 실행 (Docker)
```bash
# 프로젝트 루트에서 실행
docker compose up -d
# 실행 확인
docker compose ps
```
### 3. 백엔드 설치 및 실행
```bash
cd backend
# 패키지 설치
npm install
# DB 마이그레이션 (테이블 생성)
npm run db:migrate
# 샘플 데이터 입력
npm run db:seed
# 개발 서버 실행
npm run dev
```
백엔드가 `http://localhost:4000` 에서 실행됩니다.
### 4. 프론트엔드 설치 및 실행
새 터미널을 열고:
```bash
cd frontend
# 패키지 설치
npm install
# 개발 서버 실행
npm run dev
```
프론트엔드가 `http://localhost:3000` 에서 실행됩니다.
---
## 서버(이 PC) IP 주소 확인
```powershell
# CMD 또는 PowerShell 에서 실행
ipconfig
# → "이더넷" 또는 "Wi-Fi" 항목의 IPv4 주소 확인
# 예: 192.168.1.100
```
팀원들이 접속할 주소:
- 대시보드: `http://172.16.8.248:3000`
- 업무 상세: `http://172.16.8.248:3000/detail`
### Windows 방화벽 포트 열기 (관리자 권한 CMD)
```cmd
netsh advfirewall firewall add rule name="EEE Dashboard Frontend" dir=in action=allow protocol=TCP localport=3000
netsh advfirewall firewall add rule name="EEE Dashboard Backend" dir=in action=allow protocol=TCP localport=4000
```
---
## 듀얼 모니터 사용 방법
1. 브라우저 창을 두 개 엽니다.
2. **왼쪽 모니터**: `http://localhost:3000` (업무 목록 대시보드)
3. **오른쪽 모니터**: `http://localhost:3000/detail` (업무 상세 패널)
4. 왼쪽에서 업무를 클릭하면 오른쪽 창이 자동으로 해당 상세 내용을 표시합니다.
---
## 기본 계정 (seed 데이터)
| 이메일 | 비밀번호 | 역할 |
|---|---|---|
| admin@eee.com | admin1234! | 관리자 |
| member@eee.com | member1234! | 팀원 |
> 운영 시 반드시 비밀번호를 변경하세요.
---
## 프로젝트 구조
```
D:\EENE_Dashboard\
├── backend\ # Express + TypeScript API 서버
│ ├── prisma\
│ │ ├── schema.prisma # DB 스키마 (테이블 정의)
│ │ └── seed.ts # 초기 데이터
│ └── src\
│ ├── index.ts # 서버 진입점
│ ├── app.ts # Express 앱 설정
│ ├── socket.ts # Socket.io 핸들러
│ ├── lib\ # Prisma 클라이언트
│ ├── middleware\ # 파일 업로드, 에러 처리
│ └── routes\ # API 라우터
│ ├── tasks.ts # 업무 CRUD
│ ├── users.ts # 사용자 관리
│ ├── files.ts # 파일 업로드
│ └── kpi.ts # KPI 관리
├── frontend\ # React + TypeScript 앱
│ └── src\
│ ├── contexts\ # Socket 컨텍스트
│ ├── lib\ # API 클라이언트, 듀얼모니터 유틸
│ ├── pages\ # 각 페이지 컴포넌트
│ ├── types\ # TypeScript 타입 정의
│ └── router.tsx # 라우팅 설정
├── uploads\ # 업로드 파일 저장소
├── 서버시작.bat # 서버 실행
└── 서버종료.bat # 서버 종료
```
---
## API 엔드포인트
| 메서드 | 경로 | 설명 |
|---|---|---|
| POST | /api/auth/login | 로그인 |
| GET | /api/auth/me | 내 정보 조회 |
| GET | /api/tasks | 업무 목록 |
| POST | /api/tasks | 업무 등록 |
| PATCH | /api/tasks/:id | 업무 수정 |
| DELETE | /api/tasks/:id | 업무 삭제 |
| GET | /api/users | 사용자 목록 (관리자) |
| POST | /api/users | 사용자 생성 (관리자) |
| POST | /api/files/upload/:taskId | 파일 업로드 |
| DELETE | /api/files/:id | 파일 삭제 |
| GET | /api/kpi | KPI 조회 |
| POST | /api/kpi | KPI 등록 |

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"]
}

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
postgres:
image: postgres:16-alpine
container_name: eee_dashboard_db
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-eee_admin}
POSTGRES_PASSWORD: ${DB_PASSWORD:-eee_password}
POSTGRES_DB: ${DB_NAME:-eee_dashboard}
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-eee_admin} -d ${DB_NAME:-eee_dashboard}"]
interval: 10s
timeout: 5s
retries: 5

3
frontend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Vercel 배포 시 실제 Render 백엔드 URL로 설정
VITE_API_URL=https://your-app.onrender.com
VITE_SOCKET_URL=https://your-app.onrender.com

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EENE 인재성장팀 대시보드</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3073
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "eene-dashboard-frontend",
"version": "1.0.0",
"description": "EENE 인재성장팀 대시보드 - Frontend",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.56.0",
"axios": "^1.7.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0",
"socket.io-client": "^4.8.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}

13
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { BrowserRouter } from 'react-router-dom';
import { AppRouter } from './router';
import { SocketProvider } from './contexts/SocketContext';
export default function App() {
return (
<BrowserRouter>
<SocketProvider>
<AppRouter />
</SocketProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react';
interface MenuItem {
label: string;
icon: string;
onClick: () => void;
danger?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [onClose]);
const adjustedY = Math.min(y, window.innerHeight - items.length * 46 - 20);
const adjustedX = Math.min(x, window.innerWidth - 170);
return (
<div
ref={ref}
style={{ position: 'fixed', top: adjustedY, left: adjustedX, zIndex: 9999 }}
className="bg-white rounded-xl shadow-2xl border border-gray-100 py-1.5 min-w-[155px]"
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item, i) => (
<button
key={i}
onClick={() => { item.onClick(); onClose(); }}
className={`w-full text-left px-4 py-2.5 text-base font-semibold flex items-center gap-2.5 transition-colors ${
item.danger
? 'text-red-600 hover:bg-red-50'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<span className="text-lg leading-none">{item.icon}</span>
{item.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
interface Option { value: string; label: string; color: string; }
interface EditableSelectProps {
value: string;
options: Option[];
onSave: (val: string) => void;
className?: string;
}
export function EditableSelect({ value, options, onSave, className = '' }: EditableSelectProps) {
const [editing, setEditing] = useState(false);
const current = options.find(o => o.value === value);
if (editing) {
return (
<select
autoFocus
value={value}
onChange={(e) => { onSave(e.target.value); setEditing(false); }}
onBlur={() => setEditing(false)}
className={`rounded px-2 py-1 text-sm border border-gray-300 outline-none ${className}`}
>
{options.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
return (
<span
onClick={() => setEditing(true)}
title="클릭하여 변경"
className={`cursor-pointer hover:opacity-80 transition-opacity ${current?.color ?? ''} ${className}`}
>
{current?.label ?? value}
</span>
);
}

View File

@@ -0,0 +1,74 @@
import { useState, useRef, useEffect } from 'react';
interface EditableTextProps {
value: string;
onSave: (val: string) => void;
className?: string;
multiline?: boolean;
placeholder?: string;
}
/**
* 클릭하면 편집 가능한 텍스트 컴포넌트
* - 클릭 → 입력 필드로 전환
* - Enter 또는 blur → 저장
* - Escape → 취소
*/
export function EditableText({ value, onSave, className = '', multiline = false, placeholder = '클릭하여 입력' }: EditableTextProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null);
useEffect(() => { setDraft(value); }, [value]);
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
const commit = () => {
setEditing(false);
if (draft.trim() !== value) onSave(draft.trim());
};
const cancel = () => {
setEditing(false);
setDraft(value);
};
const sharedClass = `w-full bg-white/90 border-b-2 border-blue-400 outline-none rounded px-1 resize-none ${className}`;
if (editing) {
return multiline ? (
<textarea
ref={inputRef as any}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Escape') cancel(); }}
rows={3}
className={sharedClass}
/>
) : (
<input
ref={inputRef as any}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') cancel(); }}
className={sharedClass}
placeholder={placeholder}
/>
);
}
return (
<span
onClick={() => setEditing(true)}
title="클릭하여 수정"
className={`cursor-text hover:bg-white/30 hover:rounded px-1 -mx-1 transition-colors group relative ${className}`}
>
{value || <span className="text-white/40 italic">{placeholder}</span>}
<span className="opacity-0 group-hover:opacity-60 ml-1 text-xs transition-opacity"></span>
</span>
);
}

View File

@@ -0,0 +1,257 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import type { Task } from '../../types';
const TAG_OPTIONS = ['Growth', 'Policy', 'Performance', 'Culture', 'Asset', 'Space', 'Safety', 'Environment'];
const STATUS_OPTIONS = [
{ value: 'TODO', label: '대기' },
{ value: 'IN_PROGRESS', label: '진행' },
{ value: 'REVIEW', label: '보류' },
{ value: 'DONE', label: '완료' },
];
export interface TaskFormData {
title: string;
section: string;
tag: string;
taskType: string;
status: string;
progress: number;
description: string;
issueNote: string;
quarter: string;
startDate: string;
dueDate: string;
}
interface TaskModalProps {
mode: 'add' | 'edit';
task?: Task;
defaultSection?: string;
defaultQuarter?: string;
sectionOptions?: { value: string; label: string }[];
onSave: (data: TaskFormData) => void;
onClose: () => void;
}
export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter = '2026-Q2', sectionOptions, onSave, onClose }: TaskModalProps) {
const toDateInput = (iso: string | null | undefined) => {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
};
const [form, setForm] = useState<TaskFormData>({
title: task?.title ?? '',
section: task?.section ?? defaultSection,
tag: task?.tag ?? '',
taskType: task?.taskType ?? '상시업무',
status: task?.status ?? 'TODO',
progress: task?.progress ?? 0,
description: task?.description ?? '',
issueNote: task?.issueNote ?? '',
quarter: task?.quarter ?? defaultQuarter,
startDate: toDateInput(task?.startDate),
dueDate: toDateInput(task?.dueDate),
});
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
setForm(prev => ({ ...prev, [field]: value }));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(form);
};
const progressColor =
form.progress >= 70 ? 'bg-emerald-500' :
form.progress >= 40 ? 'bg-blue-400' :
'bg-orange-400';
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="bg-white rounded-2xl shadow-2xl w-[540px] max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-2xl font-black text-gray-800">
{mode === 'add' ? '✚ 업무 추가' : '✏ 업무 수정'}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none transition-colors"
>
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* 제목 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> *</label>
<input
required
value={form.title}
onChange={(e) => set('title', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="업무 제목을 입력하세요"
/>
</div>
{/* 섹션 + 태그 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.section}
onChange={(e) => set('section', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
>
{(sectionOptions ?? [
{ value: 'HR', label: 'HR' },
{ value: '운영관리', label: '운영관리' },
]).map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.tag}
onChange={(e) => set('tag', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
>
<option value=""> </option>
{TAG_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
{/* 업무유형 + 상태 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> </label>
<select
value={form.taskType}
onChange={(e) => set('taskType', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
>
<option value="상시업무"></option>
<option value="프로젝트"></option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
{/* 진행률 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5">
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={5}
value={form.progress}
onChange={(e) => set('progress', Number(e.target.value))}
className="flex-1 accent-blue-500"
/>
<div className="w-24 h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-3 ${progressColor} rounded-full transition-all`}
style={{ width: `${form.progress}%` }}
/>
</div>
</div>
</div>
{/* 내용 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<textarea
value={form.description}
onChange={(e) => set('description', e.target.value)}
rows={4}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 resize-none transition"
placeholder="내용을 한 줄씩 입력하세요 (줄바꿈으로 구분)"
/>
</div>
{/* 프로젝트 기간 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
type="date"
value={form.startDate}
onChange={(e) => set('startDate', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
type="date"
value={form.dueDate}
onChange={(e) => set('dueDate', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
/>
</div>
</div>
{/* 이슈 메모 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> </label>
<input
value={form.issueNote}
onChange={(e) => set('issueNote', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-red-400 focus:ring-2 focus:ring-red-100 transition text-red-600 placeholder:text-gray-300"
placeholder="[날짜] 이슈 내용 (비우면 표시 안 함)"
/>
</div>
{/* 버튼 */}
<div className="flex justify-end gap-2 pt-2 pb-1">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50 transition"
>
</button>
<button
type="submit"
className="px-6 py-2.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition"
>
{mode === 'add' ? '추가하기' : '저장하기'}
</button>
</div>
</form>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,113 @@
interface Stats {
total: number;
inProgress: number;
review: number;
waiting: number;
done: number;
issues: number;
}
interface DashboardHeaderProps {
quarter: string;
stats: Stats;
activeType: string;
onTypeChange: (type: string) => void;
activeStatus: string;
onStatusChange: (status: string) => void;
onOpenDetailWindow: () => void;
}
export function DashboardHeader({ quarter, stats, activeType, onTypeChange, activeStatus, onStatusChange, onOpenDetailWindow }: DashboardHeaderProps) {
const today = new Date();
const todayStr = `${today.getMonth() + 1}${today.getDate()}`;
return (
<header className="bg-[#1e3260] text-white px-5 py-3 shrink-0 relative flex items-center">
{/* ── 왼쪽: 팀명 + 분기 ── */}
<div className="flex items-center gap-3 shrink-0">
<div className="shrink-0">
<span className="text-base font-bold tracking-tight"></span>
<span className="ml-1.5 text-xs text-blue-300 font-medium">EE&amp;E</span>
</div>
<div className="w-px h-5 bg-white/20" />
<span className="text-sm font-semibold text-blue-200 shrink-0">
{quarter.replace(/^(\d{4})-Q(\d)$/, '$1년 $2분기')}
</span>
</div>
{/* ── 가운데: 상태 필터 버튼 (절대 중앙 정렬) ── */}
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-1.5 text-sm">
<StatButton label="전체" value={stats.total} statusKey="전체" activeStatus={activeStatus} onClick={onStatusChange} color="text-white" activeColor="bg-white/20" />
<StatButton label="진행" value={stats.inProgress} statusKey="IN_PROGRESS" activeStatus={activeStatus} onClick={onStatusChange} color="text-blue-300" activeColor="bg-blue-500/40" />
<StatButton label="보류" value={stats.review} statusKey="REVIEW" activeStatus={activeStatus} onClick={onStatusChange} color="text-orange-300" activeColor="bg-orange-500/40" />
<StatButton label="대기" value={stats.waiting} statusKey="TODO" activeStatus={activeStatus} onClick={onStatusChange} color="text-gray-400" activeColor="bg-gray-500/40" />
<StatButton label="완료" value={stats.done} statusKey="DONE" activeStatus={activeStatus} onClick={onStatusChange} color="text-emerald-400" activeColor="bg-emerald-500/40" />
{stats.issues > 0 && (
<>
<span className="text-white/30 mx-0.5">|</span>
<StatButton label="이슈" value={stats.issues} statusKey="ISSUES" activeStatus={activeStatus} onClick={onStatusChange} color="text-red-400" activeColor="bg-red-500/40" />
</>
)}
</div>
{/* ── 오른쪽: 날짜 + 업무유형 필터 ── */}
<div className="ml-auto flex items-center gap-3 shrink-0">
<button
onClick={onOpenDetailWindow}
title="우측 모니터에 상세 창 열기"
className="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors border border-white/10 hover:border-white/30"
>
<span>🖥</span>
<span> </span>
</button>
<div className="w-px h-5 bg-white/20" />
<span className="text-xs text-white/50">{todayStr}</span>
<div className="w-px h-5 bg-white/20" />
<div className="flex gap-1">
{['전체', '상시업무', '프로젝트'].map((type) => (
<button
key={type}
onClick={() => onTypeChange(type)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
activeType === type
? 'bg-blue-500 text-white'
: 'bg-white/10 text-white/70 hover:bg-white/20'
}`}
>
{type}
</button>
))}
</div>
</div>
</header>
);
}
interface StatButtonProps {
label: string;
value: number;
statusKey: string;
activeStatus: string;
onClick: (key: string) => void;
color: string;
activeColor: string;
}
function StatButton({ label, value, statusKey, activeStatus, onClick, color, activeColor }: StatButtonProps) {
const isActive = activeStatus === statusKey;
return (
<button
onClick={() => onClick(isActive ? '전체' : statusKey)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border transition-all cursor-pointer select-none ${
isActive
? `${activeColor} border-white/30 shadow-inner`
: 'border-white/10 hover:border-white/30 hover:bg-white/10 active:scale-95'
}`}
>
<span className="text-white/60 text-xs font-semibold">{label}</span>
<span className={`font-black text-sm ${color}`}>{value}</span>
</button>
);
}

View File

@@ -0,0 +1,289 @@
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { apiClient } from '../../lib/apiClient';
import { SortableTaskCard } from './TaskCard';
import { ContextMenu } from '../common/ContextMenu';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
interface DepartmentColumnProps {
title: string;
titleEn?: string;
subtitle?: string;
tasks: Task[];
headerBg: string;
headerStyle?: React.CSSProperties;
storageKey: string;
section: string;
quarter: string;
noHeader?: boolean;
headerAlign?: 'left' | 'right';
onSelectTask?: (task: Task) => void;
sectionOptions?: { value: string; label: string }[];
}
// ── 헤더 편집 팝업 ──────────────────────────────────────────
interface HeaderModalProps {
title: string;
titleEn: string;
subtitle: string;
onSave: (title: string, titleEn: string, subtitle: string) => void;
onClose: () => void;
}
function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalProps) {
const [draftTitle, setDraftTitle] = useState(title);
const [draftTitleEn, setDraftTitleEn] = useState(titleEn);
const [draftSubtitle, setDraftSubtitle] = useState(subtitle);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (draftTitle.trim()) onSave(draftTitle.trim(), draftTitleEn.trim(), draftSubtitle.trim());
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-[420px] p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-black text-gray-800"> </h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none"></button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
required
autoFocus
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg font-bold outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
value={draftTitleEn}
onChange={(e) => setDraftTitleEn(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="Human Resources"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
value={draftSubtitle}
onChange={(e) => setDraftSubtitle(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="부제목 입력"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50 transition"></button>
<button type="submit" className="px-6 py-2.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition"></button>
</div>
</form>
</div>
</div>,
document.body
);
}
export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions }: DepartmentColumnProps) {
const queryClient = useQueryClient();
// ── 컬럼 설정 API ─────────────────────────────────────────
const { data: colConfig } = useQuery({
queryKey: ['columns', section],
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(section)}`).then((r) => r.data),
staleTime: 0,
});
const patchColumn = useMutation({
mutationFn: (data: Record<string, string>) =>
apiClient.patch(`/columns/${encodeURIComponent(section)}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', section] }),
});
const title = colConfig?.title ?? initialTitle;
const titleEnState = colConfig?.titleEn ?? (titleEn ?? '');
const subtitle = colConfig?.subtitle ?? initialSubtitle;
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [showHeaderModal, setShowHeaderModal] = useState(false);
// ── 드래그 순서 관리 (DB 저장) ────────────────────────────
const [orderedIds, setOrderedIds] = useState<string[]>([]);
const saveOrderTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// DB에서 불러온 순서 반영
useEffect(() => {
if (colConfig?.cardOrder) {
try { setOrderedIds(JSON.parse(colConfig.cardOrder)); } catch { /* ignore */ }
}
}, [colConfig?.cardOrder]);
// tasks 목록이 바뀌면 새 항목을 순서 목록에 추가
useEffect(() => {
const newIds = tasks.map((t) => t.id);
setOrderedIds((prev) => {
const merged = [...prev.filter((id) => newIds.includes(id)), ...newIds.filter((id) => !prev.includes(id))];
return merged;
});
}, [tasks]);
const orderedTasks = [...tasks].sort((a, b) => {
const ai = orderedIds.indexOf(a.id);
const bi = orderedIds.indexOf(b.id);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setOrderedIds((prev) => {
const oldIdx = prev.indexOf(active.id as string);
const newIdx = prev.indexOf(over.id as string);
const next = arrayMove(prev, oldIdx, newIdx);
// 300ms 디바운스 후 DB 저장
if (saveOrderTimer.current) clearTimeout(saveOrderTimer.current);
saveOrderTimer.current = setTimeout(() => {
patchColumn.mutate({ cardOrder: JSON.stringify(next) });
}, 300);
return next;
});
};
const saveTitle = (v: string) => patchColumn.mutate({ title: v });
const saveTitleEn = (v: string) => patchColumn.mutate({ titleEn: v });
const saveSubtitle = (v: string) => patchColumn.mutate({ subtitle: v });
const create = useMutation({
mutationFn: (data: Partial<Task>) => apiClient.post('/tasks', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
const handleListContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY, type: 'list' });
};
const sectionOptions = externalSectionOptions ?? [{ value: section, label: title }];
const displayTitle = title.replace(/\s*부문$/, '');
const handleAdd = (data: TaskFormData) => {
create.mutate({
title: data.title,
section: data.section || null,
tag: data.tag || 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,
quarter: data.quarter,
priority: 'MEDIUM',
creatorId: 'system',
} as any);
setShowAddModal(false);
};
return (
<>
<div className="flex flex-col bg-gray-50 rounded-2xl overflow-hidden border border-gray-200 min-h-0">
{/* 컬럼 헤더 (noHeader 시 숨김) */}
{!noHeader && (
<div
className="h-10 shrink-0 select-none flex items-center px-4 gap-2"
style={headerStyle}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }}
>
<span className="text-white font-black text-base tracking-tight truncate">{displayTitle}</span>
{titleEnState && (
<span className="text-white/60 text-xs font-medium truncate hidden xl:block">{titleEnState}</span>
)}
<span className="ml-auto shrink-0 text-xs font-black bg-white/20 text-white px-2 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
)}
{/* 업무 카드 목록 */}
<div
className="flex-1 overflow-y-auto p-4 min-h-0"
onContextMenu={handleListContextMenu}
>
{orderedTasks.length === 0 ? (
<div className="flex items-center justify-center h-40 text-2xl text-gray-300">
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={orderedTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{orderedTasks.map((task) => <SortableTaskCard key={task.id} task={task} sectionOptions={sectionOptions} onSelect={onSelectTask} />)}
</SortableContext>
</DndContext>
)}
</div>
</div>
{/* 컨텍스트 메뉴 */}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={
ctxMenu.type === 'header'
? [{ icon: '✏', label: '헤더 수정', onClick: () => setShowHeaderModal(true) }]
: [{ icon: '✚', label: '업무 추가', onClick: () => setShowAddModal(true) }]
}
/>
)}
{/* 추가 모달 */}
{showAddModal && (
<TaskModal
mode="add"
defaultSection={section}
defaultQuarter={quarter}
sectionOptions={sectionOptions}
onSave={handleAdd}
onClose={() => setShowAddModal(false)}
/>
)}
{/* 헤더 편집 모달 */}
{showHeaderModal && (
<HeaderModal
title={title}
titleEn={titleEnState}
subtitle={subtitle}
onSave={(t, te, s) => { saveTitle(t); saveTitleEn(te); saveSubtitle(s); setShowHeaderModal(false); }}
onClose={() => setShowHeaderModal(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,140 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../../lib/apiClient';
import type { Task } from '../../types';
const SECTIONS = [
{ key: '인사관리', titleEn: 'HR Management', gradient: 'linear-gradient(120deg, #2a4a8a 0%, #3461b8 50%, #3d72d0 100%)' },
{ key: '학습성장', titleEn: 'Learning & Growth', gradient: 'linear-gradient(120deg, #5b2d8a 0%, #7340b8 50%, #8a52d0 100%)' },
{ key: '운영지원', titleEn: 'Operations', gradient: 'linear-gradient(120deg, #0d6080 0%, #0d7a9a 50%, #0e92b8 100%)' },
{ key: '전산관리', titleEn: 'IT Management', gradient: 'linear-gradient(120deg, #0a6040 0%, #0d8050 50%, #10a060 100%)' },
] as const;
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
};
const STATUS_DOT: Record<string, string> = {
IN_PROGRESS: 'bg-blue-500',
REVIEW: 'bg-orange-400',
TODO: 'bg-gray-300',
DONE: 'bg-emerald-500',
CANCELLED: 'bg-gray-300',
};
interface PanelColumnProps {
section: typeof SECTIONS[number];
tasks: Task[];
label: string;
}
function PanelColumn({ section, tasks, label }: PanelColumnProps) {
const issues = tasks.filter((t) => t.issueNote);
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
{/* 헤더 */}
<div className="shrink-0 flex items-center h-10 px-4 gap-2"
style={{ background: section.gradient }}>
<span className="text-white font-black text-sm tracking-tight truncate">{label}</span>
<span className="text-white/60 text-xs font-medium truncate hidden 2xl:block">{section.titleEn}</span>
<span className="ml-auto shrink-0 text-xs font-black bg-white/20 text-white px-2 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
{/* 업무 목록 (flex 4) */}
<div className="flex flex-col min-h-0" style={{ flex: 4 }}>
<div className="px-3 py-1.5 border-b border-gray-100 shrink-0">
<span className="text-xs font-black text-gray-400 uppercase tracking-wider"> </span>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{tasks.length === 0 ? (
<p className="text-xs text-gray-300 py-2 text-center"> </p>
) : (
tasks.map((t) => (
<div key={t.id}
className="flex items-center gap-2 rounded-lg bg-white border border-gray-100 px-2.5 py-1.5 hover:border-gray-200 transition-all">
<span className={`shrink-0 w-2 h-2 rounded-full ${STATUS_DOT[t.status] ?? 'bg-gray-300'}`} />
<span className="flex-1 text-xs font-semibold text-gray-700 truncate">{t.title}</span>
<span className="shrink-0 text-xs font-bold text-gray-400">{STATUS_LABEL[t.status]}</span>
<div className="shrink-0 w-10 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className={`h-1.5 rounded-full ${
t.progress >= 70 ? 'bg-emerald-400' :
t.progress >= 40 ? 'bg-blue-400' : 'bg-orange-400'
}`} style={{ width: `${t.progress}%` }} />
</div>
</div>
))
)}
</div>
</div>
{/* 이슈 (flex 1) */}
<div className="flex flex-col min-h-0 border-t-2 border-red-100" style={{ flex: 1 }}>
<div className="px-3 py-1 bg-red-50 shrink-0 flex items-center gap-1.5 border-b border-red-100">
<span className="text-xs font-black text-red-400 uppercase tracking-wider"></span>
{issues.length > 0 && (
<span className="text-xs font-black bg-red-400 text-white px-1.5 py-0.5 rounded-full leading-none">
{issues.length}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto px-3 py-1 space-y-1">
{issues.length === 0 ? (
<p className="text-xs text-gray-300 py-1 text-center"> </p>
) : (
issues.map((t) => (
<div key={t.id}
className="flex items-start gap-1.5 rounded-lg bg-red-50 border border-red-100 px-2.5 py-1.5">
<span className="shrink-0 text-red-400 text-xs mt-0.5"></span>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-gray-600 truncate">{t.title}</p>
<p className="text-xs text-red-500 truncate">{t.issueNote}</p>
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
interface RoutinePanelProps {
tasks: Task[];
}
export function RoutinePanel({ tasks }: RoutinePanelProps) {
const { data: configs } = useQuery<{ key: string; title: string }[]>({
queryKey: ['columns', 'all-routine'],
queryFn: async () => {
const results = await Promise.all(
SECTIONS.map((s) =>
apiClient.get(`/columns/${encodeURIComponent(s.key)}`).then((r) => ({ key: s.key, ...r.data })),
),
);
return results;
},
staleTime: 0,
});
const getLabel = (key: string) =>
configs?.find((c) => c.key === key)?.title ?? key;
return (
<div className="grid grid-cols-4 h-full min-h-0 divide-x divide-gray-200">
{SECTIONS.map((section) => (
<PanelColumn
key={section.key}
section={section}
tasks={tasks.filter((t) => t.section === section.key)}
label={getLabel(section.key)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,282 @@
import { useState, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { sendTaskSelected } from '../../lib/dualMonitor';
import { apiClient } from '../../lib/apiClient';
import { ContextMenu } from '../common/ContextMenu';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
// ─── 태그 배지 색상 (카드 배경은 흰색 통일) ──────────────────
const TAG_CONFIG: Record<string, { bg: string; text: string }> = {
Growth: { bg: 'bg-blue-100', text: 'text-blue-800' },
Policy: { bg: 'bg-purple-100', text: 'text-purple-800' },
Performance: { bg: 'bg-emerald-100', text: 'text-emerald-800' },
Culture: { bg: 'bg-amber-100', text: 'text-amber-800' },
Asset: { bg: 'bg-cyan-100', text: 'text-cyan-800' },
Space: { bg: 'bg-indigo-100', text: 'text-indigo-800' },
Safety: { bg: 'bg-red-100', text: 'text-red-800' },
Environment: { bg: 'bg-lime-100', text: 'text-lime-800' },
};
const STATUS_STYLE: Record<string, string> = {
IN_PROGRESS: 'bg-blue-500 text-white',
REVIEW: 'bg-orange-400 text-white',
TODO: 'bg-gray-300 text-gray-700',
DONE: 'bg-emerald-500 text-white',
CANCELLED: 'bg-gray-200 text-gray-500',
};
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
};
// ─── 날짜 포맷 헬퍼 ─────────────────────────────────────────
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}.${m}.${dd}`;
}
type SectionOption = { value: string; label: string };
// ─── 드래그 가능한 래퍼 ──────────────────────────────────────
export function SortableTaskCard({ task, sectionOptions, onSelect }: { task: Task; sectionOptions?: SectionOption[]; onSelect?: (task: Task) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
// dnd-kit이 dragListeners의 onPointerDown을 가로채므로 onClick이 막힐 수 있음.
// pointerDown 시작 위치를 ref로 기억했다가 pointerUp에서 이동이 적으면 클릭으로 판정.
const pointerStart = useRef<{ x: number; y: number } | null>(null);
const handlePointerDown = (e: React.PointerEvent) => {
pointerStart.current = { x: e.clientX, y: e.clientY };
};
const handlePointerUp = (e: React.PointerEvent) => {
if (!pointerStart.current || isDragging) return;
const dx = e.clientX - pointerStart.current.x;
const dy = e.clientY - pointerStart.current.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 6) {
onSelect?.(task);
}
pointerStart.current = null;
};
return (
<TaskCard
task={task}
dragRef={setNodeRef}
dragStyle={style}
dragAttributes={attributes}
dragListeners={listeners}
sectionOptions={sectionOptions}
onCardPointerDown={handlePointerDown}
onCardPointerUp={handlePointerUp}
/>
);
}
// ─── 메인 TaskCard ──────────────────────────────────────────
export function TaskCard({
task,
dragRef,
dragStyle,
dragAttributes,
dragListeners,
sectionOptions,
onCardPointerDown,
onCardPointerUp,
}: {
task: Task;
dragRef?: (node: HTMLElement | null) => void;
dragStyle?: React.CSSProperties;
dragAttributes?: DraggableAttributes;
dragListeners?: SyntheticListenerMap;
sectionOptions?: SectionOption[];
onCardPointerDown?: (e: React.PointerEvent) => void;
onCardPointerUp?: (e: React.PointerEvent) => void;
}) {
const queryClient = useQueryClient();
const tagCfg = TAG_CONFIG[task.tag ?? ''] ?? { bg: 'bg-gray-100', text: 'text-gray-600' };
// ── API mutations ──────────────────────────────────────────
const create = useMutation({
mutationFn: (data: Record<string, unknown>) => apiClient.post('/tasks', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
const patch = useMutation({
mutationFn: (data: Record<string, unknown>) => apiClient.patch(`/tasks/${task.id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
const remove = useMutation({
mutationFn: () => apiClient.delete(`/tasks/${task.id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
// ── 컨텍스트 메뉴 + 모달 상태 ─────────────────────────────
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [modalMode, setModalMode] = useState<'add' | 'edit' | null>(null);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY });
};
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,
quarter: data.quarter,
priority: 'MEDIUM',
creatorId: 'system',
});
setModalMode(null);
};
const handleEdit = (data: TaskFormData) => {
patch.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,
});
setModalMode(null);
};
const handleDelete = () => {
if (window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) {
remove.mutate();
}
};
return (
<>
<div
ref={dragRef}
style={dragStyle}
{...dragAttributes}
{...dragListeners}
className="bg-white rounded-2xl px-5 py-3 mb-3 shadow-sm border border-gray-100 hover:shadow-md hover:border-gray-200 transition-all select-none h-[112px] cursor-grab active:cursor-grabbing overflow-hidden"
onPointerDown={onCardPointerDown}
onPointerUp={onCardPointerUp}
onContextMenu={handleContextMenu}
>
{/* ── 상단: 제목 + 진행률 ── */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0 flex-1">
<span className="text-2xl font-bold text-gray-900 leading-snug min-w-0 truncate">
{task.title}
</span>
</div>
<span className={`shrink-0 text-2xl font-black mt-0.5 min-w-[4rem] text-right ${
task.progress >= 70 ? 'text-emerald-500' :
task.progress >= 40 ? 'text-blue-400' :
'text-orange-400'
}`}>
{task.progress}%
</span>
</div>
{/* ── 태그 + 기간 + 상태 ── */}
<div className="mt-1.5 flex items-center gap-2">
<span
className={`shrink-0 text-sm font-black px-2.5 py-0.5 rounded-full ${tagCfg.bg} ${tagCfg.text} cursor-pointer`}
onClick={() => sendTaskSelected(task.id)}
>
{task.tag}
</span>
<span className="text-base text-gray-400 font-medium flex-1 truncate">
{(task.startDate || task.dueDate)
? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}`
: ''}
</span>
<span className={`text-sm font-bold px-2.5 py-0.5 rounded-full ${STATUS_STYLE[task.status]} shrink-0`}>
{STATUS_LABEL[task.status]}
</span>
</div>
{/* ── 이슈 메모 ── */}
{task.issueNote && (
<div className="mt-2 flex gap-2 text-sm font-semibold text-red-600 min-w-0">
<span className="shrink-0"> :</span>
<span className="truncate">{task.issueNote}</span>
</div>
)}
</div>
{/* ── 컨텍스트 메뉴 ── */}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={[
{ icon: '✚', label: '업무 추가', onClick: () => setModalMode('add') },
{ icon: '✏', label: '업무 수정', onClick: () => setModalMode('edit') },
{ icon: '🗑', label: '업무 삭제', onClick: handleDelete, danger: true },
]}
/>
)}
{/* ── 추가 모달 ── */}
{modalMode === 'add' && (
<TaskModal
mode="add"
defaultSection={task.section ?? 'HR'}
defaultQuarter={task.quarter}
sectionOptions={sectionOptions}
onSave={handleAdd}
onClose={() => setModalMode(null)}
/>
)}
{/* ── 수정 모달 ── */}
{modalMode === 'edit' && (
<TaskModal
mode="edit"
task={task}
sectionOptions={sectionOptions}
onSave={handleEdit}
onClose={() => setModalMode(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,155 @@
import type { Task } from '../../types';
const TAG_CONFIG: Record<string, { bg: string; text: string }> = {
Growth: { bg: 'bg-blue-100', text: 'text-blue-800' },
Policy: { bg: 'bg-purple-100', text: 'text-purple-800' },
Performance: { bg: 'bg-emerald-100', text: 'text-emerald-800' },
Culture: { bg: 'bg-amber-100', text: 'text-amber-800' },
Asset: { bg: 'bg-cyan-100', text: 'text-cyan-800' },
Space: { bg: 'bg-indigo-100', text: 'text-indigo-800' },
Safety: { bg: 'bg-red-100', text: 'text-red-800' },
Environment: { bg: 'bg-lime-100', text: 'text-lime-800' },
};
const STATUS_STYLE: Record<string, string> = {
IN_PROGRESS: 'bg-blue-500 text-white',
REVIEW: 'bg-orange-400 text-white',
TODO: 'bg-gray-300 text-gray-700',
DONE: 'bg-emerald-500 text-white',
CANCELLED: 'bg-gray-200 text-gray-500',
};
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
};
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
}
interface Props {
task: Task | null;
onClose: () => void;
}
export function TaskDetailPanel({ task, onClose }: Props) {
const isOpen = !!task;
return (
<div
className={`h-full flex flex-col bg-white border-l border-gray-200 shadow-xl transition-all duration-300 ease-out overflow-hidden ${
isOpen ? 'w-[420px]' : 'w-0'
}`}
>
{task && (
<>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className={`shrink-0 text-sm font-black px-2.5 py-0.5 rounded-full ${TAG_CONFIG[task.tag ?? '']?.bg ?? 'bg-gray-100'} ${TAG_CONFIG[task.tag ?? '']?.text ?? 'text-gray-600'}`}>
{task.tag}
</span>
<span className={`shrink-0 text-sm font-bold px-2.5 py-0.5 rounded-full ${STATUS_STYLE[task.status]}`}>
{STATUS_LABEL[task.status]}
</span>
</div>
<button
onClick={onClose}
className="shrink-0 ml-2 text-gray-400 hover:text-gray-600 text-xl leading-none transition-colors"
>
</button>
</div>
{/* 본문 */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{/* 제목 */}
<div>
<p className="text-xs font-bold text-gray-400 mb-1"></p>
<h2 className="text-2xl font-black text-gray-900 leading-snug">{task.title}</h2>
</div>
{/* 진행률 */}
<div>
<p className="text-xs font-bold text-gray-400 mb-1.5"></p>
<div className="flex items-center gap-3">
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-3 rounded-full transition-all ${
task.progress >= 70 ? 'bg-emerald-500' :
task.progress >= 40 ? 'bg-blue-400' :
'bg-orange-400'
}`}
style={{ width: `${task.progress}%` }}
/>
</div>
<span className={`text-xl font-black min-w-[3rem] text-right ${
task.progress >= 70 ? 'text-emerald-500' :
task.progress >= 40 ? 'text-blue-400' :
'text-orange-400'
}`}>
{task.progress}%
</span>
</div>
</div>
{/* 기간 */}
{(task.startDate || task.dueDate) && (
<div>
<p className="text-xs font-bold text-gray-400 mb-1"></p>
<p className="text-base font-medium text-gray-700">
{fmtDate(task.startDate)} ~ {fmtDate(task.dueDate)}
</p>
</div>
)}
{/* 섹션 / 업무유형 */}
<div className="flex gap-6">
{task.section && (
<div>
<p className="text-xs font-bold text-gray-400 mb-1"></p>
<p className="text-base font-semibold text-gray-700">{task.section}</p>
</div>
)}
{task.taskType && (
<div>
<p className="text-xs font-bold text-gray-400 mb-1"></p>
<p className="text-base font-semibold text-gray-700">{task.taskType}</p>
</div>
)}
</div>
{/* 내용 */}
{task.description && (
<div>
<p className="text-xs font-bold text-gray-400 mb-2"></p>
<ul className="space-y-2">
{task.description.split('\n').filter(Boolean).map((b, i) => (
<li key={i} className="flex gap-2 text-base text-gray-700">
<span className="shrink-0 text-gray-300 mt-0.5"></span>
<span>{b.replace(/^[•·\-]\s*/, '')}</span>
</li>
))}
</ul>
</div>
)}
{/* 이슈 메모 */}
{task.issueNote && (
<div className="rounded-xl bg-red-50 border border-red-100 px-4 py-3">
<p className="text-xs font-bold text-red-400 mb-1"> </p>
<p className="text-base font-semibold text-red-600">{task.issueNote}</p>
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { apiClient } from '../lib/apiClient';
interface User {
id: string;
email: string;
name: string;
role: 'ADMIN' | 'MANAGER' | 'MEMBER';
department: string | null;
}
interface AuthContextValue {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const TOKEN_KEY = 'eee_token';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!token) {
setIsLoading(false);
return;
}
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
apiClient
.get<User>('/auth/me')
.then(({ data }) => setUser(data))
.catch(() => {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
})
.finally(() => setIsLoading(false));
}, [token]);
const login = async (email: string, password: string) => {
const { data } = await apiClient.post<{ token: string; user: User }>('/auth/login', {
email,
password,
});
localStorage.setItem(TOKEN_KEY, data.token);
apiClient.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
setToken(data.token);
setUser(data.user);
};
const logout = () => {
localStorage.removeItem(TOKEN_KEY);
delete apiClient.defaults.headers.common['Authorization'];
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider
value={{ user, token, isAuthenticated: !!user, isLoading, login, logout }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
}

View File

@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
import { io, type Socket } from 'socket.io-client';
const SocketContext = createContext<Socket | null>(null);
// 같은 네트워크 팀원도 접속 가능: 백엔드 주소를 현재 페이지 호스트에서 자동 감지
const SOCKET_URL =
import.meta.env.VITE_SOCKET_URL ||
`${window.location.protocol}//${window.location.hostname}:4000`;
export function SocketProvider({ children }: { children: ReactNode }) {
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const socket = io(SOCKET_URL, { transports: ['websocket'] });
socketRef.current = socket;
socket.on('connect', () => console.log('[Socket] Connected'));
socket.on('disconnect', () => console.log('[Socket] Disconnected'));
return () => {
socket.disconnect();
socketRef.current = null;
};
}, []);
return (
<SocketContext.Provider value={socketRef.current}>
{children}
</SocketContext.Provider>
);
}
export function useSocket(): Socket | null {
return useContext(SocketContext);
}

View File

@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import type { Task } from '../types';
interface TasksParams {
quarter?: string;
section?: string;
taskType?: string;
}
export function useTasks(params?: TasksParams) {
return useQuery({
queryKey: ['tasks', params],
queryFn: async () => {
const { data } = await apiClient.get<Task[]>('/tasks', { params });
return data;
},
refetchInterval: 30_000,
});
}

1
frontend/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,19 @@
import axios from 'axios';
// 개발: Vite 프록시 → /api (localhost:4000)
// 배포: VITE_API_URL=https://xxx.onrender.com 설정 시 그 주소 사용
const baseURL = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/api`
: '/api';
export const apiClient = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
});
apiClient.interceptors.response.use(
(res) => res,
(error) => Promise.reject(error),
);

View File

@@ -0,0 +1,84 @@
/**
* 듀얼 모니터 연동 유틸리티
* BroadcastChannel API를 사용해 두 브라우저 창 간 실시간 통신
*/
const CHANNEL_NAME = 'eee_dashboard';
const DETAIL_WINDOW_NAME = 'eene_detail';
type DualMonitorEvent =
| { type: 'TASK_SELECTED'; taskId: string }
| { type: 'TASK_DESELECTED' }
| { type: 'REFRESH' };
let channel: BroadcastChannel | null = null;
let detailWindow: Window | null = null;
function getChannel(): BroadcastChannel {
if (!channel) {
channel = new BroadcastChannel(CHANNEL_NAME);
}
return channel;
}
/** 상세 창이 열려 있는지 확인 */
export function isDetailWindowOpen(): boolean {
return !!detailWindow && !detailWindow.closed;
}
/** 상세 창 토글 — 열려 있으면 닫고, 닫혀 있으면 오른쪽 모니터에 열기 */
export function openDetailWindow(): Window | null {
if (isDetailWindowOpen()) {
detailWindow!.close();
detailWindow = null;
return null;
}
// 현재 창이 왼쪽 모니터에 있다고 가정하고
// 오른쪽 모니터의 시작 X 좌표 = 현재 창 X + 현재 화면 너비
const screenW = window.screen.width;
const screenH = window.screen.height;
const rightMonitorLeft = window.screenX + screenW;
detailWindow = window.open(
'/detail',
DETAIL_WINDOW_NAME,
`width=${screenW},height=${screenH},left=${rightMonitorLeft},top=0,resizable=yes,scrollbars=yes`,
);
return detailWindow;
}
/** 좌측 → 우측: 업무 선택 이벤트 전송 (창이 닫혀 있으면 열고 전송) */
export function sendTaskSelected(taskId: string): void {
if (!isDetailWindowOpen()) {
openDetailWindow();
// 창이 로드될 때까지 잠시 대기 후 전송
setTimeout(() => {
getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent);
}, 800);
return;
}
detailWindow!.focus();
getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent);
}
/** 좌측 → 우측: 업무 선택 해제 */
export function sendTaskDeselected(): void {
getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent);
}
/** 이벤트 수신 리스너 등록 */
export function onDualMonitorEvent(
handler: (event: DualMonitorEvent) => void,
): () => void {
const ch = getChannel();
const listener = (e: MessageEvent<DualMonitorEvent>) => handler(e.data);
ch.addEventListener('message', listener);
return () => ch.removeEventListener('message', listener);
}
/** 채널 종료 */
export function closeChannel(): void {
channel?.close();
channel = null;
}

23
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30, // 30초
retry: 1,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,4 @@
// TODO: 관리자 페이지 UI 구현 예정
export default function AdminPage() {
return <div> - </div>;
}

View File

@@ -0,0 +1,168 @@
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import { useTasks } from '../hooks/useTasks';
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
import { RoutinePanel } from '../components/dashboard/RoutinePanel';
import { useSocket } from '../contexts/SocketContext';
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
const QUARTER = '2026-Q2';
export default function DashboardPage() {
const [activeType, setActiveType] = useState('전체');
const [activeStatus, setActiveStatus] = useState('전체');
const [isBottomPanelOpen, setIsBottomPanelOpen] = useState(false);
const queryClient = useQueryClient();
const socket = useSocket();
const { data: tasks = [], isLoading } = useTasks({ quarter: QUARTER });
useEffect(() => {
if (!socket) return;
const refresh = () => queryClient.invalidateQueries({ queryKey: ['tasks'] });
socket.on('tasks:refresh', refresh);
socket.on('task:updated', refresh);
return () => { socket.off('tasks:refresh', refresh); socket.off('task:updated', refresh); };
}, [socket, queryClient]);
const byType = tasks.filter((t) => activeType === '전체' || t.taskType === activeType);
const stats = {
total: byType.length,
inProgress: byType.filter((t) => t.status === 'IN_PROGRESS').length,
review: byType.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED').length,
waiting: byType.filter((t) => t.status === 'TODO').length,
done: byType.filter((t) => t.status === 'DONE').length,
issues: byType.filter((t) => !!t.issueNote).length,
};
const filtered = byType.filter((t) => {
if (activeStatus === '전체') return true;
if (activeStatus === 'ISSUES') return !!t.issueNote;
if (activeStatus === 'REVIEW') return t.status === 'REVIEW' || t.status === 'CANCELLED';
return t.status === activeStatus;
});
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
const sec1Tasks = filtered.filter((t) => t.section === '인사관리');
const sec2Tasks = filtered.filter((t) => t.section === '학습성장');
const sec3Tasks = filtered.filter((t) => t.section === '운영지원');
const sec4Tasks = filtered.filter((t) => t.section === '전산관리');
const routineTasks = tasks.filter((t) => t.taskType === '상시업무');
const { data: colConfigs } = useQuery({
queryKey: ['columns', 'all'],
queryFn: async () => {
const results = await Promise.all(
SECTIONS.map((s) => apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => ({ key: s, ...r.data }))),
);
return results;
},
staleTime: 0,
});
const sectionOptions = SECTIONS.map((s) => ({
value: s,
label: colConfigs?.find((c) => c.key === s)?.title ?? s,
}));
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-slate-100">
<div className="text-3xl text-gray-400"> ...</div>
</div>
);
}
return (
<div className="relative flex flex-col h-screen bg-slate-100 overflow-hidden" style={{ fontSize: '18px' }}>
<DashboardHeader
quarter={QUARTER}
stats={stats}
activeType={activeType}
onTypeChange={(type) => { setActiveType(type); setActiveStatus('전체'); }}
activeStatus={activeStatus}
onStatusChange={setActiveStatus}
onOpenDetailWindow={openDetailWindow}
/>
<main className="relative flex-1 overflow-hidden min-h-0">
<div className="grid h-full grid-cols-4 overflow-hidden min-h-0">
<DepartmentColumn
title="인사관리"
titleEn="HR Management"
tasks={sec1Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #2a4a8a 0%, #3461b8 50%, #3d72d0 100%)' }}
storageKey="col_sec1"
section="인사관리"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
<DepartmentColumn
title="학습성장"
titleEn="Learning & Growth"
tasks={sec2Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #5b2d8a 0%, #7340b8 50%, #8a52d0 100%)' }}
storageKey="col_sec2"
section="학습성장"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
<DepartmentColumn
title="운영지원"
titleEn="Operations"
tasks={sec3Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #0d6080 0%, #0d7a9a 50%, #0e92b8 100%)' }}
storageKey="col_sec3"
section="운영지원"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
<DepartmentColumn
title="전산관리"
titleEn="IT Management"
tasks={sec4Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #0a6040 0%, #0d8050 50%, #10a060 100%)' }}
storageKey="col_sec4"
section="전산관리"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
</div>
{/* 하단 슬라이드 패널 */}
<button
type="button"
onClick={() => setIsBottomPanelOpen((v) => !v)}
className={`absolute left-1/2 z-40 -translate-x-1/2 rounded-t-2xl border border-orange-200 bg-orange-50 px-10 py-1.5 text-orange-600 shadow-md transition-all hover:bg-orange-100 ${
isBottomPanelOpen ? 'top-0' : 'bottom-0'
}`}
aria-label={isBottomPanelOpen ? '하단 정보 닫기' : '하단 정보 열기'}
>
<span className={`block text-2xl font-black leading-none transition-transform ${isBottomPanelOpen ? 'rotate-180' : ''}`}>
^
</span>
</button>
<section
className={`absolute inset-x-0 bottom-0 z-30 h-full rounded-t-3xl border-t border-gray-200 bg-white shadow-2xl transition-transform duration-300 ease-out overflow-hidden ${
isBottomPanelOpen ? 'translate-y-0' : 'translate-y-full'
}`}
>
<RoutinePanel tasks={routineTasks} />
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,468 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import { onDualMonitorEvent } from '../lib/dualMonitor';
import type { Task, Milestone, FileRecord } from '../types';
/* ─── 공통 유틸 ───────────────────────────────── */
const TAG_CONFIG: Record<string, { bg: string; text: string; border: string }> = {
Growth: { bg: '#EFF6FF', text: '#1D4ED8', border: '#BFDBFE' },
Policy: { bg: '#F5F3FF', text: '#6D28D9', border: '#DDD6FE' },
Performance: { bg: '#ECFDF5', text: '#065F46', border: '#A7F3D0' },
Culture: { bg: '#FFFBEB', text: '#92400E', border: '#FDE68A' },
Asset: { bg: '#ECFEFF', text: '#155E75', border: '#A5F3FC' },
Space: { bg: '#EEF2FF', text: '#3730A3', border: '#C7D2FE' },
Safety: { bg: '#FEF2F2', text: '#991B1B', border: '#FECACA' },
Environment: { bg: '#F7FEE7', text: '#3F6212', border: '#D9F99D' },
};
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
IN_PROGRESS: { bg: '#3B82F6', text: '#fff', label: '진행 중' },
REVIEW: { bg: '#F97316', text: '#fff', label: '보류' },
TODO: { bg: '#E5E7EB', text: '#374151', label: '대기' },
DONE: { bg: '#10B981', text: '#fff', label: '완료' },
CANCELLED: { bg: '#D1D5DB', text: '#6B7280', label: '취소' },
};
function fmtDate(iso: string | null | undefined) {
if (!iso) return '';
const d = new Date(iso);
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
}
function fileIcon(mime: string) {
if (mime.includes('pdf')) return '📄';
if (mime.includes('sheet') || mime.includes('excel') || mime.includes('csv')) return '📊';
if (mime.includes('word')) return '📝';
if (mime.includes('image')) return '🖼';
if (mime.includes('video')) return '🎬';
return '📎';
}
function fileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-2">
<span className="flex-1 h-px bg-gray-100" />
{children}
<span className="flex-1 h-px bg-gray-100" />
</h3>
);
}
/* ═══════════════════════════════════════════════
대기 화면
═══════════════════════════════════════════════ */
function WaitingScreen() {
return (
<div className="flex flex-col h-full items-center justify-center gap-6 bg-slate-50">
<div className="w-20 h-20 rounded-full bg-blue-100 flex items-center justify-center text-4xl animate-pulse"></div>
<div className="text-center">
<p className="text-2xl font-black text-gray-700"> </p>
<p className="text-base font-medium text-gray-400 mt-2">
<br /> .
</p>
</div>
</div>
);
}
/* ═══════════════════════════════════════════════
메인 상세 뷰 (탭 없이 한 페이지)
═══════════════════════════════════════════════ */
function DetailView({ task, files }: { task: Task; files: FileRecord[] }) {
const qc = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [previewFile, setPreviewFile] = useState<FileRecord | null>(null);
const [uploading, setUploading] = useState(false);
const [addingMs, setAddingMs] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDate, setNewDate] = useState('');
const [newDesc, setNewDesc] = useState('');
const tag = TAG_CONFIG[task.tag ?? ''] ?? { bg: '#F3F4F6', text: '#374151', border: '#E5E7EB' };
const status = STATUS_CONFIG[task.status] ?? STATUS_CONFIG.TODO;
const progress = task.progress ?? 0;
const progressColor = progress >= 70 ? '#10B981' : progress >= 40 ? '#3B82F6' : '#F97316';
const bullets = task.description?.split('\n').filter(Boolean) ?? [];
// 기간 타임라인
const start = task.startDate ? new Date(task.startDate) : null;
const end = task.dueDate ? new Date(task.dueDate) : null;
const now = new Date();
const totalDays = start && end ? Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000)) : null;
const elapsedDays = start ? Math.max(0, Math.ceil((now.getTime() - start.getTime()) / 86400000)) : null;
const timePercent = totalDays && elapsedDays !== null
? Math.min(100, Math.round((elapsedDays / totalDays) * 100))
: null;
// 마일스톤
const { data: milestones = [] } = useQuery<Milestone[]>({
queryKey: ['milestones', task.id],
queryFn: async () => (await apiClient.get(`/milestones/${task.id}`)).data,
});
const addMs = useMutation({
mutationFn: (body: object) => apiClient.post(`/milestones/${task.id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['milestones', task.id] });
setAddingMs(false); setNewTitle(''); setNewDate(''); setNewDesc('');
},
});
const toggleMs = useMutation({
mutationFn: ({ id, completed }: { id: string; completed: boolean }) =>
apiClient.patch(`/milestones/item/${id}`, { completed }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['milestones', task.id] }),
});
const deleteMs = useMutation({
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['milestones', task.id] }),
});
const deleteFile = useMutation({
mutationFn: (id: string) => apiClient.delete(`/files/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['task'] }),
});
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const form = new FormData();
form.append('file', file);
form.append('uploadedBy', 'system');
try {
await apiClient.post(`/files/upload/${task.id}`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
qc.invalidateQueries({ queryKey: ['task'] });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const msDone = milestones.filter((m) => m.completedAt).length;
const msTotal = milestones.length;
return (
<div className="flex-1 overflow-y-auto">
{/* ── 헤더 배너 ─────────────────────────────── */}
<div className="px-8 pt-7 pb-6"
style={{ background: 'linear-gradient(135deg, #1e3260 0%, #1e4fa0 60%, #2a6dd0 100%)' }}>
<div className="flex flex-wrap gap-2 mb-4">
{task.tag && (
<span className="text-sm font-black px-3 py-1 rounded-full border"
style={{ background: tag.bg, color: tag.text, borderColor: tag.border }}>
{task.tag}
</span>
)}
<span className="text-sm font-bold px-3 py-1 rounded-full"
style={{ background: status.bg, color: status.text }}>
{status.label}
</span>
{task.taskType && (
<span className="text-sm font-medium px-3 py-1 rounded-full bg-white/15 text-white/80">{task.taskType}</span>
)}
{task.section && (
<span className="text-sm font-medium px-3 py-1 rounded-full bg-white/10 text-blue-200 ml-auto">{task.section}</span>
)}
</div>
<h1 className="text-3xl font-black text-white leading-snug mb-3">{task.title}</h1>
{(task.startDate || task.dueDate) && (
<p className="text-blue-200 text-base font-medium">
{fmtDate(task.startDate)} ~ {fmtDate(task.dueDate)}
</p>
)}
</div>
<div className="px-8 py-6 space-y-8">
{/* ── 진행률 ───────────────────────────────── */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-gray-500"></span>
<span className="text-2xl font-black" style={{ color: progressColor }}>{progress}%</span>
</div>
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<div className="h-3 rounded-full transition-all duration-500"
style={{ width: `${progress}%`, background: progressColor }} />
</div>
</div>
{/* ── 기간 타임라인 ────────────────────────── */}
{start && end && timePercent !== null && (
<div className="bg-white border border-gray-100 rounded-2xl p-5 shadow-sm">
<div className="flex justify-between text-sm font-semibold text-gray-400 mb-3">
<span>{fmtDate(task.startDate)}</span>
<span className="font-black text-gray-600"> {totalDays}</span>
<span>{fmtDate(task.dueDate)}</span>
</div>
<div className="relative h-5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-5 rounded-full bg-gradient-to-r from-blue-400 to-blue-600 transition-all"
style={{ width: `${timePercent}%` }} />
<div className="absolute inset-0 flex items-center justify-center text-xs font-black text-white drop-shadow">
{timePercent}%
</div>
</div>
{now > end && (
<p className="mt-2 text-xs font-bold text-orange-500 text-center"> </p>
)}
</div>
)}
{/* ── 내용 ─────────────────────────────────── */}
{bullets.length > 0 && (
<div>
<SectionTitle></SectionTitle>
<ul className="space-y-2">
{bullets.map((b, i) => (
<li key={i} className="flex gap-3 bg-white rounded-xl px-5 py-3 shadow-sm border border-gray-100">
<span className="shrink-0 text-blue-300 mt-0.5"></span>
<span className="text-base text-gray-700">{b.replace(/^[•·\-]\s*/, '')}</span>
</li>
))}
</ul>
</div>
)}
{/* ── 이슈 ─────────────────────────────────── */}
{task.issueNote && (
<div className="bg-red-50 border border-red-200 rounded-xl px-5 py-4">
<p className="text-xs font-black text-red-400 mb-1"> </p>
<p className="text-base font-semibold text-red-700">{task.issueNote}</p>
</div>
)}
{/* ── 프로세스 단계 ─────────────────────────── */}
<div>
<SectionTitle> </SectionTitle>
{msTotal > 0 && (
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-2 bg-emerald-500 rounded-full transition-all"
style={{ width: `${Math.round((msDone / msTotal) * 100)}%` }} />
</div>
<span className="text-sm font-black text-gray-500 shrink-0">{msDone}/{msTotal} </span>
</div>
)}
<ul className="space-y-2 mb-3">
{milestones.map((m, idx) => (
<li key={m.id}
className={`flex gap-4 items-start bg-white rounded-2xl px-5 py-4 border shadow-sm transition-all ${
m.completedAt ? 'border-emerald-200 bg-emerald-50/40' : 'border-gray-100'
}`}
>
<button
onClick={() => toggleMs.mutate({ id: m.id, completed: !m.completedAt })}
className={`shrink-0 w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-black transition-all ${
m.completedAt
? 'bg-emerald-500 border-emerald-500 text-white'
: 'border-gray-300 text-gray-400 hover:border-blue-400'
}`}
>
{m.completedAt ? '✓' : idx + 1}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-base font-bold ${m.completedAt ? 'line-through text-gray-400' : 'text-gray-800'}`}>
{m.title}
</span>
{m.dueDate && (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
m.completedAt ? 'bg-emerald-100 text-emerald-600' :
new Date(m.dueDate) < now ? 'bg-red-100 text-red-600' : 'bg-blue-50 text-blue-500'
}`}>
{fmtDate(m.dueDate)}
</span>
)}
</div>
{m.description && <p className="mt-1 text-sm text-gray-500">{m.description}</p>}
{m.completedAt && <p className="mt-1 text-xs text-emerald-500 font-semibold">: {fmtDate(m.completedAt)}</p>}
</div>
<button
onClick={() => { if (window.confirm('삭제하시겠습니까?')) deleteMs.mutate(m.id); }}
className="shrink-0 text-gray-300 hover:text-red-400 transition-colors text-xl leading-none"
>×</button>
</li>
))}
</ul>
{addingMs ? (
<div className="bg-white border border-blue-200 rounded-2xl px-5 py-4 space-y-3 shadow-sm">
<input autoFocus placeholder="단계 제목 *" value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="w-full text-base font-semibold border-b border-gray-200 pb-2 focus:outline-none focus:border-blue-400" />
<input placeholder="설명 (선택)" value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="w-full text-sm text-gray-600 border-b border-gray-100 pb-2 focus:outline-none" />
<div className="flex items-center gap-3">
<input type="date" value={newDate} onChange={(e) => setNewDate(e.target.value)}
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-400" />
<div className="ml-auto flex gap-2">
<button onClick={() => setAddingMs(false)} className="text-sm text-gray-400 hover:text-gray-600 px-3 py-1.5"></button>
<button
onClick={() => { if (newTitle.trim()) addMs.mutate({ title: newTitle.trim(), description: newDesc || undefined, dueDate: newDate || undefined }); }}
className="text-sm font-bold bg-blue-500 text-white px-4 py-1.5 rounded-lg hover:bg-blue-600 transition-colors"
></button>
</div>
</div>
</div>
) : (
<button onClick={() => setAddingMs(true)}
className="w-full py-3 rounded-2xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-blue-300 hover:text-blue-400 transition-colors text-sm font-bold">
+
</button>
)}
</div>
{/* ── 첨부파일 ─────────────────────────────── */}
<div>
<SectionTitle></SectionTitle>
{/* 미리보기 뷰어 */}
{previewFile && (
<div className="mb-4 rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm font-bold text-gray-700 truncate">{previewFile.originalName}</span>
<div className="flex gap-3 shrink-0 ml-3">
<a href={`/api/files/${previewFile.id}/download`}
className="text-xs font-semibold text-blue-500 hover:underline"></a>
<button onClick={() => setPreviewFile(null)}
className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
</div>
<div style={{ height: '420px' }}>
{previewFile.mimetype.includes('image') ? (
<img src={`/api/files/${previewFile.id}/view`} alt={previewFile.originalName}
className="w-full h-full object-contain bg-gray-50 p-4" />
) : (
<iframe src={`/api/files/${previewFile.id}/view`} title={previewFile.originalName}
className="w-full h-full border-0" />
)}
</div>
</div>
)}
<div className="space-y-2 mb-3">
{files.length === 0 && !uploading && (
<p className="text-center text-gray-400 text-sm py-4"> </p>
)}
{files.map((f) => (
<div key={f.id}
className={`flex items-center gap-4 bg-white rounded-xl px-4 py-3 border shadow-sm hover:border-blue-200 transition-all cursor-pointer group ${
previewFile?.id === f.id ? 'border-blue-300 bg-blue-50/30' : 'border-gray-100'
}`}
onClick={() => setPreviewFile(previewFile?.id === f.id ? null : f)}
>
<span className="text-2xl shrink-0">{fileIcon(f.mimetype)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-gray-800 truncate">{f.originalName}</p>
<p className="text-xs text-gray-400">{fileSize(f.size)} · {fmtDate(f.createdAt)}</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<a href={`/api/files/${f.id}/download`} onClick={(e) => e.stopPropagation()}
className="text-xs font-semibold text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100">
</a>
<button
onClick={(e) => { e.stopPropagation(); if (window.confirm('삭제하시겠습니까?')) deleteFile.mutate(f.id); }}
className="text-xs font-semibold text-red-400 hover:text-red-600 px-2 py-1 rounded hover:bg-red-50">
</button>
</div>
</div>
))}
{uploading && (
<div className="flex items-center gap-3 bg-blue-50 rounded-xl px-4 py-3 border border-blue-200">
<span className="text-sm font-semibold text-blue-600 animate-pulse"> ...</span>
</div>
)}
</div>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleUpload} />
<button onClick={() => fileInputRef.current?.click()}
className="w-full py-3 rounded-2xl border-2 border-dashed border-gray-200 text-gray-400 hover:border-blue-300 hover:text-blue-400 transition-colors text-sm font-bold">
+
</button>
</div>
<div className="h-8" /> {/* 하단 여백 */}
</div>
</div>
);
}
/* ═══════════════════════════════════════════════
메인 페이지
═══════════════════════════════════════════════ */
export default function DetailPage() {
const [taskId, setTaskId] = useState<string | null>(null);
useEffect(() => {
const unsub = onDualMonitorEvent((evt) => {
if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId);
if (evt.type === 'TASK_DESELECTED') setTaskId(null);
});
return unsub;
}, []);
const { data: task, isLoading } = useQuery({
queryKey: ['task', taskId],
queryFn: async () => {
const { data } = await apiClient.get<Task & { files: FileRecord[] }>(`/tasks/${taskId}`);
return data;
},
enabled: !!taskId,
staleTime: 10_000,
});
const tag = task ? (TAG_CONFIG[task.tag ?? ''] ?? { bg: '#F3F4F6', text: '#374151', border: '#E5E7EB' }) : null;
const status = task ? (STATUS_CONFIG[task.status] ?? STATUS_CONFIG.TODO) : null;
return (
<div className="flex flex-col h-screen bg-slate-50 overflow-hidden" style={{ fontSize: '18px' }}>
{/* 최상단 바 */}
<div className="shrink-0 flex items-center gap-3 px-6 h-12"
style={{ background: 'linear-gradient(90deg, #1e3260 0%, #1e4fa0 100%)' }}>
<span className="text-white font-black text-base tracking-wide">EENE </span>
{task && tag && status && (
<>
<span className="w-px h-4 bg-white/20" />
<span className="text-white/80 text-sm font-bold truncate max-w-[300px]">{task.title}</span>
<div className="ml-auto flex gap-2 shrink-0">
{task.tag && (
<span className="text-xs font-black px-2 py-0.5 rounded-full border"
style={{ background: tag.bg, color: tag.text, borderColor: tag.border }}>
{task.tag}
</span>
)}
<span className="text-xs font-bold px-2 py-0.5 rounded-full"
style={{ background: status.bg, color: status.text }}>
{status.label}
</span>
</div>
</>
)}
</div>
{/* 본문 */}
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center text-gray-400 text-xl"> ...</div>
) : !task ? (
<WaitingScreen />
) : (
<DetailView task={task} files={(task as any).files ?? []} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
// TODO: 로그인 화면 UI 구현 예정
export default function LoginPage() {
return <div> ( )</div>;
}

View File

@@ -0,0 +1,10 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<h1>404 - </h1>
<Link to="/"> </Link>
</div>
);
}

23
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Routes, Route } from 'react-router-dom';
import DashboardPage from './pages/DashboardPage';
import DetailPage from './pages/DetailPage';
import AdminPage from './pages/AdminPage';
import NotFoundPage from './pages/NotFoundPage';
export function AppRouter() {
return (
<Routes>
{/* 좌측 모니터: 업무 목록 대시보드 */}
<Route path="/" element={<DashboardPage />} />
{/* 우측 모니터: 업무 상세 패널 */}
<Route path="/detail" element={<DetailPage />} />
<Route path="/detail/:taskId" element={<DetailPage />} />
{/* 관리자 전용 */}
<Route path="/admin" element={<AdminPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}

View File

@@ -0,0 +1,86 @@
export type Role = 'ADMIN' | 'MANAGER' | 'MEMBER';
export type TaskStatus = 'TODO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE' | 'CANCELLED';
export type Priority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
export interface User {
id: string;
email: string;
name: string;
role: Role;
department: string | null;
isActive: boolean;
createdAt: string;
}
export interface Task {
id: string;
title: string;
description: string | null;
status: TaskStatus;
priority: Priority;
quarter: string;
category: string | null;
section: string | null; // HR | 운영관리
tag: string | null; // Growth | Policy | Performance | Culture | Asset | Space | Safety | Environment
taskType: string | null; // 상시업무 | 프로젝트
progress: number; // 0-100
issueNote: string | null;
startDate: string | null;
dueDate: string | null;
creatorId: string;
assigneeId: string | null;
createdAt: string;
updatedAt: string;
assignee?: Pick<User, 'id' | 'name' | 'department'> | null;
creator?: Pick<User, 'id' | 'name'>;
_count?: { files: number; details: number };
}
export interface TaskDetail {
id: string;
taskId: string;
content: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface KpiMetric {
id: string;
taskId: string;
quarter: string;
target: number;
actual: number;
unit: string | null;
}
export interface FileRecord {
id: string;
taskId: string;
filename: string;
originalName: string;
mimetype: string;
size: number;
path: string;
uploadedBy: string;
createdAt: string;
}
export interface Milestone {
id: string;
taskId: string;
title: string;
description: string | null;
dueDate: string | null;
completedAt: string | null;
order: number;
createdAt: string;
updatedAt: string;
}
export interface TaskFull extends Task {
details: TaskDetail[];
kpiMetrics: KpiMetric[];
files: FileRecord[];
milestones: Milestone[];
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

27
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
host: true, // 0.0.0.0 — 같은 네트워크 팀원 접속 허용
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
});

24
render.yaml Normal file
View File

@@ -0,0 +1,24 @@
services:
- type: web
name: eene-dashboard-backend
runtime: node
rootDir: backend
buildCommand: npm install && npm run build && npx prisma generate
startCommand: npm start
envVars:
- key: DATABASE_URL
sync: false # Render 대시보드에서 직접 입력
- key: PORT
value: 4000
- key: FRONTEND_URL
sync: false # Vercel 배포 후 주소 입력
- key: JWT_SECRET
generateValue: true
- key: JWT_EXPIRES_IN
value: 7d
- key: UPLOAD_DIR
value: ./uploads
- key: MAX_FILE_SIZE_MB
value: 20
- key: NODE_ENV
value: production

0
uploads/.gitkeep Normal file
View File

45
서버시작.bat Normal file
View File

@@ -0,0 +1,45 @@
@echo off
chcp 65001 > nul
title EENE Dashboard Start
echo ================================
echo EENE Dashboard - Server Start
echo ================================
echo.
:: 1. PostgreSQL 서비스 확인 (직접 설치 시 자동 실행됨)
echo [1/3] Checking Database...
sc query postgresql-x64-16 | find "RUNNING" > nul 2>&1
if %errorlevel% neq 0 (
echo Starting PostgreSQL service...
net start postgresql-x64-16
)
echo Database is ready.
echo.
:: 2. 백엔드 서버
echo [2/3] Starting Backend Server...
start "EENE-Backend" cmd /k "cd /d "%~dp0backend" && npm run dev"
timeout /t 3 /nobreak > nul
echo Done.
echo.
:: 3. 프론트엔드 서버
echo [3/3] Starting Frontend Server...
start "EENE-Frontend" cmd /k "cd /d "%~dp0frontend" && npm run dev"
echo Done.
echo.
echo ================================
echo All servers are running!
echo.
echo [This PC]
echo Dashboard : http://localhost:3000
echo Detail : http://localhost:3000/detail
echo.
echo [Team Access]
echo Dashboard : http://172.16.8.248:3000
echo Detail : http://172.16.8.248:3000/detail
echo ================================
echo.
pause

18
서버종료.bat Normal file
View File

@@ -0,0 +1,18 @@
@echo off
chcp 65001 > nul
title EENE Dashboard Stop
echo ================================
echo EENE Dashboard - Server Stop
echo ================================
echo.
echo [1/1] Stopping Frontend / Backend...
taskkill /fi "WindowTitle eq EENE-Backend*" /f > nul 2>&1
taskkill /fi "WindowTitle eq EENE-Frontend*" /f > nul 2>&1
echo Done.
echo.
echo All servers stopped.
echo (PostgreSQL keeps running as Windows service)
pause

19
윈도우시작등록.bat Normal file
View File

@@ -0,0 +1,19 @@
@echo off
chcp 65001 > nul
title Auto Start Registration
set STARTUP=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup
set TARGET=%~dp0서버시작.bat
echo Registering EENE Dashboard for Windows startup...
echo.
powershell -Command "$ws = New-Object -ComObject WScript.Shell; $lnk = $ws.CreateShortcut('%STARTUP%\EENE-Dashboard.lnk'); $lnk.TargetPath = '%TARGET%'; $lnk.WorkingDirectory = '%~dp0'; $lnk.Description = 'EENE Dashboard Auto Start'; $lnk.Save()"
if %errorlevel% equ 0 (
echo [Done] Server will auto-start when Windows boots.
) else (
echo [Error] Please run as Administrator.
)
echo.
pause