Initial commit - EENE Dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
18
.env.example
Normal file
18
.env.example
Normal 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
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
data/
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
162
README.md
Normal file
162
README.md
Normal 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
3153
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
backend/package.json
Normal file
41
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
147
backend/prisma/migrations/20260528092950_init/migration.sql
Normal file
147
backend/prisma/migrations/20260528092950_init/migration.sql
Normal 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;
|
||||
@@ -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");
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
193
backend/prisma/schema.prisma
Normal file
193
backend/prisma/schema.prisma
Normal 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
145
backend/prisma/seed.ts
Normal 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
48
backend/src/app.ts
Normal 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
35
backend/src/index.ts
Normal 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
14
backend/src/lib/prisma.ts
Normal 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;
|
||||
}
|
||||
43
backend/src/middleware/auth.ts
Normal file
43
backend/src/middleware/auth.ts
Normal 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();
|
||||
}
|
||||
26
backend/src/middleware/errorHandler.ts
Normal file
26
backend/src/middleware/errorHandler.ts
Normal 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: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
26
backend/src/middleware/upload.ts
Normal file
26
backend/src/middleware/upload.ts
Normal 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 },
|
||||
});
|
||||
70
backend/src/routes/auth.ts
Normal file
70
backend/src/routes/auth.ts
Normal 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;
|
||||
67
backend/src/routes/columns.ts
Normal file
67
backend/src/routes/columns.ts
Normal 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;
|
||||
84
backend/src/routes/files.ts
Normal file
84
backend/src/routes/files.ts
Normal 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;
|
||||
20
backend/src/routes/index.ts
Normal file
20
backend/src/routes/index.ts
Normal 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
75
backend/src/routes/kpi.ts
Normal 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;
|
||||
75
backend/src/routes/milestones.ts
Normal file
75
backend/src/routes/milestones.ts
Normal 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
141
backend/src/routes/tasks.ts
Normal 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;
|
||||
78
backend/src/routes/users.ts
Normal file
78
backend/src/routes/users.ts
Normal 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
29
backend/src/socket.ts
Normal 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
19
backend/tsconfig.json
Normal 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
18
docker-compose.yml
Normal 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
3
frontend/.env.example
Normal 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
13
frontend/index.html
Normal 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
3073
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal 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
13
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/common/ContextMenu.tsx
Normal file
54
frontend/src/components/common/ContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/common/EditableSelect.tsx
Normal file
41
frontend/src/components/common/EditableSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/common/EditableText.tsx
Normal file
74
frontend/src/components/common/EditableText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
257
frontend/src/components/common/TaskModal.tsx
Normal file
257
frontend/src/components/common/TaskModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/dashboard/DashboardHeader.tsx
Normal file
113
frontend/src/components/dashboard/DashboardHeader.tsx
Normal 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&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>
|
||||
);
|
||||
}
|
||||
289
frontend/src/components/dashboard/DepartmentColumn.tsx
Normal file
289
frontend/src/components/dashboard/DepartmentColumn.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
frontend/src/components/dashboard/RoutinePanel.tsx
Normal file
140
frontend/src/components/dashboard/RoutinePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
282
frontend/src/components/dashboard/TaskCard.tsx
Normal file
282
frontend/src/components/dashboard/TaskCard.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
155
frontend/src/components/dashboard/TaskDetailPanel.tsx
Normal file
155
frontend/src/components/dashboard/TaskDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
frontend/src/contexts/AuthContext.tsx
Normal file
80
frontend/src/contexts/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
36
frontend/src/contexts/SocketContext.tsx
Normal file
36
frontend/src/contexts/SocketContext.tsx
Normal 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);
|
||||
}
|
||||
20
frontend/src/hooks/useTasks.ts
Normal file
20
frontend/src/hooks/useTasks.ts
Normal 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
1
frontend/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
19
frontend/src/lib/apiClient.ts
Normal file
19
frontend/src/lib/apiClient.ts
Normal 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),
|
||||
);
|
||||
84
frontend/src/lib/dualMonitor.ts
Normal file
84
frontend/src/lib/dualMonitor.ts
Normal 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
23
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
4
frontend/src/pages/AdminPage.tsx
Normal file
4
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
// TODO: 관리자 페이지 UI 구현 예정
|
||||
export default function AdminPage() {
|
||||
return <div>관리자 페이지 - 구현 예정</div>;
|
||||
}
|
||||
168
frontend/src/pages/DashboardPage.tsx
Normal file
168
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
468
frontend/src/pages/DetailPage.tsx
Normal file
468
frontend/src/pages/DetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
frontend/src/pages/LoginPage.tsx
Normal file
4
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
// TODO: 로그인 화면 UI 구현 예정
|
||||
export default function LoginPage() {
|
||||
return <div>로그인 페이지 (구현 예정)</div>;
|
||||
}
|
||||
10
frontend/src/pages/NotFoundPage.tsx
Normal file
10
frontend/src/pages/NotFoundPage.tsx
Normal 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
23
frontend/src/router.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/src/types/index.ts
Normal file
86
frontend/src/types/index.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
27
frontend/vite.config.ts
Normal 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
24
render.yaml
Normal 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
0
uploads/.gitkeep
Normal file
45
서버시작.bat
Normal file
45
서버시작.bat
Normal 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
18
서버종료.bat
Normal 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
19
윈도우시작등록.bat
Normal 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
|
||||
Reference in New Issue
Block a user