feat: team org panel, admin CRUD, local deploy tools, bidirectional data sync
Add TeamMember model and APIs, team status UI, /admin page, local server bats, and scripts to sync data between local PostgreSQL and Render. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
12
.env.docker.example
Normal file
12
.env.docker.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Docker Compose 사용 시 (선택)
|
||||
# 1. Windows PostgreSQL 서비스 중지 (5432 포트 충돌 방지)
|
||||
# 2. backend\.env 의 DATABASE_URL 을 아래로 변경
|
||||
# 3. docker compose up -d
|
||||
|
||||
DB_USER=eee_admin
|
||||
DB_PASSWORD=eee_password
|
||||
DB_NAME=eee_dashboard
|
||||
DB_PORT=5432
|
||||
|
||||
# backend\.env:
|
||||
# DATABASE_URL="postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard"
|
||||
@@ -3,6 +3,7 @@ DB_USER=eee_admin
|
||||
DB_PASSWORD=eee_password
|
||||
DB_NAME=eee_dashboard
|
||||
DB_PORT=5432
|
||||
# Docker compose 기본값과 동일 (data/postgres 에 영구 저장)
|
||||
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard"
|
||||
|
||||
# ─── Backend ─────────────────────────────────────────────────
|
||||
|
||||
42
README.md
42
README.md
@@ -67,6 +67,45 @@ npm run dev
|
||||
|
||||
---
|
||||
|
||||
## 로컬 전용 운영 (데이터 PC에 영구 저장)
|
||||
|
||||
Render 용량 제한 시 **이 PC만으로** 운영할 수 있습니다. 모든 데이터는 아래 폴더에 저장됩니다.
|
||||
|
||||
| 데이터 | 저장 위치 |
|
||||
|--------|-----------|
|
||||
| 업무·팀원·KPI 등 | `data/postgres/` (Docker PostgreSQL 볼륨) |
|
||||
| 업무 첨부 파일 | `uploads/` |
|
||||
| 팀원 프로필 사진 | `uploads/team/` |
|
||||
|
||||
### 빠른 시작
|
||||
|
||||
**Windows:** `서버시작.bat` 더블클릭 (DB 시작 → 스키마 동기화 → API+WEB 실행)
|
||||
**종료:** `서버종료.bat` (API/WEB만) · `서버종료.bat docker` (Docker DB까지 중지, 데이터는 유지)
|
||||
|
||||
수동 실행:
|
||||
|
||||
```bash
|
||||
npm run local:db # PostgreSQL 컨테이너 시작
|
||||
npm run local:setup # DB 스키마 동기화
|
||||
npm run local:api # 백엔드 :4000 (터미널 1)
|
||||
npm run local:web # 프론트 :3000 (터미널 2)
|
||||
```
|
||||
|
||||
### 접속 주소
|
||||
|
||||
| 화면 | 주소 |
|
||||
|------|------|
|
||||
| 대시보드 | `http://localhost:3000` |
|
||||
| 팀원 관리 | `http://localhost:3000/admin` |
|
||||
| API | `http://localhost:4000/api` |
|
||||
|
||||
사설망 IP(`172.x`, `192.168.x`)로 접속하면 **자동으로 같은 IP의 :4000 백엔드**에 연결됩니다. (Render 서버 불필요)
|
||||
|
||||
> `backend/.env` 의 `DATABASE_URL` 이 Docker 계정과 맞아야 합니다.
|
||||
> 기본: `postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard`
|
||||
|
||||
---
|
||||
|
||||
## 서버(이 PC) IP 주소 확인
|
||||
|
||||
```powershell
|
||||
@@ -160,3 +199,6 @@ D:\EENE_Dashboard\
|
||||
| DELETE | /api/files/:id | 파일 삭제 |
|
||||
| GET | /api/kpi | KPI 조회 |
|
||||
| POST | /api/kpi | KPI 등록 |
|
||||
| GET | /api/team-members | 팀원 목록 |
|
||||
| POST | /api/team-members | 팀원 등록 |
|
||||
| POST | /api/team-members/photo | 팀원 사진 업로드 (로컬 저장) |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "EENE 인재성장팀 대시보드 - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "npm run db:sync && tsx watch src/index.ts",
|
||||
"db:sync": "prisma migrate deploy || prisma db push",
|
||||
"build": "prisma generate && tsc",
|
||||
"start": "npm run db:sync && node dist/index.js",
|
||||
@@ -12,7 +12,9 @@
|
||||
"db:generate": "prisma generate",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:import-hr": "tsx scripts/import-hr-data.ts"
|
||||
"db:import-hr": "tsx scripts/import-hr-data.ts",
|
||||
"db:sync-remote": "tsx scripts/sync-from-remote.ts",
|
||||
"db:push-remote": "tsx scripts/sync-to-remote.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.0.0",
|
||||
|
||||
@@ -29,6 +29,29 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// ─── 팀 인원 (조직도 마스터) ─────────────────────────────────
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
rank String? // 직급 (수석연구원, 선임연구원 등)
|
||||
role String? // 직책 (팀장, 셀장, 팀원 등)
|
||||
cell String? // 셀 (HR, 총무, 리더 — 리더/빈값이면 상단 팀장 영역)
|
||||
contact String?
|
||||
photoUrl String?
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
pmTasks Task[] @relation("PmTasks")
|
||||
taskAssignees TaskAssignee[]
|
||||
|
||||
@@index([cell])
|
||||
@@index([isActive])
|
||||
@@map("team_members")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
MANAGER
|
||||
@@ -60,11 +83,14 @@ model Task {
|
||||
keywords String?
|
||||
creatorId String
|
||||
assigneeId String?
|
||||
pmMemberId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
creator User @relation("CreatedTasks", fields: [creatorId], references: [id])
|
||||
assignee User? @relation("AssignedTasks", fields: [assigneeId], references: [id])
|
||||
pmMember TeamMember? @relation("PmTasks", fields: [pmMemberId], references: [id])
|
||||
taskAssignees TaskAssignee[]
|
||||
details TaskDetail[]
|
||||
kpiMetrics KpiMetric[]
|
||||
files File[]
|
||||
@@ -73,10 +99,23 @@ model Task {
|
||||
@@index([quarter])
|
||||
@@index([status])
|
||||
@@index([assigneeId])
|
||||
@@index([pmMemberId])
|
||||
@@index([section])
|
||||
@@map("tasks")
|
||||
}
|
||||
|
||||
model TaskAssignee {
|
||||
taskId String
|
||||
memberId String
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
member TeamMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([taskId, memberId])
|
||||
@@index([memberId])
|
||||
@@map("task_assignees")
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
TODO
|
||||
IN_PROGRESS
|
||||
|
||||
415
backend/scripts/sync-from-remote.ts
Normal file
415
backend/scripts/sync-from-remote.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 배포 서버(Render) 데이터 → 로컬 PostgreSQL 복사
|
||||
* 사용: npm run db:sync-remote
|
||||
* 환경변수 SOURCE_API_URL 로 원본 변경 가능
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { PrismaClient, type Priority, type TaskStatus } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const SOURCE = (process.env.SOURCE_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
|
||||
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'];
|
||||
|
||||
type RemoteUser = { id: string; name: string; department?: string | null };
|
||||
type RemoteTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: TaskStatus;
|
||||
priority: Priority;
|
||||
quarter: string;
|
||||
category?: string | null;
|
||||
section?: string | null;
|
||||
tag?: string | null;
|
||||
taskType?: string | null;
|
||||
progress: number;
|
||||
issueNote?: string | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
showDate: boolean;
|
||||
showDescription: boolean;
|
||||
showStatus: boolean;
|
||||
showIssue: boolean;
|
||||
showProgress: boolean;
|
||||
keywords?: string | null;
|
||||
creatorId: string;
|
||||
assigneeId?: string | null;
|
||||
pmMemberId?: string | null;
|
||||
creator?: RemoteUser;
|
||||
assignee?: RemoteUser | null;
|
||||
assigneeMembers?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
cell?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
}>;
|
||||
pmMember?: {
|
||||
id: string;
|
||||
name: string;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
cell?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
} | null;
|
||||
milestones?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
progress: number;
|
||||
links?: string | null;
|
||||
completedAt?: string | null;
|
||||
order: number;
|
||||
}>;
|
||||
details?: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
authorName?: string | null;
|
||||
milestoneId?: string | null;
|
||||
updatedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author?: RemoteUser;
|
||||
}>;
|
||||
kpiMetrics?: Array<{
|
||||
id: string;
|
||||
quarter: string;
|
||||
target: number;
|
||||
actual: number;
|
||||
unit?: string | null;
|
||||
}>;
|
||||
files?: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
displayName?: string | null;
|
||||
sortOrder: number;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
path: string;
|
||||
milestoneId?: string | null;
|
||||
uploadedBy: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${SOURCE}${path}`);
|
||||
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function ensureUser(remote?: RemoteUser | null, fallbackEmail?: string): Promise<string> {
|
||||
const pw = await bcrypt.hash('imported!', 10);
|
||||
const email = fallbackEmail || `${(remote?.name || 'user').replace(/\s/g, '')}@import.local`;
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { name: remote?.name || email },
|
||||
create: {
|
||||
email,
|
||||
password: pw,
|
||||
name: remote?.name || '사용자',
|
||||
role: 'MEMBER',
|
||||
department: remote?.department || 'EENE',
|
||||
},
|
||||
});
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function ensureTeamMember(data: {
|
||||
name: string;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
cell?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
}): Promise<string> {
|
||||
const existing = await prisma.teamMember.findFirst({
|
||||
where: { name: data.name, cell: data.cell ?? null },
|
||||
});
|
||||
if (existing) {
|
||||
return prisma.teamMember
|
||||
.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
rank: data.rank ?? existing.rank,
|
||||
role: data.role ?? existing.role,
|
||||
contact: data.contact ?? existing.contact,
|
||||
photoUrl: data.photoUrl ?? existing.photoUrl,
|
||||
sortOrder: data.sortOrder ?? existing.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
.then((m) => m.id);
|
||||
}
|
||||
const created = await prisma.teamMember.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
rank: data.rank ?? null,
|
||||
role: data.role ?? null,
|
||||
cell: data.cell ?? null,
|
||||
contact: data.contact ?? null,
|
||||
photoUrl: data.photoUrl ?? null,
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function clearLocalData() {
|
||||
await prisma.file.deleteMany({});
|
||||
await prisma.taskDetail.deleteMany({});
|
||||
await prisma.taskAssignee.deleteMany({});
|
||||
await prisma.milestone.deleteMany({});
|
||||
await prisma.kpiMetric.deleteMany({});
|
||||
await prisma.task.deleteMany({});
|
||||
}
|
||||
|
||||
async function syncColumnConfigs() {
|
||||
for (const key of SECTIONS) {
|
||||
try {
|
||||
const config = await fetchJson<{
|
||||
key: string;
|
||||
title: string;
|
||||
titleEn?: string | null;
|
||||
subtitle?: string | null;
|
||||
cardOrder?: string | null;
|
||||
}>(`/api/columns/${encodeURIComponent(key)}`);
|
||||
|
||||
await prisma.columnConfig.upsert({
|
||||
where: { key },
|
||||
create: {
|
||||
key,
|
||||
title: config.title,
|
||||
titleEn: config.titleEn ?? '',
|
||||
subtitle: config.subtitle ?? '',
|
||||
cardOrder: config.cardOrder ?? null,
|
||||
},
|
||||
update: {
|
||||
title: config.title,
|
||||
titleEn: config.titleEn ?? '',
|
||||
subtitle: config.subtitle ?? '',
|
||||
cardOrder: config.cardOrder ?? null,
|
||||
},
|
||||
});
|
||||
console.log(` ✓ column: ${key}`);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ column skip ${key}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTeamMembersFromTasks(tasks: RemoteTask[]) {
|
||||
const seen = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
const members = [
|
||||
...(task.assigneeMembers ?? []),
|
||||
...(task.pmMember ? [task.pmMember] : []),
|
||||
];
|
||||
for (const m of members) {
|
||||
const key = `${m.name}|${m.cell ?? ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
await ensureTeamMember(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`📡 Source: ${SOURCE}`);
|
||||
console.log('📥 Fetching remote tasks...');
|
||||
|
||||
const list = await fetchJson<RemoteTask[]>('/api/tasks');
|
||||
console.log(` Found ${list.length} tasks`);
|
||||
|
||||
const fullTasks: RemoteTask[] = [];
|
||||
for (const item of list) {
|
||||
const full = await fetchJson<RemoteTask>(`/api/tasks/${item.id}`);
|
||||
fullTasks.push(full);
|
||||
}
|
||||
|
||||
console.log('🗑️ Clearing local task data...');
|
||||
await clearLocalData();
|
||||
|
||||
const adminId = await ensureUser({ id: '', name: '관리자' }, 'admin@eene.com');
|
||||
const userMap = new Map<string, string>();
|
||||
userMap.set('admin', adminId);
|
||||
|
||||
console.log('👥 Syncing team members...');
|
||||
try {
|
||||
const remoteMembers = await fetchJson<
|
||||
Array<{
|
||||
name: string;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
cell?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
}>
|
||||
>('/api/team-members');
|
||||
for (const m of remoteMembers) await ensureTeamMember(m);
|
||||
console.log(` ${remoteMembers.length} team members from API`);
|
||||
} catch {
|
||||
await syncTeamMembersFromTasks(fullTasks);
|
||||
console.log(' team members inferred from tasks');
|
||||
}
|
||||
|
||||
console.log('📋 Importing tasks...');
|
||||
for (const remote of fullTasks) {
|
||||
let creatorId = userMap.get(remote.creatorId);
|
||||
if (!creatorId) {
|
||||
creatorId = await ensureUser(remote.creator, `creator-${remote.creatorId}@import.local`);
|
||||
userMap.set(remote.creatorId, creatorId);
|
||||
}
|
||||
|
||||
let assigneeId: string | null = null;
|
||||
if (remote.assignee) {
|
||||
let mapped = userMap.get(remote.assignee.id);
|
||||
if (!mapped) {
|
||||
mapped = await ensureUser(remote.assignee, `assignee-${remote.assignee.id}@import.local`);
|
||||
userMap.set(remote.assignee.id, mapped);
|
||||
}
|
||||
assigneeId = mapped;
|
||||
}
|
||||
|
||||
let pmMemberId: string | null = null;
|
||||
if (remote.pmMember) {
|
||||
pmMemberId = await ensureTeamMember(remote.pmMember);
|
||||
}
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title: remote.title,
|
||||
description: remote.description ?? null,
|
||||
status: remote.status,
|
||||
priority: remote.priority,
|
||||
quarter: remote.quarter,
|
||||
category: remote.category ?? null,
|
||||
section: remote.section ?? null,
|
||||
tag: remote.tag ?? null,
|
||||
taskType: remote.taskType ?? null,
|
||||
progress: remote.progress ?? 0,
|
||||
issueNote: remote.issueNote ?? null,
|
||||
startDate: remote.startDate ? new Date(remote.startDate) : null,
|
||||
dueDate: remote.dueDate ? new Date(remote.dueDate) : null,
|
||||
showDate: remote.showDate,
|
||||
showDescription: remote.showDescription,
|
||||
showStatus: remote.showStatus,
|
||||
showIssue: remote.showIssue,
|
||||
showProgress: remote.showProgress,
|
||||
keywords: remote.keywords ?? null,
|
||||
creatorId,
|
||||
assigneeId,
|
||||
pmMemberId,
|
||||
},
|
||||
});
|
||||
|
||||
const milestoneIdMap = new Map<string, string>();
|
||||
for (const ms of remote.milestones ?? []) {
|
||||
const created = await prisma.milestone.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
title: ms.title,
|
||||
description: ms.description ?? null,
|
||||
startDate: ms.startDate ? new Date(ms.startDate) : null,
|
||||
dueDate: ms.dueDate ? new Date(ms.dueDate) : null,
|
||||
progress: ms.progress ?? 0,
|
||||
links: ms.links ?? null,
|
||||
completedAt: ms.completedAt ? new Date(ms.completedAt) : null,
|
||||
order: ms.order ?? 0,
|
||||
},
|
||||
});
|
||||
milestoneIdMap.set(ms.id, created.id);
|
||||
}
|
||||
|
||||
for (const d of remote.details ?? []) {
|
||||
let authorId = userMap.get(d.updatedBy);
|
||||
if (!authorId) {
|
||||
authorId = await ensureUser(d.author, `author-${d.updatedBy}@import.local`);
|
||||
userMap.set(d.updatedBy, authorId);
|
||||
}
|
||||
await prisma.taskDetail.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null,
|
||||
content: d.content,
|
||||
authorName: d.authorName ?? null,
|
||||
updatedBy: authorId,
|
||||
createdAt: new Date(d.createdAt),
|
||||
updatedAt: new Date(d.updatedAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const k of remote.kpiMetrics ?? []) {
|
||||
await prisma.kpiMetric.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
quarter: k.quarter,
|
||||
target: k.target,
|
||||
actual: k.actual,
|
||||
unit: k.unit ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const assigneeMemberIds: string[] = [];
|
||||
for (const m of remote.assigneeMembers ?? []) {
|
||||
assigneeMemberIds.push(await ensureTeamMember(m));
|
||||
}
|
||||
if (assigneeMemberIds.length > 0) {
|
||||
await prisma.taskAssignee.createMany({
|
||||
data: [...new Set(assigneeMemberIds)].map((memberId) => ({ taskId: task.id, memberId })),
|
||||
});
|
||||
}
|
||||
|
||||
for (const f of remote.files ?? []) {
|
||||
let uploaderId = userMap.get(f.uploadedBy);
|
||||
if (!uploaderId) {
|
||||
uploaderId = creatorId;
|
||||
userMap.set(f.uploadedBy, uploaderId);
|
||||
}
|
||||
await prisma.file.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
milestoneId: f.milestoneId ? milestoneIdMap.get(f.milestoneId) ?? null : null,
|
||||
filename: f.filename,
|
||||
originalName: f.originalName,
|
||||
displayName: f.displayName ?? null,
|
||||
sortOrder: f.sortOrder ?? 0,
|
||||
mimetype: f.mimetype,
|
||||
size: f.size,
|
||||
path: f.path,
|
||||
uploadedBy: uploaderId,
|
||||
createdAt: new Date(f.createdAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✓ ${remote.title}`);
|
||||
}
|
||||
|
||||
console.log('📐 Syncing column order...');
|
||||
await syncColumnConfigs();
|
||||
|
||||
const count = await prisma.task.count();
|
||||
console.log(`\n✅ Done! Local DB now has ${count} tasks (from ${SOURCE})`);
|
||||
console.log(' Refresh http://localhost:3000');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('❌ Sync failed:', err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
245
backend/scripts/sync-to-remote.ts
Normal file
245
backend/scripts/sync-to-remote.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 로컬 PostgreSQL → 배포 서버(Render API) 데이터 업로드
|
||||
* 사용: npm run db:push-remote
|
||||
* 환경변수 TARGET_API_URL 로 대상 변경 가능
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
|
||||
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'];
|
||||
|
||||
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${TARGET}${path}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${path} → ${res.status} ${text}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function memberKey(name: string, cell: string | null) {
|
||||
return `${name}|${cell ?? ''}`;
|
||||
}
|
||||
|
||||
async function ensureRemoteApiReady() {
|
||||
const res = await fetch(`${TARGET}/api/team-members`);
|
||||
if (res.status === 404) {
|
||||
throw new Error(
|
||||
'배포 서버에 team-members API가 없습니다. 코드 배포(Render) 완료 후 다시 실행하세요.',
|
||||
);
|
||||
}
|
||||
if (!res.ok) throw new Error(`team-members check failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async function syncTeamMembers(): Promise<Map<string, string>> {
|
||||
const locals = await prisma.teamMember.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
type RemoteMember = {
|
||||
id: string;
|
||||
name: string;
|
||||
cell: string | null;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
let remotes: RemoteMember[] = [];
|
||||
try {
|
||||
remotes = await api<RemoteMember[]>('GET', '/api/team-members?all=1');
|
||||
} catch {
|
||||
remotes = await api<RemoteMember[]>('GET', '/api/team-members');
|
||||
}
|
||||
|
||||
const remoteByKey = new Map(remotes.map((m) => [memberKey(m.name, m.cell), m]));
|
||||
const idMap = new Map<string, string>();
|
||||
|
||||
for (const local of locals) {
|
||||
const key = memberKey(local.name, local.cell);
|
||||
const payload = {
|
||||
name: local.name,
|
||||
rank: local.rank,
|
||||
role: local.role,
|
||||
cell: local.cell,
|
||||
contact: local.contact,
|
||||
photoUrl: local.photoUrl,
|
||||
sortOrder: local.sortOrder,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const existing = remoteByKey.get(key);
|
||||
if (existing) {
|
||||
await api('PATCH', `/api/team-members/${existing.id}`, payload);
|
||||
idMap.set(local.id, existing.id);
|
||||
console.log(` ✓ team ${local.name} (updated)`);
|
||||
} else {
|
||||
const created = await api<RemoteMember>('POST', '/api/team-members', payload);
|
||||
idMap.set(local.id, created.id);
|
||||
remoteByKey.set(key, created);
|
||||
console.log(` ✓ team ${local.name} (created)`);
|
||||
}
|
||||
}
|
||||
|
||||
return idMap;
|
||||
}
|
||||
|
||||
async function clearRemoteTasks() {
|
||||
const remoteTasks = await api<Array<{ id: string }>>('GET', '/api/tasks');
|
||||
for (const t of remoteTasks) {
|
||||
await api('DELETE', `/api/tasks/${t.id}`);
|
||||
}
|
||||
console.log(` removed ${remoteTasks.length} remote tasks`);
|
||||
}
|
||||
|
||||
async function syncTasks(memberIdMap: Map<string, string>) {
|
||||
const tasks = await prisma.task.findMany({
|
||||
include: {
|
||||
milestones: { orderBy: { order: 'asc' } },
|
||||
details: { orderBy: { createdAt: 'asc' } },
|
||||
kpiMetrics: true,
|
||||
taskAssignees: true,
|
||||
pmMember: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
const assigneeMemberIds = task.taskAssignees
|
||||
.map((ta) => memberIdMap.get(ta.memberId))
|
||||
.filter((id): id is string => Boolean(id));
|
||||
|
||||
const pmMemberId = task.pmMemberId ? memberIdMap.get(task.pmMemberId) ?? null : null;
|
||||
|
||||
const created = await api<{ id: string }>('POST', '/api/tasks', {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
quarter: task.quarter,
|
||||
category: task.category,
|
||||
section: task.section,
|
||||
tag: task.tag,
|
||||
taskType: task.taskType,
|
||||
progress: task.progress,
|
||||
issueNote: task.issueNote,
|
||||
startDate: task.startDate?.toISOString() ?? null,
|
||||
dueDate: task.dueDate?.toISOString() ?? null,
|
||||
showDate: task.showDate,
|
||||
showDescription: task.showDescription,
|
||||
showStatus: task.showStatus,
|
||||
showIssue: task.showIssue,
|
||||
showProgress: task.showProgress,
|
||||
keywords: task.keywords,
|
||||
pmMemberId,
|
||||
assigneeMemberIds,
|
||||
});
|
||||
|
||||
const milestoneIdMap = new Map<string, string>();
|
||||
for (const ms of task.milestones) {
|
||||
const remoteMs = await api<{ id: string }>('POST', `/api/milestones/${created.id}`, {
|
||||
title: ms.title,
|
||||
description: ms.description,
|
||||
startDate: ms.startDate?.toISOString() ?? null,
|
||||
dueDate: ms.dueDate?.toISOString() ?? null,
|
||||
progress: ms.progress,
|
||||
links: ms.links,
|
||||
});
|
||||
milestoneIdMap.set(ms.id, remoteMs.id);
|
||||
}
|
||||
|
||||
for (const d of task.details) {
|
||||
await api('POST', `/api/details/${created.id}`, {
|
||||
content: d.content,
|
||||
authorName: d.authorName,
|
||||
milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const k of task.kpiMetrics) {
|
||||
await api('POST', '/api/kpi', {
|
||||
taskId: created.id,
|
||||
quarter: k.quarter,
|
||||
target: k.target,
|
||||
actual: k.actual,
|
||||
unit: k.unit,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✓ task ${task.title}`);
|
||||
}
|
||||
|
||||
return tasks.length;
|
||||
}
|
||||
|
||||
async function syncColumnConfigs() {
|
||||
const configs = await prisma.columnConfig.findMany();
|
||||
for (const config of configs) {
|
||||
await api('PATCH', `/api/columns/${encodeURIComponent(config.key)}`, {
|
||||
title: config.title,
|
||||
titleEn: config.titleEn,
|
||||
subtitle: config.subtitle,
|
||||
cardOrder: config.cardOrder,
|
||||
});
|
||||
console.log(` ✓ column ${config.key}`);
|
||||
}
|
||||
|
||||
for (const key of SECTIONS) {
|
||||
if (!configs.some((c) => c.key === key)) {
|
||||
const local = await prisma.columnConfig.findUnique({ where: { key } });
|
||||
if (local) continue;
|
||||
try {
|
||||
await api('GET', `/api/columns/${encodeURIComponent(key)}`);
|
||||
} catch {
|
||||
/* ensure exists on remote */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`📤 Target: ${TARGET}`);
|
||||
console.log('🔍 Checking remote API...');
|
||||
await ensureRemoteApiReady();
|
||||
|
||||
const localTasks = await prisma.task.count();
|
||||
const localMembers = await prisma.teamMember.count({ where: { isActive: true } });
|
||||
console.log(` Local: ${localTasks} tasks, ${localMembers} team members`);
|
||||
|
||||
if (localTasks === 0) {
|
||||
throw new Error('로컬 DB에 업무가 없습니다. 먼저 데이터가져오기.bat 또는 작업을 진행하세요.');
|
||||
}
|
||||
|
||||
console.log('👥 Uploading team members...');
|
||||
const memberIdMap = await syncTeamMembers();
|
||||
|
||||
console.log('🗑️ Clearing remote tasks...');
|
||||
await clearRemoteTasks();
|
||||
|
||||
console.log('📋 Uploading tasks...');
|
||||
const count = await syncTasks(memberIdMap);
|
||||
|
||||
console.log('📐 Uploading column order...');
|
||||
await syncColumnConfigs();
|
||||
|
||||
const remoteTasks = await api<unknown[]>('GET', '/api/tasks');
|
||||
console.log(`\n✅ Done! Remote now has ${remoteTasks.length} tasks`);
|
||||
console.log(` Site: https://eene-dashboard.vercel.app/`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('❌ Push failed:', err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -11,6 +11,8 @@ const app = express();
|
||||
app.use(helmet());
|
||||
const allowedOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://172.16.8.248:3000',
|
||||
'https://eene-dashboard.vercel.app',
|
||||
process.env.FRONTEND_URL,
|
||||
@@ -19,6 +21,11 @@ const allowedOrigins = [
|
||||
function isAllowedOrigin(origin: string): boolean {
|
||||
if (allowedOrigins.includes(origin)) return true;
|
||||
if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true;
|
||||
// 로컬·사설망 프론트 (용량 절약용 로컬 서버)
|
||||
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return true;
|
||||
if (/^http:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||
if (/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||
if (/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Server } from 'socket.io';
|
||||
import app from './app';
|
||||
import { setupSocketHandlers } from './socket';
|
||||
import { prisma } from './lib/prisma';
|
||||
import { ensureLocalDirs } from './lib/ensureLocalDirs';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 4000;
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
@@ -12,7 +13,7 @@ const httpServer = createServer(app);
|
||||
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: FRONTEND_URL,
|
||||
origin: (_origin, callback) => callback(null, true),
|
||||
credentials: true,
|
||||
},
|
||||
});
|
||||
@@ -20,8 +21,9 @@ const io = new Server(httpServer, {
|
||||
setupSocketHandlers(io);
|
||||
|
||||
async function main() {
|
||||
ensureLocalDirs();
|
||||
await prisma.$connect();
|
||||
console.log('✅ Database connected');
|
||||
console.log('✅ Database connected (PostgreSQL — 로컬 data/postgres 또는 DATABASE_URL)');
|
||||
|
||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);
|
||||
|
||||
22
backend/src/lib/ensureLocalDirs.ts
Normal file
22
backend/src/lib/ensureLocalDirs.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/** 로컬 uploads·팀 사진 폴더 생성 (데이터 영구 저장) */
|
||||
export function ensureLocalDirs() {
|
||||
const uploadDir = path.resolve(process.env.UPLOAD_DIR || '../uploads');
|
||||
const teamDir = path.join(uploadDir, 'team');
|
||||
const dataPostgresHint = path.resolve('../data/postgres');
|
||||
|
||||
for (const dir of [uploadDir, teamDir]) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`📁 Created: ${dir}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dataPostgresHint)) {
|
||||
console.log(
|
||||
'💡 PostgreSQL 로컬 저장: 프로젝트 루트에서 docker compose up -d 실행 시 data/postgres 에 DB가 보존됩니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
76
backend/src/lib/taskQuery.ts
Normal file
76
backend/src/lib/taskQuery.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { prisma } from './prisma';
|
||||
|
||||
export const teamMemberSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
rank: true,
|
||||
role: true,
|
||||
cell: true,
|
||||
contact: true,
|
||||
photoUrl: true,
|
||||
sortOrder: true,
|
||||
} as const;
|
||||
|
||||
export const taskInclude = {
|
||||
assignee: { select: { id: true, name: true, department: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
pmMember: { select: teamMemberSelect },
|
||||
taskAssignees: {
|
||||
include: { member: { select: teamMemberSelect } },
|
||||
},
|
||||
_count: { select: { files: true, details: true } },
|
||||
} as const;
|
||||
|
||||
export const taskDetailInclude = {
|
||||
assignee: { select: { id: true, name: true, department: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
pmMember: { select: teamMemberSelect },
|
||||
taskAssignees: {
|
||||
include: { member: { select: teamMemberSelect } },
|
||||
},
|
||||
details: {
|
||||
orderBy: { createdAt: 'desc' as const },
|
||||
include: { author: { select: { id: true, name: true } } },
|
||||
},
|
||||
kpiMetrics: true,
|
||||
files: true,
|
||||
milestones: { orderBy: { order: 'asc' as const } },
|
||||
};
|
||||
|
||||
export function formatTask<T extends Record<string, unknown>>(task: T) {
|
||||
const { taskAssignees, ...rest } = task as T & {
|
||||
taskAssignees?: Array<{ member: unknown }>;
|
||||
};
|
||||
const assigneeMembers = (taskAssignees ?? []).map((ta) => ta.member);
|
||||
return { ...rest, assigneeMembers };
|
||||
}
|
||||
|
||||
export async function syncTaskMembers(
|
||||
taskId: string,
|
||||
pmMemberId: string | null | undefined,
|
||||
assigneeMemberIds: string[] | undefined,
|
||||
) {
|
||||
if (pmMemberId !== undefined) {
|
||||
await prisma.task.update({
|
||||
where: { id: taskId },
|
||||
data: { pmMemberId: pmMemberId || null },
|
||||
});
|
||||
}
|
||||
|
||||
if (assigneeMemberIds !== undefined) {
|
||||
await prisma.taskAssignee.deleteMany({ where: { taskId } });
|
||||
const ids = [...new Set(assigneeMemberIds.filter(Boolean))];
|
||||
if (ids.length > 0) {
|
||||
await prisma.taskAssignee.createMany({
|
||||
data: ids.map((memberId) => ({ taskId, memberId })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMemberIds(body: Record<string, unknown>): string[] | undefined {
|
||||
if (body.assigneeMemberIds === undefined) return undefined;
|
||||
const raw = body.assigneeMemberIds;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(String).filter(Boolean);
|
||||
}
|
||||
33
backend/src/middleware/uploadTeamPhoto.ts
Normal file
33
backend/src/middleware/uploadTeamPhoto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import fs from 'fs';
|
||||
|
||||
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
|
||||
const TEAM_DIR = path.join(UPLOAD_DIR, 'team');
|
||||
|
||||
if (!fs.existsSync(TEAM_DIR)) {
|
||||
fs.mkdirSync(TEAM_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination(_req, _file, cb) {
|
||||
cb(null, TEAM_DIR);
|
||||
},
|
||||
filename(_req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
cb(null, `${uuidv4()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadTeamPhoto = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter(_req, file, cb) {
|
||||
if (/^image\/(jpeg|jpg|png|gif|webp)$/i.test(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('JPEG, PNG, GIF, WebP 이미지만 업로드할 수 있습니다.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -7,11 +7,13 @@ import kpiRoutes from './kpi';
|
||||
import columnRoutes from './columns';
|
||||
import milestoneRoutes from './milestones';
|
||||
import detailRoutes from './details';
|
||||
import teamMemberRoutes from './teamMembers';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/tasks', taskRoutes);
|
||||
router.use('/team-members', teamMemberRoutes);
|
||||
router.use('/users', userRoutes);
|
||||
router.use('/files', fileRoutes);
|
||||
router.use('/kpi', kpiRoutes);
|
||||
|
||||
@@ -2,6 +2,13 @@ import { Router } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { resolveCreatorId } from '../lib/resolveUser';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import {
|
||||
formatTask,
|
||||
parseMemberIds,
|
||||
syncTaskMembers,
|
||||
taskDetailInclude,
|
||||
taskInclude,
|
||||
} from '../lib/taskQuery';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -17,15 +24,11 @@ router.get('/', async (req, res, next) => {
|
||||
...(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 } },
|
||||
},
|
||||
include: taskInclude,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
res.json(tasks);
|
||||
res.json(tasks.map((t) => formatTask(t)));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -37,21 +40,11 @@ router.get('/:id', async (req, res, next) => {
|
||||
const taskId = String(req.params.id);
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, department: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
details: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { author: { select: { id: true, name: true } } },
|
||||
},
|
||||
kpiMetrics: true,
|
||||
files: true,
|
||||
milestones: { orderBy: { order: 'asc' } },
|
||||
},
|
||||
include: taskDetailInclude,
|
||||
});
|
||||
|
||||
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||
res.json(task);
|
||||
res.json(formatTask(task));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -60,16 +53,17 @@ router.get('/:id', async (req, res, next) => {
|
||||
// POST /api/tasks — 업무 등록
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const body = req.body as Record<string, any>;
|
||||
const { title, description, status, priority, quarter, category,
|
||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
||||
showDescription, showStatus, showIssue, showProgress, keywords } =
|
||||
req.body as Record<string, any>;
|
||||
showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body;
|
||||
|
||||
if (!title || !quarter) {
|
||||
throw new AppError(400, '제목과 분기는 필수입니다.');
|
||||
}
|
||||
|
||||
const creatorId = await resolveCreatorId((req.body as Record<string, string>).creatorId);
|
||||
const creatorId = await resolveCreatorId(body.creatorId);
|
||||
const assigneeMemberIds = parseMemberIds(body);
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
@@ -93,11 +87,23 @@ router.post('/', async (req, res, next) => {
|
||||
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
|
||||
keywords: keywords || null,
|
||||
assigneeId: assigneeId || null,
|
||||
pmMemberId: pmMemberId || null,
|
||||
creatorId,
|
||||
},
|
||||
include: taskInclude,
|
||||
});
|
||||
|
||||
res.status(201).json(task);
|
||||
if (assigneeMemberIds !== undefined) {
|
||||
await syncTaskMembers(task.id, undefined, assigneeMemberIds);
|
||||
const refreshed = await prisma.task.findUnique({
|
||||
where: { id: task.id },
|
||||
include: taskInclude,
|
||||
});
|
||||
res.status(201).json(formatTask(refreshed!));
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json(formatTask(task));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -109,12 +115,14 @@ router.patch('/:id', async (req, res, next) => {
|
||||
const existing = await prisma.task.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||
|
||||
const body = req.body as Record<string, any>;
|
||||
const { title, description, status, priority, quarter, category,
|
||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
||||
showDescription, showStatus, showIssue, showProgress, keywords } =
|
||||
req.body as Record<string, any>;
|
||||
showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body;
|
||||
|
||||
const task = await prisma.task.update({
|
||||
const assigneeMemberIds = parseMemberIds(body);
|
||||
|
||||
await prisma.task.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(title && { title }),
|
||||
@@ -131,6 +139,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
|
||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
|
||||
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
|
||||
...(pmMemberId !== undefined && { pmMemberId: pmMemberId || null }),
|
||||
...(showDate !== undefined && { showDate: showDate === true || showDate === 'true' }),
|
||||
...(showDescription !== undefined && { showDescription: showDescription === true || showDescription === 'true' }),
|
||||
...(showStatus !== undefined && { showStatus: showStatus === true || showStatus === 'true' }),
|
||||
@@ -140,7 +149,20 @@ router.patch('/:id', async (req, res, next) => {
|
||||
},
|
||||
});
|
||||
|
||||
res.json(task);
|
||||
if (pmMemberId !== undefined || assigneeMemberIds !== undefined) {
|
||||
await syncTaskMembers(
|
||||
req.params.id,
|
||||
pmMemberId !== undefined ? (pmMemberId || null) : undefined,
|
||||
assigneeMemberIds,
|
||||
);
|
||||
}
|
||||
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: taskInclude,
|
||||
});
|
||||
|
||||
res.json(formatTask(task!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
126
backend/src/routes/teamMembers.ts
Normal file
126
backend/src/routes/teamMembers.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { uploadTeamPhoto } from '../middleware/uploadTeamPhoto';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const memberSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
rank: true,
|
||||
role: true,
|
||||
cell: true,
|
||||
contact: true,
|
||||
photoUrl: true,
|
||||
sortOrder: true,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// GET /api/team-members (?all=1 이면 비활성 포함 — 관리 화면용)
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const includeAll = req.query.all === '1' || req.query.all === 'true';
|
||||
const members = await prisma.teamMember.findMany({
|
||||
where: includeAll ? undefined : { isActive: true },
|
||||
select: memberSelect,
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
res.json(members);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/team-members/photo — 팀원 사진 (로컬 uploads/team/ 저장)
|
||||
router.post('/photo', uploadTeamPhoto.single('photo'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
throw new AppError(400, '이미지 파일을 선택해 주세요.');
|
||||
}
|
||||
res.status(201).json({
|
||||
url: `/uploads/team/${req.file.filename}`,
|
||||
filename: req.file.filename,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/team-members — 인원 등록
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { name, rank, role, cell, contact, photoUrl, sortOrder } =
|
||||
req.body as Record<string, unknown>;
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
throw new AppError(400, '이름은 필수입니다.');
|
||||
}
|
||||
|
||||
const member = await prisma.teamMember.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
rank: typeof rank === 'string' ? rank : null,
|
||||
role: typeof role === 'string' ? role : null,
|
||||
cell: typeof cell === 'string' ? cell : null,
|
||||
contact: typeof contact === 'string' ? contact : null,
|
||||
photoUrl: typeof photoUrl === 'string' ? photoUrl : null,
|
||||
sortOrder: typeof sortOrder === 'number' ? sortOrder : Number(sortOrder) || 0,
|
||||
},
|
||||
select: memberSelect,
|
||||
});
|
||||
|
||||
res.status(201).json(member);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/team-members/:id
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.teamMember.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, '팀원을 찾을 수 없습니다.');
|
||||
|
||||
const { name, rank, role, cell, contact, photoUrl, sortOrder, isActive } =
|
||||
req.body as Record<string, unknown>;
|
||||
|
||||
const member = await prisma.teamMember.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(name !== undefined && { name: String(name).trim() }),
|
||||
...(rank !== undefined && { rank: rank ? String(rank) : null }),
|
||||
...(role !== undefined && { role: role ? String(role) : null }),
|
||||
...(cell !== undefined && { cell: cell ? String(cell) : null }),
|
||||
...(contact !== undefined && { contact: contact ? String(contact) : null }),
|
||||
...(photoUrl !== undefined && { photoUrl: photoUrl ? String(photoUrl) : null }),
|
||||
...(sortOrder !== undefined && { sortOrder: Number(sortOrder) || 0 }),
|
||||
...(isActive !== undefined && { isActive: isActive === true || isActive === 'true' }),
|
||||
},
|
||||
select: memberSelect,
|
||||
});
|
||||
|
||||
res.json(member);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/team-members/:id — soft delete
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.teamMember.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, '팀원을 찾을 수 없습니다.');
|
||||
|
||||
await prisma.teamMember.update({
|
||||
where: { id: req.params.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
7
frontend/.env.local.example
Normal file
7
frontend/.env.local.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# 로컬 백엔드 강제 지정 (선택 — 미설정 시 자동 감지)
|
||||
# 개발(npm run dev): Vite 프록시 /api → localhost:4000
|
||||
# 사설망 IP 접속: 자동으로 http://<이PC IP>:4000 연결
|
||||
# Vercel 배포: Render API 사용
|
||||
|
||||
# VITE_API_URL=http://localhost:4000
|
||||
# VITE_SOCKET_URL=http://localhost:4000
|
||||
195
frontend/src/components/admin/TeamMemberFormModal.tsx
Normal file
195
frontend/src/components/admin/TeamMemberFormModal.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
||||
import type { TeamMemberForm } from '../../hooks/useTeamMembersAdmin';
|
||||
import { EMPTY_MEMBER_FORM } from '../../hooks/useTeamMembersAdmin';
|
||||
import { TeamMemberAvatar } from '../dashboard/TeamMemberAvatar';
|
||||
import { getCellLabel } from '../../lib/teamStatus';
|
||||
import type { TeamMemberBrief } from '../../types';
|
||||
|
||||
const CELL_OPTIONS = [
|
||||
{ value: '리더', label: '리더 (팀장)' },
|
||||
{ value: 'HR', label: '인사' },
|
||||
{ value: '총무', label: '총무' },
|
||||
];
|
||||
|
||||
const RANK_OPTIONS = ['수석연구원', '책임연구원', '선임연구원', '연구원', '주임', '사원'];
|
||||
const ROLE_OPTIONS = ['팀장', '셀장', '팀원'];
|
||||
|
||||
interface TeamMemberFormModalProps {
|
||||
mode: 'add' | 'edit';
|
||||
initial?: TeamMemberForm;
|
||||
onSave: (form: TeamMemberForm) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
export function TeamMemberFormModal({
|
||||
mode,
|
||||
initial = EMPTY_MEMBER_FORM,
|
||||
onSave,
|
||||
onClose,
|
||||
saving = false,
|
||||
}: TeamMemberFormModalProps) {
|
||||
const [form, setForm] = useState<TeamMemberForm>(initial);
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
|
||||
const set = <K extends keyof TeamMemberForm>(key: K, value: TeamMemberForm[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handlePhotoFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingPhoto(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('photo', file);
|
||||
const { data } = await apiClient.post<{ url: string }>('/team-members/photo', fd);
|
||||
set('photoUrl', data.url);
|
||||
} catch (err) {
|
||||
alert(getApiErrorMessage(err, '사진 업로드에 실패했습니다.'));
|
||||
} finally {
|
||||
setUploadingPhoto(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const preview: TeamMemberBrief = {
|
||||
id: 'preview',
|
||||
name: form.name || '이름',
|
||||
rank: form.rank || null,
|
||||
role: form.role || null,
|
||||
cell: form.cell || null,
|
||||
contact: form.contact || null,
|
||||
photoUrl: form.photoUrl || null,
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) return;
|
||||
await onSave(form);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="admin-modal-backdrop" onClick={onClose}>
|
||||
<div className="admin-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="admin-modal-header">
|
||||
<h2>{mode === 'add' ? '팀원 추가' : '팀원 수정'}</h2>
|
||||
<button type="button" className="admin-modal-close" onClick={onClose} aria-label="닫기">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="admin-modal-body">
|
||||
<div className="admin-form-preview">
|
||||
<TeamMemberAvatar member={preview} size="leader" />
|
||||
<div>
|
||||
<div className="admin-preview-name">{form.name || '이름'}</div>
|
||||
<div className="admin-preview-sub">
|
||||
{[form.rank, form.role].filter(Boolean).join(' · ') || '직급 · 직책'}
|
||||
</div>
|
||||
<div className="admin-preview-cell">{getCellLabel(form.cell) || '셀'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-grid">
|
||||
<label className="admin-field admin-field-full">
|
||||
<span>이름 *</span>
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder="홍길동"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>직급</span>
|
||||
<input
|
||||
list="rank-options"
|
||||
value={form.rank}
|
||||
onChange={(e) => set('rank', e.target.value)}
|
||||
placeholder="선임연구원"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>직책</span>
|
||||
<input
|
||||
list="role-options"
|
||||
value={form.role}
|
||||
onChange={(e) => set('role', e.target.value)}
|
||||
placeholder="팀원"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>소속 셀</span>
|
||||
<select value={form.cell} onChange={(e) => set('cell', e.target.value)}>
|
||||
{CELL_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>표시 순서</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.sortOrder}
|
||||
onChange={(e) => set('sortOrder', Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field admin-field-full">
|
||||
<span>연락처</span>
|
||||
<input
|
||||
value={form.contact}
|
||||
onChange={(e) => set('contact', e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="admin-field admin-field-full admin-photo-field">
|
||||
<span>프로필 사진</span>
|
||||
<div className="admin-photo-upload-row">
|
||||
<label className="admin-photo-file-btn">
|
||||
{uploadingPhoto ? '업로드 중…' : '📷 사진 파일 선택'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
className="sr-only"
|
||||
disabled={uploadingPhoto}
|
||||
onChange={handlePhotoFile}
|
||||
/>
|
||||
</label>
|
||||
<span className="admin-photo-hint">로컬 서버 uploads/team/ 에 저장됩니다</span>
|
||||
</div>
|
||||
<input
|
||||
value={form.photoUrl}
|
||||
onChange={(e) => set('photoUrl', e.target.value)}
|
||||
placeholder="또는 URL 직접 입력 (/uploads/team/...)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<datalist id="rank-options">
|
||||
{RANK_OPTIONS.map((r) => <option key={r} value={r} />)}
|
||||
</datalist>
|
||||
<datalist id="role-options">
|
||||
{ROLE_OPTIONS.map((r) => <option key={r} value={r} />)}
|
||||
</datalist>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button type="button" className="admin-btn-ghost" onClick={onClose}>취소</button>
|
||||
<button type="submit" className="admin-btn-primary" disabled={saving || !form.name.trim()}>
|
||||
{saving ? '저장 중…' : mode === 'add' ? '추가하기' : '저장하기'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Task } from '../../types';
|
||||
import type { Task, TeamMember } from '../../types';
|
||||
import { normalizeTaskType, displayFlagsForTaskType } from '../../lib/taskType';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
@@ -28,6 +28,8 @@ export interface TaskFormData {
|
||||
showIssue: boolean;
|
||||
showProgress: boolean;
|
||||
keywords: string;
|
||||
pmMemberId: string;
|
||||
assigneeMemberIds: string[];
|
||||
}
|
||||
|
||||
interface TaskModalProps {
|
||||
@@ -36,11 +38,21 @@ interface TaskModalProps {
|
||||
defaultSection?: string;
|
||||
defaultQuarter?: string;
|
||||
sectionOptions?: { value: string; label: string }[];
|
||||
teamMembers?: TeamMember[];
|
||||
onSave: (data: TaskFormData) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter = '2026-Q2', sectionOptions, onSave, onClose }: TaskModalProps) {
|
||||
export function TaskModal({
|
||||
mode,
|
||||
task,
|
||||
defaultSection = 'HR',
|
||||
defaultQuarter = '2026-Q2',
|
||||
sectionOptions,
|
||||
teamMembers = [],
|
||||
onSave,
|
||||
onClose,
|
||||
}: TaskModalProps) {
|
||||
const toDateInput = (iso: string | null | undefined) => {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
@@ -65,8 +77,22 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
|
||||
showIssue: task?.showIssue ?? true,
|
||||
showProgress: task?.showProgress ?? true,
|
||||
keywords: task?.keywords ?? '',
|
||||
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
|
||||
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
|
||||
});
|
||||
|
||||
const toggleAssignee = (memberId: string) => {
|
||||
setForm((prev) => {
|
||||
const has = prev.assigneeMemberIds.includes(memberId);
|
||||
return {
|
||||
...prev,
|
||||
assigneeMemberIds: has
|
||||
? prev.assigneeMemberIds.filter((id) => id !== memberId)
|
||||
: [...prev.assigneeMemberIds, memberId],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
@@ -226,6 +252,53 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PM + 담당자 */}
|
||||
{teamMembers.length > 0 && (
|
||||
<div className="space-y-3 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">PM</label>
|
||||
<select
|
||||
value={form.pmMemberId}
|
||||
onChange={(e) => set('pmMemberId', e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition bg-white"
|
||||
>
|
||||
<option value="">선택 안 함</option>
|
||||
{teamMembers.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}{m.rank ? ` · ${m.rank}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">담당자 (복수 선택)</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{teamMembers.map((m) => {
|
||||
const checked = form.assigneeMemberIds.includes(m.id);
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border cursor-pointer select-none text-sm font-semibold transition ${
|
||||
checked
|
||||
? 'bg-emerald-600 text-white border-emerald-600'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-emerald-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => toggleAssignee(m.id)}
|
||||
/>
|
||||
{m.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 키워드 */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">키워드 (콤마로 구분)</label>
|
||||
|
||||
@@ -1,186 +1,382 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isDetailWindowOpen } from '../../lib/dualMonitor';
|
||||
|
||||
import {
|
||||
|
||||
FILTER_ALL,
|
||||
|
||||
isStatusChipActive,
|
||||
|
||||
type CoreStatusFilter,
|
||||
|
||||
} from '../../lib/statusFilters';
|
||||
|
||||
import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
|
||||
|
||||
|
||||
|
||||
interface Stats {
|
||||
|
||||
total: number;
|
||||
|
||||
inProgress: number;
|
||||
|
||||
review: number;
|
||||
|
||||
done: number;
|
||||
|
||||
issues: number;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
|
||||
quarter: string;
|
||||
|
||||
stats: Stats;
|
||||
|
||||
activeFilters: string[];
|
||||
|
||||
issueFilterActive: boolean;
|
||||
|
||||
onToggleAll: () => void;
|
||||
|
||||
onToggleStatus: (key: CoreStatusFilter) => void;
|
||||
|
||||
onToggleIssue: () => void;
|
||||
|
||||
onOpenDetailWindow: () => void | Promise<void>;
|
||||
|
||||
onOpenTaskManager: () => void;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const STAT_ACCENT = {
|
||||
|
||||
전체: 'text-[#ffdb3a]',
|
||||
|
||||
IN_PROGRESS: 'text-[#10b981]',
|
||||
|
||||
REVIEW: 'text-[#ff9f0a]',
|
||||
|
||||
DONE: 'text-[#b0b0b0]',
|
||||
|
||||
ISSUES: 'text-[#ff5252]',
|
||||
|
||||
} as const;
|
||||
|
||||
|
||||
|
||||
type StatKey = keyof typeof STAT_ACCENT;
|
||||
|
||||
|
||||
|
||||
export function DashboardHeader({
|
||||
|
||||
quarter,
|
||||
|
||||
stats,
|
||||
|
||||
activeFilters,
|
||||
|
||||
issueFilterActive,
|
||||
|
||||
onToggleAll,
|
||||
|
||||
onToggleStatus,
|
||||
|
||||
onToggleIssue,
|
||||
|
||||
onOpenDetailWindow,
|
||||
|
||||
onOpenTaskManager,
|
||||
|
||||
}: DashboardHeaderProps) {
|
||||
|
||||
const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen);
|
||||
|
||||
const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
|
||||
|
||||
|
||||
|
||||
const handleOpenDetailWindow = () => {
|
||||
|
||||
void Promise.resolve(onOpenDetailWindow()).then(() => {
|
||||
|
||||
setDetailViewActive(isDetailWindowOpen());
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const statItems: Array<{
|
||||
|
||||
label: string;
|
||||
|
||||
value: number;
|
||||
|
||||
statusKey: StatKey;
|
||||
|
||||
onClick: () => void;
|
||||
|
||||
isActive: boolean;
|
||||
|
||||
}> = [
|
||||
|
||||
{
|
||||
|
||||
label: '전체',
|
||||
|
||||
value: stats.total,
|
||||
|
||||
statusKey: '전체',
|
||||
|
||||
onClick: onToggleAll,
|
||||
|
||||
isActive: isStatusChipActive(FILTER_ALL, activeFilters, issueFilterActive),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
label: '진행',
|
||||
|
||||
value: stats.inProgress,
|
||||
|
||||
statusKey: 'IN_PROGRESS',
|
||||
|
||||
onClick: () => onToggleStatus('IN_PROGRESS'),
|
||||
|
||||
isActive: isStatusChipActive('IN_PROGRESS', activeFilters, issueFilterActive),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
label: '보류',
|
||||
|
||||
value: stats.review,
|
||||
|
||||
statusKey: 'REVIEW',
|
||||
|
||||
onClick: () => onToggleStatus('REVIEW'),
|
||||
|
||||
isActive: isStatusChipActive('REVIEW', activeFilters, issueFilterActive),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
label: '완료',
|
||||
|
||||
value: stats.done,
|
||||
|
||||
statusKey: 'DONE',
|
||||
|
||||
onClick: () => onToggleStatus('DONE'),
|
||||
|
||||
isActive: isStatusChipActive('DONE', activeFilters, issueFilterActive),
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isDetailWindowOpen } from '../../lib/dualMonitor';
|
||||
|
||||
import {
|
||||
|
||||
FILTER_ALL,
|
||||
|
||||
isStatusChipActive,
|
||||
|
||||
type CoreStatusFilter,
|
||||
|
||||
} from '../../lib/statusFilters';
|
||||
|
||||
import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
|
||||
|
||||
|
||||
|
||||
interface Stats {
|
||||
|
||||
total: number;
|
||||
|
||||
inProgress: number;
|
||||
|
||||
review: number;
|
||||
|
||||
done: number;
|
||||
|
||||
issues: number;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
|
||||
quarter: string;
|
||||
|
||||
stats: Stats;
|
||||
|
||||
activeFilters: string[];
|
||||
|
||||
issueFilterActive: boolean;
|
||||
|
||||
onToggleAll: () => void;
|
||||
|
||||
onToggleStatus: (key: CoreStatusFilter) => void;
|
||||
|
||||
onToggleIssue: () => void;
|
||||
|
||||
onOpenDetailWindow: () => void | Promise<void>;
|
||||
|
||||
onOpenTaskManager: () => void;
|
||||
|
||||
teamPanelOpen: boolean;
|
||||
|
||||
onToggleTeamPanel: () => void;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const STAT_ACCENT = {
|
||||
|
||||
전체: 'text-[#ffdb3a]',
|
||||
|
||||
IN_PROGRESS: 'text-[#10b981]',
|
||||
|
||||
REVIEW: 'text-[#ff9f0a]',
|
||||
|
||||
DONE: 'text-[#b0b0b0]',
|
||||
|
||||
ISSUES: 'text-[#ff5252]',
|
||||
|
||||
} as const;
|
||||
|
||||
|
||||
|
||||
type StatKey = keyof typeof STAT_ACCENT;
|
||||
|
||||
|
||||
|
||||
export function DashboardHeader({
|
||||
|
||||
quarter,
|
||||
|
||||
stats,
|
||||
|
||||
activeFilters,
|
||||
|
||||
issueFilterActive,
|
||||
|
||||
onToggleAll,
|
||||
|
||||
onToggleStatus,
|
||||
|
||||
onToggleIssue,
|
||||
|
||||
onOpenDetailWindow,
|
||||
|
||||
onOpenTaskManager,
|
||||
|
||||
teamPanelOpen,
|
||||
|
||||
onToggleTeamPanel,
|
||||
|
||||
}: DashboardHeaderProps) {
|
||||
|
||||
const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen);
|
||||
|
||||
const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
|
||||
|
||||
|
||||
|
||||
const handleOpenDetailWindow = () => {
|
||||
|
||||
void Promise.resolve(onOpenDetailWindow()).then(() => {
|
||||
|
||||
setDetailViewActive(isDetailWindowOpen());
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const statItems: Array<{
|
||||
|
||||
label: string;
|
||||
|
||||
value: number;
|
||||
|
||||
statusKey: StatKey;
|
||||
|
||||
onClick: () => void;
|
||||
|
||||
isActive: boolean;
|
||||
|
||||
}> = [
|
||||
|
||||
{
|
||||
|
||||
label: '전체',
|
||||
|
||||
value: stats.total,
|
||||
|
||||
statusKey: '전체',
|
||||
|
||||
onClick: onToggleAll,
|
||||
|
||||
isActive: isStatusChipActive(FILTER_ALL, activeFilters, issueFilterActive),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
label: '진행',
|
||||
|
||||
value: stats.inProgress,
|
||||
|
||||
statusKey: 'IN_PROGRESS',
|
||||
|
||||
onClick: () => onToggleStatus('IN_PROGRESS'),
|
||||
|
||||
isActive: isStatusChipActive('IN_PROGRESS', activeFilters, issueFilterActive),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
label: '보류',
|
||||
|
||||
value: stats.review,
|
||||
|
||||
statusKey: 'REVIEW',
|
||||
|
||||
onClick: () => onToggleStatus('REVIEW'),
|
||||
|
||||
isActive: isStatusChipActive('REVIEW', activeFilters, issueFilterActive),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
label: '완료',
|
||||
|
||||
value: stats.done,
|
||||
|
||||
statusKey: 'DONE',
|
||||
|
||||
onClick: () => onToggleStatus('DONE'),
|
||||
|
||||
isActive: isStatusChipActive('DONE', activeFilters, issueFilterActive),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
label: '이슈',
|
||||
|
||||
value: stats.issues,
|
||||
|
||||
statusKey: 'ISSUES',
|
||||
|
||||
onClick: onToggleIssue,
|
||||
|
||||
isActive: issueFilterActive,
|
||||
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<header className="dashboard-header-bar shrink-0">
|
||||
|
||||
<div className="side-left-group min-w-0 shrink-0">
|
||||
|
||||
<span className="side-title-main main_tit flex shrink-0 items-center gap-[10px] text-[20px] font-bold tracking-[-0.5px] text-[#bad8ca]">
|
||||
|
||||
<span>총괄기획실</span>
|
||||
|
||||
<span>|</span>
|
||||
|
||||
<span>People Growth Hub</span>
|
||||
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="팀 현황"
|
||||
className={`team-status-btn-new ${teamPanelOpen ? 'active' : ''}`}
|
||||
onClick={onToggleTeamPanel}
|
||||
>
|
||||
<UsersIcon size={16} />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="header-stats-bar side-polygon-stats">
|
||||
|
||||
<div className="poly-stat-item" style={{ fontSize: '18px', gap: '8px' }}>
|
||||
|
||||
<span className="poly-stat-quarter header-stat-text">{quarterLabel}</span>
|
||||
|
||||
<span className="poly-stat-bullet header-stat-text">·</span>
|
||||
|
||||
{statItems.map((item, index) => (
|
||||
|
||||
<span key={item.statusKey} className="contents">
|
||||
|
||||
{(index === 1 || index === 4) && <StatDivider />}
|
||||
|
||||
<StatClick
|
||||
|
||||
label={item.label}
|
||||
|
||||
value={item.value}
|
||||
|
||||
accent={STAT_ACCENT[item.statusKey]}
|
||||
|
||||
isActive={item.isActive}
|
||||
|
||||
onClick={item.onClick}
|
||||
|
||||
/>
|
||||
|
||||
</span>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="side-right-actions shrink-0">
|
||||
|
||||
<button type="button" onClick={onOpenTaskManager} title="신규 프로젝트 추가" className="header-action-btn-new">
|
||||
|
||||
<PlusIcon size={16} />
|
||||
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
onClick={handleOpenDetailWindow}
|
||||
|
||||
title="듀얼뷰"
|
||||
|
||||
className={`header-view-btn-new ${detailViewActive ? 'active' : ''}`}
|
||||
|
||||
>
|
||||
|
||||
<DualMonitorIcon size={16} />
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function StatDivider() {
|
||||
|
||||
return <div className="poly-stat-divider" aria-hidden />;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface StatClickProps {
|
||||
|
||||
label: string;
|
||||
|
||||
value: number;
|
||||
|
||||
accent: string;
|
||||
|
||||
isActive: boolean;
|
||||
|
||||
onClick: () => void;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function StatClick({ label, value, accent, isActive, onClick }: StatClickProps) {
|
||||
|
||||
return (
|
||||
|
||||
<span
|
||||
|
||||
role="button"
|
||||
|
||||
tabIndex={0}
|
||||
|
||||
onClick={onClick}
|
||||
|
||||
onKeyDown={(e) => {
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onClick();
|
||||
|
||||
}
|
||||
|
||||
}}
|
||||
|
||||
className={`poly-click-stat header-stat-text ${isActive ? 'active' : ''}`}
|
||||
|
||||
style={{ cursor: 'pointer', padding: '2px 6px', borderRadius: '4px' }}
|
||||
|
||||
>
|
||||
|
||||
{label}{' '}
|
||||
|
||||
<span className={`poly-stat-val ${accent}`}>{value}</span>
|
||||
|
||||
<span className="poly-stat-unit"> 건</span>
|
||||
|
||||
</span>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ 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';
|
||||
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
|
||||
import type { Task, TeamMember } from '../../types';
|
||||
|
||||
interface DepartmentColumnProps {
|
||||
title: string;
|
||||
@@ -26,6 +27,7 @@ interface DepartmentColumnProps {
|
||||
headerAlign?: 'left' | 'right';
|
||||
onSelectTask?: (task: Task) => void;
|
||||
sectionOptions?: { value: string; label: string }[];
|
||||
teamMembers?: TeamMember[];
|
||||
}
|
||||
|
||||
// ── 헤더 편집 팝업 ──────────────────────────────────────────
|
||||
@@ -94,7 +96,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
|
||||
);
|
||||
}
|
||||
|
||||
export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, orderedIds, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions }: DepartmentColumnProps) {
|
||||
export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, orderedIds, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions, teamMembers = [] }: DepartmentColumnProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ── 컬럼 설정 API ─────────────────────────────────────────
|
||||
@@ -184,23 +186,8 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
||||
const handleAdd = async (data: TaskFormData) => {
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
title: data.title,
|
||||
section: data.section || null,
|
||||
taskType: data.taskType || null,
|
||||
status: data.status as Task['status'],
|
||||
progress: data.progress,
|
||||
description: data.description || null,
|
||||
issueNote: data.issueNote || null,
|
||||
startDate: data.startDate || null,
|
||||
dueDate: data.dueDate || null,
|
||||
showDate: data.showDate,
|
||||
showDescription: data.showDescription,
|
||||
showStatus: data.showStatus,
|
||||
showIssue: data.showIssue,
|
||||
showProgress: data.showProgress,
|
||||
keywords: data.keywords || null,
|
||||
quarter: data.quarter,
|
||||
priority: 'MEDIUM',
|
||||
...taskFormToApiPayload(data),
|
||||
priority: 'MEDIUM',
|
||||
} as Partial<Task>);
|
||||
setShowAddModal(false);
|
||||
} catch (err: unknown) {
|
||||
@@ -212,16 +199,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
||||
if (!editingTask) return;
|
||||
patch.mutate({
|
||||
id: editingTask.id,
|
||||
data: {
|
||||
title: data.title, section: data.section || null,
|
||||
taskType: data.taskType || null, status: data.status, progress: data.progress,
|
||||
description: data.description || null, issueNote: data.issueNote || null,
|
||||
startDate: data.startDate || null, dueDate: data.dueDate || null,
|
||||
showDate: data.showDate, showDescription: data.showDescription,
|
||||
showStatus: data.showStatus, showIssue: data.showIssue,
|
||||
showProgress: data.showProgress,
|
||||
keywords: data.keywords || null,
|
||||
},
|
||||
data: taskFormToApiPayload(data),
|
||||
});
|
||||
setShowEditModal(false);
|
||||
setEditingTask(null);
|
||||
@@ -343,6 +321,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
||||
defaultSection={section}
|
||||
defaultQuarter={quarter}
|
||||
sectionOptions={sectionOptions}
|
||||
teamMembers={teamMembers}
|
||||
onSave={handleAdd}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
/>
|
||||
@@ -353,6 +332,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
||||
mode="edit"
|
||||
task={editingTask}
|
||||
sectionOptions={sectionOptions}
|
||||
teamMembers={teamMembers}
|
||||
onSave={handleEdit}
|
||||
onClose={() => { setShowEditModal(false); setEditingTask(null); }}
|
||||
/>
|
||||
|
||||
84
frontend/src/components/dashboard/MemberTaskTooltip.tsx
Normal file
84
frontend/src/components/dashboard/MemberTaskTooltip.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Task } from '../../types';
|
||||
import { getMemberTasks, taskStatusBadge, taskSubtitle } from '../../lib/teamStatus';
|
||||
|
||||
interface MemberTaskTooltipProps {
|
||||
memberId: string;
|
||||
tasks: Task[];
|
||||
isStatic: boolean;
|
||||
activeProjectId: string | null;
|
||||
onProjectClick: (taskId: string | null) => void;
|
||||
}
|
||||
|
||||
export function MemberTaskTooltip({
|
||||
memberId,
|
||||
tasks,
|
||||
isStatic,
|
||||
activeProjectId,
|
||||
onProjectClick,
|
||||
}: MemberTaskTooltipProps) {
|
||||
const memberTasks = getMemberTasks(memberId, tasks);
|
||||
if (memberTasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`member-tooltip ${isStatic ? 'is-static' : ''}`}>
|
||||
<div className="tooltip-header">
|
||||
참여 중인 업무 ({memberTasks.length})
|
||||
</div>
|
||||
<div className="tooltip-list">
|
||||
{memberTasks.map((task) => {
|
||||
const badge = taskStatusBadge(task);
|
||||
const isActive = activeProjectId === task.id;
|
||||
const subtitle = taskSubtitle(task);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`tooltip-item ${isActive ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProjectClick(isActive ? null : task.id);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onProjectClick(isActive ? null : task.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="tooltip-item-row">
|
||||
<span className="tooltip-dot" aria-hidden />
|
||||
<div className="tooltip-item-body">
|
||||
<div className="tooltip-title">
|
||||
{task.title}
|
||||
{subtitle && <span className="tooltip-sub">{subtitle}</span>}
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="tooltip-project-detail">
|
||||
<span>
|
||||
PM: <strong>{task.pmMember?.name ?? '미정'}</strong>
|
||||
</span>
|
||||
<span>
|
||||
담당자:{' '}
|
||||
<strong>
|
||||
{task.assigneeMembers?.length
|
||||
? task.assigneeMembers.map((m) => m.name).join(', ')
|
||||
: '미정'}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`tooltip-badge tooltip-badge-${badge.variant}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { createPortal } from 'react-dom';
|
||||
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
||||
import { TaskModal } from '../common/TaskModal';
|
||||
import type { TaskFormData } from '../common/TaskModal';
|
||||
import type { Task } from '../../types';
|
||||
import type { Task, TeamMember } from '../../types';
|
||||
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
|
||||
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
|
||||
@@ -24,10 +25,11 @@ interface TaskManagerProps {
|
||||
tasks: Task[];
|
||||
sectionOptions: { value: string; label: string }[];
|
||||
quarter: string;
|
||||
teamMembers?: TeamMember[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskManagerProps) {
|
||||
export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [], onClose }: TaskManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [filterSection, setFilterSection] = useState<string>('전체');
|
||||
const [filterType, setFilterType] = useState<string>('전체');
|
||||
@@ -64,18 +66,8 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
|
||||
const handleAdd = async (data: TaskFormData) => {
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
title: data.title, section: data.section || null, tag: data.tag || null,
|
||||
taskType: data.taskType || null, status: data.status, progress: data.progress,
|
||||
description: data.description || null, issueNote: data.issueNote || null,
|
||||
startDate: data.startDate || null, dueDate: data.dueDate || null,
|
||||
showDate: data.showDate,
|
||||
showDescription: data.showDescription,
|
||||
showStatus: data.showStatus,
|
||||
showIssue: data.showIssue,
|
||||
showProgress: data.showProgress,
|
||||
keywords: data.keywords || null,
|
||||
quarter: data.quarter,
|
||||
priority: 'MEDIUM',
|
||||
...taskFormToApiPayload(data),
|
||||
priority: 'MEDIUM',
|
||||
});
|
||||
setModalMode(null);
|
||||
} catch (err: unknown) {
|
||||
@@ -87,18 +79,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
|
||||
if (!editingTask) return;
|
||||
patch.mutate({
|
||||
id: editingTask.id,
|
||||
data: {
|
||||
title: data.title, section: data.section || null, tag: data.tag || null,
|
||||
taskType: data.taskType || null, status: data.status, progress: data.progress,
|
||||
description: data.description || null, issueNote: data.issueNote || null,
|
||||
startDate: data.startDate || null, dueDate: data.dueDate || null,
|
||||
showDate: data.showDate,
|
||||
showDescription: data.showDescription,
|
||||
showStatus: data.showStatus,
|
||||
showIssue: data.showIssue,
|
||||
showProgress: data.showProgress,
|
||||
keywords: data.keywords || null,
|
||||
},
|
||||
data: taskFormToApiPayload(data),
|
||||
});
|
||||
setModalMode(null);
|
||||
setEditingTask(null);
|
||||
@@ -232,6 +213,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
|
||||
defaultSection={filterSection !== '전체' ? filterSection : '인사관리'}
|
||||
defaultQuarter={quarter}
|
||||
sectionOptions={sectionOptions}
|
||||
teamMembers={teamMembers}
|
||||
onSave={handleAdd}
|
||||
onClose={() => setModalMode(null)}
|
||||
/>
|
||||
@@ -241,6 +223,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
|
||||
mode="edit"
|
||||
task={editingTask}
|
||||
sectionOptions={sectionOptions}
|
||||
teamMembers={teamMembers}
|
||||
onSave={handleEdit}
|
||||
onClose={() => { setModalMode(null); setEditingTask(null); }}
|
||||
/>
|
||||
|
||||
34
frontend/src/components/dashboard/TeamMemberAvatar.tsx
Normal file
34
frontend/src/components/dashboard/TeamMemberAvatar.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import { staticAssetUrl } from '../../lib/apiBase';
|
||||
import type { TeamMemberBrief } from '../../types';
|
||||
|
||||
interface TeamMemberAvatarProps {
|
||||
member: TeamMemberBrief;
|
||||
className?: string;
|
||||
size?: 'leader' | 'member';
|
||||
}
|
||||
|
||||
export function TeamMemberAvatar({ member, className = '', size = 'member' }: TeamMemberAvatarProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const initial = member.name?.charAt(0) ?? '?';
|
||||
const isLeader = size === 'leader';
|
||||
|
||||
const photoSrc = staticAssetUrl(member.photoUrl);
|
||||
|
||||
if (photoSrc && !imgError) {
|
||||
return (
|
||||
<img
|
||||
src={photoSrc}
|
||||
alt={member.name}
|
||||
className={`${isLeader ? 'leader-avatar' : 'member-avatar'} ${className}`}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${isLeader ? 'leader-avatar' : 'member-avatar'} ${className}`}>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
frontend/src/components/dashboard/TeamStatusPanel.tsx
Normal file
215
frontend/src/components/dashboard/TeamStatusPanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Task, TeamMember } from '../../types';
|
||||
import {
|
||||
getCellLabel,
|
||||
getHighlightMemberIds,
|
||||
groupTeamMembers,
|
||||
} from '../../lib/teamStatus';
|
||||
import { TeamMemberAvatar } from './TeamMemberAvatar';
|
||||
import { MemberTaskTooltip } from './MemberTaskTooltip';
|
||||
import { UsersIcon } from './HeaderIcons';
|
||||
|
||||
function LayersIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface TeamStatusPanelProps {
|
||||
members: TeamMember[];
|
||||
tasks: Task[];
|
||||
showAllTasks: boolean;
|
||||
activeProjectId: string | null;
|
||||
onToggleShowAll: () => void;
|
||||
onProjectClick: (taskId: string | null) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function MemberInfo({
|
||||
member,
|
||||
showAllTasks,
|
||||
isLeader,
|
||||
}: {
|
||||
member: TeamMember;
|
||||
showAllTasks: boolean;
|
||||
isLeader: boolean;
|
||||
}) {
|
||||
if (showAllTasks) {
|
||||
return (
|
||||
<>
|
||||
<span className="member-name">{member.name}</span>
|
||||
{member.rank && <span className="member-rank">{member.rank}</span>}
|
||||
{member.role && <span className="member-role">{member.role}</span>}
|
||||
{member.contact && <span className="member-contact">{member.contact}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLeader) {
|
||||
return (
|
||||
<>
|
||||
<div className="leader-name-row">
|
||||
<span className="leader-name">{member.name}</span>
|
||||
<span className="leader-sub">
|
||||
{[member.rank, member.role].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
{member.contact && <span className="member-contact">{member.contact}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="member-name-row">
|
||||
<span className="member-name">{member.name}</span>
|
||||
<span className="member-role">
|
||||
{[member.rank, member.role].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
{member.contact && <span className="member-contact">{member.contact}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamStatusPanel({
|
||||
members,
|
||||
tasks,
|
||||
showAllTasks,
|
||||
activeProjectId,
|
||||
onToggleShowAll,
|
||||
onProjectClick,
|
||||
onClose,
|
||||
}: TeamStatusPanelProps) {
|
||||
const activeTask = activeProjectId ? tasks.find((t) => t.id === activeProjectId) : null;
|
||||
const highlightIds = useMemo(() => getHighlightMemberIds(activeTask), [activeTask]);
|
||||
|
||||
const { groups, cellKeys } = useMemo(() => groupTeamMembers(members), [members]);
|
||||
const visibleCells = cellKeys.filter((key) => (groups[key]?.length ?? 0) > 0);
|
||||
const leaders = groups.리더 ?? [];
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="team-overlay">
|
||||
<div className="team-panel-header">
|
||||
<div className="team-panel-title">
|
||||
<UsersIcon size={20} />
|
||||
<span>팀 인원 현황</span>
|
||||
<span className="team-total-badge">0명</span>
|
||||
</div>
|
||||
<button type="button" className="team-close-btn" onClick={onClose} aria-label="닫기">
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="team-tree-scroll team-empty-state">
|
||||
<p>등록된 팀원이 없습니다.</p>
|
||||
<p className="team-empty-hint">
|
||||
<Link to="/admin" className="admin-team-manage-link">팀원 관리</Link>
|
||||
에서 등록할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderMemberCard = (member: TeamMember, isLeader: boolean) => {
|
||||
const highlighted = highlightIds.includes(member.id);
|
||||
const cardClass = isLeader ? 'tree-leader-card' : 'tree-member-card';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`${cardClass} ${highlighted ? 'highlighted-member' : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TeamMemberAvatar member={member} size={isLeader ? 'leader' : 'member'} />
|
||||
<div className={isLeader ? 'leader-info' : 'member-info-wrap'}>
|
||||
<MemberInfo member={member} showAllTasks={showAllTasks} isLeader={isLeader} />
|
||||
</div>
|
||||
<MemberTaskTooltip
|
||||
memberId={member.id}
|
||||
tasks={tasks}
|
||||
isStatic={showAllTasks}
|
||||
activeProjectId={activeProjectId}
|
||||
onProjectClick={onProjectClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="team-overlay">
|
||||
<div className="team-panel-header">
|
||||
<div className="team-panel-title">
|
||||
<UsersIcon size={20} />
|
||||
<span>팀 인원 현황</span>
|
||||
<span className="team-total-badge">{members.length}명</span>
|
||||
</div>
|
||||
<div className="team-panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`team-view-toggle ${showAllTasks ? 'active' : ''}`}
|
||||
onClick={onToggleShowAll}
|
||||
>
|
||||
<LayersIcon size={14} />
|
||||
<span>업무 전체보기</span>
|
||||
</button>
|
||||
<button type="button" className="team-close-btn" onClick={onClose} aria-label="닫기">
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`team-tree-scroll ${showAllTasks ? 'show-all-tooltips' : ''}`}
|
||||
onClick={() => onProjectClick(null)}
|
||||
>
|
||||
{leaders.length > 0 && (
|
||||
<>
|
||||
<div className="tree-leaders-row">
|
||||
{leaders.map((m) => renderMemberCard(m, true))}
|
||||
</div>
|
||||
{visibleCells.length > 0 && <div className="tree-root-vline" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{visibleCells.length > 0 && (
|
||||
<div className="tree-cells-row">
|
||||
{visibleCells.map((cellKey, index, arr) => {
|
||||
const cellMembers = groups[cellKey] ?? [];
|
||||
return (
|
||||
<div
|
||||
key={cellKey}
|
||||
className={`tree-cell-col ${index === arr.length - 1 ? 'last' : ''}`}
|
||||
>
|
||||
<div className="tree-cell-hline-wrap" aria-hidden />
|
||||
<div className="tree-cell-card static">
|
||||
<span className="tree-cell-name">{getCellLabel(cellKey)}</span>
|
||||
<span className="tree-badge">{cellMembers.length}명</span>
|
||||
</div>
|
||||
<div className="tree-members-list">
|
||||
{cellMembers.map((m) => renderMemberCard(m, false))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,17 @@
|
||||
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { getSocketUrl } from '../lib/apiBase';
|
||||
|
||||
const SocketContext = createContext<Socket | null>(null);
|
||||
|
||||
const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
|
||||
|
||||
const SOCKET_URL =
|
||||
import.meta.env.VITE_SOCKET_URL ||
|
||||
(import.meta.env.PROD
|
||||
? RENDER_API
|
||||
: `${window.location.protocol}//${window.location.hostname}:4000`);
|
||||
|
||||
export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io(SOCKET_URL, { transports: ['websocket'] });
|
||||
const socket = io(getSocketUrl(), { transports: ['websocket'] });
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => console.log('[Socket] Connected'));
|
||||
socket.on('connect', () => console.log('[Socket] Connected', getSocketUrl()));
|
||||
socket.on('disconnect', () => console.log('[Socket] Disconnected'));
|
||||
|
||||
return () => {
|
||||
|
||||
14
frontend/src/hooks/useTeamMembers.ts
Normal file
14
frontend/src/hooks/useTeamMembers.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '../lib/apiClient';
|
||||
import type { TeamMember } from '../types';
|
||||
|
||||
export function useTeamMembers() {
|
||||
return useQuery({
|
||||
queryKey: ['team-members'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<TeamMember[]>('/team-members');
|
||||
return data;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
85
frontend/src/hooks/useTeamMembersAdmin.ts
Normal file
85
frontend/src/hooks/useTeamMembersAdmin.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient, getApiErrorMessage } from '../lib/apiClient';
|
||||
import type { TeamMember } from '../types';
|
||||
|
||||
export interface TeamMemberForm {
|
||||
name: string;
|
||||
rank: string;
|
||||
role: string;
|
||||
cell: string;
|
||||
contact: string;
|
||||
photoUrl: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export const EMPTY_MEMBER_FORM: TeamMemberForm = {
|
||||
name: '',
|
||||
rank: '',
|
||||
role: '',
|
||||
cell: 'HR',
|
||||
contact: '',
|
||||
photoUrl: '',
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
export function memberToForm(member: TeamMember): TeamMemberForm {
|
||||
return {
|
||||
name: member.name,
|
||||
rank: member.rank ?? '',
|
||||
role: member.role ?? '',
|
||||
cell: member.cell ?? '리더',
|
||||
contact: member.contact ?? '',
|
||||
photoUrl: member.photoUrl ?? '',
|
||||
sortOrder: member.sortOrder ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function formToPayload(form: TeamMemberForm) {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
rank: form.rank.trim() || null,
|
||||
role: form.role.trim() || null,
|
||||
cell: form.cell.trim() || null,
|
||||
contact: form.contact.trim() || null,
|
||||
photoUrl: form.photoUrl.trim() || null,
|
||||
sortOrder: Number(form.sortOrder) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTeamMembersAdmin() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['team-members', 'admin'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<TeamMember[]>('/team-members', {
|
||||
params: { all: '1' },
|
||||
});
|
||||
return data.filter((m) => m.isActive !== false);
|
||||
},
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['team-members'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['team-members', 'admin'] });
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (form: TeamMemberForm) =>
|
||||
apiClient.post('/team-members', formToPayload(form)),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, form }: { id: string; form: TeamMemberForm }) =>
|
||||
apiClient.patch(`/team-members/${id}`, formToPayload(form)),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/team-members/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { query, create, update, remove, getApiErrorMessage };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
66
frontend/src/lib/apiBase.ts
Normal file
66
frontend/src/lib/apiBase.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/** 배포(Render) 백엔드 — Vercel 등 외부 호스팅용 */
|
||||
export const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
|
||||
|
||||
/** 사설망·로컬 IP 여부 (로컬 서버 우선 연결) */
|
||||
export function isLocalNetworkHost(hostname: string): boolean {
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
||||
/^192\.168\./.test(hostname) ||
|
||||
/^10\./.test(hostname)
|
||||
);
|
||||
}
|
||||
|
||||
/** API·소켓·정적 파일이 붙는 백엔드 origin (프로토콜+호스트+포트) */
|
||||
export function getBackendOrigin(): string {
|
||||
const envUrl = import.meta.env.VITE_API_URL || import.meta.env.VITE_SOCKET_URL;
|
||||
if (envUrl) {
|
||||
return String(envUrl).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||
return `${window.location.protocol}//${window.location.hostname}:4000`;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && isLocalNetworkHost(window.location.hostname)) {
|
||||
return `${window.location.protocol}//${window.location.hostname}:4000`;
|
||||
}
|
||||
|
||||
return RENDER_API;
|
||||
}
|
||||
|
||||
/** REST API base (/api 포함) */
|
||||
export function getApiBaseUrl(): string {
|
||||
if (import.meta.env.VITE_API_URL) {
|
||||
return `${import.meta.env.VITE_API_URL.replace(/\/$/, '')}/api`;
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
return '/api';
|
||||
}
|
||||
if (typeof window !== 'undefined' && isLocalNetworkHost(window.location.hostname)) {
|
||||
return `${getBackendOrigin()}/api`;
|
||||
}
|
||||
return `${RENDER_API}/api`;
|
||||
}
|
||||
|
||||
export function getSocketUrl(): string {
|
||||
if (import.meta.env.VITE_SOCKET_URL) {
|
||||
return import.meta.env.VITE_SOCKET_URL;
|
||||
}
|
||||
return getBackendOrigin();
|
||||
}
|
||||
|
||||
/** /uploads/... 경로를 브라우저에서 열 수 있는 URL로 변환 */
|
||||
export function staticAssetUrl(path: string | null | undefined): string {
|
||||
if (!path) return '';
|
||||
if (/^https?:\/\//i.test(path) || path.startsWith('data:')) return path;
|
||||
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${getBackendOrigin()}${normalized}`;
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import { getApiBaseUrl } from './apiBase';
|
||||
|
||||
// 개발: Vite 프록시 → /api (localhost:4000)
|
||||
// 배포: VITE_API_URL 미설정 시 Render 백엔드 기본값 사용
|
||||
const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
|
||||
const baseURL = import.meta.env.VITE_API_URL
|
||||
? `${import.meta.env.VITE_API_URL}/api`
|
||||
: import.meta.env.PROD
|
||||
? `${RENDER_API}/api`
|
||||
: '/api';
|
||||
// 개발: Vite 프록시 /api → localhost:4000
|
||||
// 사설망 IP 접속: 자동으로 같은 IP:4000 백엔드
|
||||
// Vercel 배포: Render API (VITE_API_URL로 오버라이드 가능)
|
||||
const baseURL = getApiBaseUrl();
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL,
|
||||
|
||||
25
frontend/src/lib/taskFormPayload.ts
Normal file
25
frontend/src/lib/taskFormPayload.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { TaskFormData } from '../components/common/TaskModal';
|
||||
|
||||
export function taskFormToApiPayload(data: TaskFormData): Record<string, unknown> {
|
||||
return {
|
||||
title: data.title,
|
||||
section: data.section || null,
|
||||
tag: data.tag || null,
|
||||
taskType: data.taskType || null,
|
||||
status: data.status,
|
||||
progress: data.progress,
|
||||
description: data.description || null,
|
||||
issueNote: data.issueNote || null,
|
||||
startDate: data.startDate || null,
|
||||
dueDate: data.dueDate || null,
|
||||
showDate: data.showDate,
|
||||
showDescription: data.showDescription,
|
||||
showStatus: data.showStatus,
|
||||
showIssue: data.showIssue,
|
||||
showProgress: data.showProgress,
|
||||
keywords: data.keywords || null,
|
||||
quarter: data.quarter,
|
||||
pmMemberId: data.pmMemberId || null,
|
||||
assigneeMemberIds: data.assigneeMemberIds,
|
||||
};
|
||||
}
|
||||
121
frontend/src/lib/teamStatus.ts
Normal file
121
frontend/src/lib/teamStatus.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Task, TeamMember } from '../types';
|
||||
import { isRoutineTask } from './taskType';
|
||||
|
||||
/** 셀 컬럼 기본 순서 — 팀원 데이터에 다른 셀이 있으면 뒤에 추가 */
|
||||
export const DEFAULT_CELL_ORDER = ['HR', '총무'] as const;
|
||||
|
||||
/** DB 저장값 → 화면 표시명 (저장값은 그대로, 라벨만 변경) */
|
||||
const CELL_DISPLAY_LABELS: Record<string, string> = {
|
||||
HR: '인사',
|
||||
};
|
||||
|
||||
export function getCellLabel(cell: string | null | undefined): string {
|
||||
if (!cell) return '';
|
||||
return CELL_DISPLAY_LABELS[cell] ?? cell;
|
||||
}
|
||||
|
||||
const LEADER_CELLS = new Set(['', '리더', '팀장']);
|
||||
|
||||
export function isLeaderCell(cell: string | null | undefined): boolean {
|
||||
if (!cell || LEADER_CELLS.has(cell)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const RANK_ORDER: Record<string, number> = {
|
||||
수석연구원: 1,
|
||||
책임연구원: 2,
|
||||
선임연구원: 3,
|
||||
연구원: 4,
|
||||
};
|
||||
|
||||
const CELL_ORDER: Record<string, number> = {
|
||||
HR: 1,
|
||||
총무: 2,
|
||||
};
|
||||
|
||||
export function compareTeamMembers(a: TeamMember, b: TeamMember): number {
|
||||
const aLeader = a.role?.includes('팀장') || isLeaderCell(a.cell);
|
||||
const bLeader = b.role?.includes('팀장') || isLeaderCell(b.cell);
|
||||
if (aLeader && !bLeader) return -1;
|
||||
if (!aLeader && bLeader) return 1;
|
||||
|
||||
const aCell = CELL_ORDER[a.cell ?? ''] ?? 99;
|
||||
const bCell = CELL_ORDER[b.cell ?? ''] ?? 99;
|
||||
if (aCell !== bCell) return aCell - bCell;
|
||||
|
||||
const aCellLead = a.role?.includes('셀장');
|
||||
const bCellLead = b.role?.includes('셀장');
|
||||
if (aCellLead && !bCellLead) return -1;
|
||||
if (!aCellLead && bCellLead) return 1;
|
||||
|
||||
const aRank = RANK_ORDER[a.rank ?? ''] ?? 99;
|
||||
const bRank = RANK_ORDER[b.rank ?? ''] ?? 99;
|
||||
if (aRank !== bRank) return aRank - bRank;
|
||||
|
||||
const aOrder = a.sortOrder ?? 0;
|
||||
const bOrder = b.sortOrder ?? 0;
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
|
||||
return a.name.localeCompare(b.name, 'ko');
|
||||
}
|
||||
|
||||
export function groupTeamMembers(members: TeamMember[]) {
|
||||
const cellKeys: string[] = [...DEFAULT_CELL_ORDER];
|
||||
members.forEach((m) => {
|
||||
if (m.cell && !isLeaderCell(m.cell) && !cellKeys.includes(m.cell)) {
|
||||
cellKeys.push(m.cell);
|
||||
}
|
||||
});
|
||||
|
||||
const groups: Record<string, TeamMember[]> = { 리더: [] };
|
||||
cellKeys.forEach((k) => { groups[k] = []; });
|
||||
|
||||
members.forEach((m) => {
|
||||
if (isLeaderCell(m.cell) || m.role?.includes('팀장')) {
|
||||
groups.리더.push(m);
|
||||
} else if (m.cell && groups[m.cell]) {
|
||||
groups[m.cell].push(m);
|
||||
} else {
|
||||
groups.리더.push(m);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(groups).forEach((key) => {
|
||||
groups[key].sort(compareTeamMembers);
|
||||
});
|
||||
|
||||
return { groups, cellKeys };
|
||||
}
|
||||
|
||||
export function getMemberTasks(memberId: string, tasks: Task[]): Task[] {
|
||||
return tasks.filter((t) => {
|
||||
if (t.status === 'DONE' || t.status === 'CANCELLED') return false;
|
||||
if (t.pmMember?.id === memberId) return true;
|
||||
return t.assigneeMembers?.some((a) => a.id === memberId) ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
export function getHighlightMemberIds(task: Task | null | undefined): string[] {
|
||||
if (!task) return [];
|
||||
const ids: string[] = [];
|
||||
if (task.pmMember?.id) ids.push(task.pmMember.id);
|
||||
task.assigneeMembers?.forEach((m) => {
|
||||
if (m.id && !ids.includes(m.id)) ids.push(m.id);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function taskStatusBadge(task: Task): { label: string; variant: 'always' | 'hold' | 'wait' | 'progress' | 'done' } {
|
||||
if (task.taskType && isRoutineTask(task.taskType)) {
|
||||
return { label: '상시', variant: 'always' };
|
||||
}
|
||||
if (task.status === 'DONE') return { label: '완료', variant: 'done' };
|
||||
if (task.status === 'REVIEW') return { label: '홀딩', variant: 'hold' };
|
||||
if (task.status === 'TODO') return { label: '대기', variant: 'wait' };
|
||||
return { label: `${task.progress}%`, variant: 'progress' };
|
||||
}
|
||||
|
||||
export function taskSubtitle(task: Task): string {
|
||||
const parts = [task.section, task.tag].filter(Boolean);
|
||||
return parts.length ? `| ${parts.join(' · ')}` : '';
|
||||
}
|
||||
@@ -1,4 +1,234 @@
|
||||
// TODO: 관리자 페이지 UI 구현 예정
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TeamMemberFormModal } from '../components/admin/TeamMemberFormModal';
|
||||
import { TeamMemberAvatar } from '../components/dashboard/TeamMemberAvatar';
|
||||
import {
|
||||
EMPTY_MEMBER_FORM,
|
||||
memberToForm,
|
||||
useTeamMembersAdmin,
|
||||
type TeamMemberForm,
|
||||
} from '../hooks/useTeamMembersAdmin';
|
||||
import { getCellLabel, groupTeamMembers, isLeaderCell } from '../lib/teamStatus';
|
||||
import type { TeamMember } from '../types';
|
||||
|
||||
export default function AdminPage() {
|
||||
return <div>관리자 페이지 - 구현 예정</div>;
|
||||
const { query, create, update, remove, getApiErrorMessage } = useTeamMembersAdmin();
|
||||
const members = query.data ?? [];
|
||||
|
||||
const [modalMode, setModalMode] = useState<'add' | 'edit' | null>(null);
|
||||
const [editingMember, setEditingMember] = useState<TeamMember | null>(null);
|
||||
const [defaultCell, setDefaultCell] = useState('HR');
|
||||
|
||||
const { groups, cellKeys } = useMemo(() => groupTeamMembers(members), [members]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const list: { key: string; label: string; members: TeamMember[] }[] = [];
|
||||
if ((groups.리더?.length ?? 0) > 0) {
|
||||
list.push({ key: '리더', label: '팀장', members: groups.리더 });
|
||||
}
|
||||
cellKeys.forEach((key) => {
|
||||
const cellMembers = groups[key] ?? [];
|
||||
if (cellMembers.length > 0) {
|
||||
list.push({ key, label: getCellLabel(key), members: cellMembers });
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}, [groups, cellKeys]);
|
||||
|
||||
const openAdd = (cell = 'HR') => {
|
||||
setEditingMember(null);
|
||||
setDefaultCell(cell);
|
||||
setModalMode('add');
|
||||
};
|
||||
|
||||
const openEdit = (member: TeamMember) => {
|
||||
setEditingMember(member);
|
||||
setModalMode('edit');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalMode(null);
|
||||
setEditingMember(null);
|
||||
};
|
||||
|
||||
const handleSave = async (form: TeamMemberForm) => {
|
||||
try {
|
||||
if (modalMode === 'add') {
|
||||
await create.mutateAsync(form);
|
||||
} else if (editingMember) {
|
||||
await update.mutateAsync({ id: editingMember.id, form });
|
||||
}
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
alert(getApiErrorMessage(err, '저장에 실패했습니다.'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (member: TeamMember) => {
|
||||
if (!window.confirm(`"${member.name}" 팀원을 삭제(비활성)하시겠습니까?`)) return;
|
||||
try {
|
||||
await remove.mutateAsync(member.id);
|
||||
} catch (err) {
|
||||
alert(getApiErrorMessage(err, '삭제에 실패했습니다.'));
|
||||
}
|
||||
};
|
||||
|
||||
const saving = create.isPending || update.isPending;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<header className="admin-header">
|
||||
<div className="admin-header-left">
|
||||
<Link to="/" className="admin-back-link">← 대시보드</Link>
|
||||
<div className="admin-header-title">
|
||||
<span className="admin-header-org">총괄기획실</span>
|
||||
<span className="admin-header-sep">|</span>
|
||||
<span>팀원 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-header-actions">
|
||||
<button type="button" className="admin-btn-primary" onClick={() => openAdd('리더')}>
|
||||
+ 팀원 추가
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<div className="admin-stat-chips">
|
||||
<span className="admin-stat-chip admin-stat-total">
|
||||
전체 <strong>{members.length}</strong>명
|
||||
</span>
|
||||
<span className="admin-stat-chip">
|
||||
팀장 <strong>{groups.리더?.length ?? 0}</strong>명
|
||||
</span>
|
||||
<span className="admin-stat-chip">
|
||||
인사 <strong>{groups.HR?.length ?? 0}</strong>명
|
||||
</span>
|
||||
<span className="admin-stat-chip">
|
||||
총무 <strong>{groups.총무?.length ?? 0}</strong>명
|
||||
</span>
|
||||
</div>
|
||||
<p className="admin-toolbar-hint">
|
||||
등록한 팀원은 대시보드 <strong>팀 인원 현황</strong>에 표시됩니다. 업무 PM·담당자 선택에도 사용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<main className="admin-main">
|
||||
{query.isLoading && (
|
||||
<div className="admin-empty">데이터를 불러오는 중…</div>
|
||||
)}
|
||||
|
||||
{!query.isLoading && members.length === 0 && (
|
||||
<div className="admin-empty-card">
|
||||
<h3>등록된 팀원이 없습니다</h3>
|
||||
<p>팀장 1명, 인사 2명, 총무 2명 순으로 추가해 보세요.</p>
|
||||
<div className="admin-quick-add">
|
||||
<button type="button" className="admin-btn-outline" onClick={() => openAdd('리더')}>
|
||||
+ 팀장 추가
|
||||
</button>
|
||||
<button type="button" className="admin-btn-outline" onClick={() => openAdd('HR')}>
|
||||
+ 인사 추가
|
||||
</button>
|
||||
<button type="button" className="admin-btn-outline" onClick={() => openAdd('총무')}>
|
||||
+ 총무 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.map((section) => (
|
||||
<section key={section.key} className="admin-section">
|
||||
<div className="admin-section-head">
|
||||
<div className="admin-section-title">
|
||||
<span>{section.label}</span>
|
||||
<span className="admin-section-badge">{section.members.length}명</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-outline admin-btn-sm"
|
||||
onClick={() => openAdd(isLeaderCell(section.key) ? '리더' : section.key)}
|
||||
>
|
||||
+ {section.label} 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-member-table-wrap">
|
||||
<table className="admin-member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-avatar" />
|
||||
<th>이름</th>
|
||||
<th>직급 · 직책</th>
|
||||
<th>연락처</th>
|
||||
<th>순서</th>
|
||||
<th className="w-actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.members.map((member) => (
|
||||
<tr key={member.id}>
|
||||
<td>
|
||||
<TeamMemberAvatar member={member} size="member" />
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-member-name">{member.name}</span>
|
||||
<span className="admin-member-cell-tag">{getCellLabel(member.cell) || '팀장'}</span>
|
||||
</td>
|
||||
<td>
|
||||
{[member.rank, member.role].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
<td className="admin-contact">{member.contact ?? '—'}</td>
|
||||
<td className="admin-sort">{member.sortOrder ?? 0}</td>
|
||||
<td>
|
||||
<div className="admin-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
title="수정"
|
||||
onClick={() => openEdit(member)}
|
||||
>
|
||||
✏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn admin-icon-btn-danger"
|
||||
title="삭제"
|
||||
onClick={() => handleDelete(member)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{members.length > 0 && (
|
||||
<div className="admin-footer-note">
|
||||
<Link to="/" className="admin-preview-link">
|
||||
대시보드에서 팀 인원 현황 미리보기 →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{modalMode && (
|
||||
<TeamMemberFormModal
|
||||
mode={modalMode}
|
||||
initial={
|
||||
modalMode === 'edit' && editingMember
|
||||
? memberToForm(editingMember)
|
||||
: { ...EMPTY_MEMBER_FORM, cell: defaultCell }
|
||||
}
|
||||
onSave={handleSave}
|
||||
onClose={closeModal}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import { apiClient } from '../lib/apiClient';
|
||||
import { useTasks } from '../hooks/useTasks';
|
||||
import { useTeamMembers } from '../hooks/useTeamMembers';
|
||||
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
|
||||
import { TeamStatusPanel } from '../components/dashboard/TeamStatusPanel';
|
||||
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
|
||||
import { TaskManager } from '../components/dashboard/TaskManager';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
@@ -42,6 +44,9 @@ export default function DashboardPage() {
|
||||
const [filtersBeforeIssue, setFiltersBeforeIssue] = useState<string[]>([...DEFAULT_STATUS_FILTERS]);
|
||||
const [issueFilterActive, setIssueFilterActive] = useState(false);
|
||||
const [showTaskManager, setShowTaskManager] = useState(false);
|
||||
const [teamPanelOpen, setTeamPanelOpen] = useState(false);
|
||||
const [showAllTeamTasks, setShowAllTeamTasks] = useState(false);
|
||||
const [activeTeamProjectId, setActiveTeamProjectId] = useState<string | null>(null);
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
|
||||
const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
@@ -50,6 +55,7 @@ export default function DashboardPage() {
|
||||
const socket = useSocket();
|
||||
|
||||
const { data: tasks = [], isLoading } = useTasks({ quarter: QUARTER });
|
||||
const { data: teamMembers = [] } = useTeamMembers();
|
||||
|
||||
const { data: colConfigs } = useQuery({
|
||||
queryKey: ['columns', 'all'],
|
||||
@@ -248,9 +254,35 @@ export default function DashboardPage() {
|
||||
onToggleIssue={handleToggleIssue}
|
||||
onOpenDetailWindow={() => { openDetailWindow(); }}
|
||||
onOpenTaskManager={() => setShowTaskManager(true)}
|
||||
teamPanelOpen={teamPanelOpen}
|
||||
onToggleTeamPanel={() => {
|
||||
setTeamPanelOpen((open) => {
|
||||
if (open) {
|
||||
setActiveTeamProjectId(null);
|
||||
setShowAllTeamTasks(false);
|
||||
}
|
||||
return !open;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="relative flex min-h-0 flex-1 overflow-hidden px-5 py-5">
|
||||
{teamPanelOpen && (
|
||||
<TeamStatusPanel
|
||||
members={teamMembers}
|
||||
tasks={tasks}
|
||||
showAllTasks={showAllTeamTasks}
|
||||
activeProjectId={activeTeamProjectId}
|
||||
onToggleShowAll={() => setShowAllTeamTasks((v) => !v)}
|
||||
onProjectClick={setActiveTeamProjectId}
|
||||
onClose={() => {
|
||||
setTeamPanelOpen(false);
|
||||
setActiveTeamProjectId(null);
|
||||
setShowAllTeamTasks(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className={`relative flex min-h-0 flex-1 overflow-hidden px-5 py-5 ${teamPanelOpen ? 'hidden' : ''}`}>
|
||||
{/* ── 좌측 라벨 컬럼 ── */}
|
||||
<div className="mr-4 flex w-16 shrink-0 flex-col overflow-hidden rounded-[2rem] bg-white shadow-[0_16px_40px_rgba(15,23,42,0.12)] ring-1 ring-slate-200/70">
|
||||
<div className="h-10 shrink-0 border-b border-slate-100 bg-slate-50" />
|
||||
@@ -283,6 +315,7 @@ export default function DashboardPage() {
|
||||
quarter={QUARTER}
|
||||
onSelectTask={(t) => sendTaskSelected(t.id)}
|
||||
sectionOptions={sectionOptions}
|
||||
teamMembers={teamMembers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -307,6 +340,7 @@ export default function DashboardPage() {
|
||||
tasks={tasks}
|
||||
sectionOptions={sectionOptions}
|
||||
quarter={QUARTER}
|
||||
teamMembers={teamMembers}
|
||||
onClose={() => setShowTaskManager(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,23 @@ export interface User {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
rank: string | null;
|
||||
role: string | null;
|
||||
cell: string | null;
|
||||
contact: string | null;
|
||||
photoUrl: string | null;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export type TeamMemberBrief = Pick<
|
||||
TeamMember,
|
||||
'id' | 'name' | 'rank' | 'role' | 'cell' | 'contact' | 'photoUrl'
|
||||
>;
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -35,10 +52,13 @@ export interface Task {
|
||||
keywords: string | null;
|
||||
creatorId: string;
|
||||
assigneeId: string | null;
|
||||
pmMemberId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
assignee?: Pick<User, 'id' | 'name' | 'department'> | null;
|
||||
creator?: Pick<User, 'id' | 'name'>;
|
||||
pmMember?: TeamMemberBrief | null;
|
||||
assigneeMembers?: TeamMemberBrief[];
|
||||
_count?: { files: number; details: number };
|
||||
}
|
||||
|
||||
|
||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "eene-dashboard",
|
||||
"private": true,
|
||||
"description": "EENE 인재성장팀 대시보드 — 로컬·배포 통합",
|
||||
"scripts": {
|
||||
"local:db": "docker compose up -d",
|
||||
"local:db:stop": "docker compose down",
|
||||
"local:setup": "cd backend && npm run db:sync",
|
||||
"local:api": "cd backend && npm run dev",
|
||||
"local:web": "cd frontend && npm run dev",
|
||||
"local:all": "npm run local:db && npm run local:setup"
|
||||
}
|
||||
}
|
||||
4
scripts/get-lan-ip.ps1
Normal file
4
scripts/get-lan-ip.ps1
Normal file
@@ -0,0 +1,4 @@
|
||||
$ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.IPAddress -match '^(172\.|192\.168\.|10\.)' } |
|
||||
Select-Object -First 1 -ExpandProperty IPAddress
|
||||
if ($ip) { Write-Output $ip }
|
||||
11
scripts/kill-ports.ps1
Normal file
11
scripts/kill-ports.ps1
Normal file
@@ -0,0 +1,11 @@
|
||||
$ports = @(4000, 3000, 3001)
|
||||
$killed = @()
|
||||
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
|
||||
Where-Object { $ports -contains $_.LocalPort } |
|
||||
ForEach-Object {
|
||||
$pid = $_.OwningProcess
|
||||
if ($killed -notcontains $pid) {
|
||||
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
|
||||
$killed += $pid
|
||||
}
|
||||
}
|
||||
9
scripts/wait-db.ps1
Normal file
9
scripts/wait-db.ps1
Normal file
@@ -0,0 +1,9 @@
|
||||
$ok = $false
|
||||
1..30 | ForEach-Object {
|
||||
if (Test-NetConnection -ComputerName localhost -Port 5432 -WarningAction SilentlyContinue -ErrorAction SilentlyContinue | Where-Object { $_.TcpTestSucceeded }) {
|
||||
$ok = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (-not $ok) { exit 1 }
|
||||
140
start-server.bat
Normal file
140
start-server.bat
Normal file
@@ -0,0 +1,140 @@
|
||||
@echo off
|
||||
REM Keep window open via cmd /k
|
||||
if /i not "%~1"=="_run" (
|
||||
cd /d "%~dp0"
|
||||
cmd /k "%~f0" _run
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
setlocal EnableDelayedExpansion
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
title EENE Dashboard - Running
|
||||
|
||||
echo ================================
|
||||
echo EENE Dashboard - Server Start
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
REM --- [1/5] Stop old API/WEB ---
|
||||
echo [1/5] Stopping old API/WEB servers...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\kill-ports.ps1" 2>nul
|
||||
ping 127.0.0.1 -n 3 >nul
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
REM --- [2/5] Database ---
|
||||
echo [2/5] Starting Database...
|
||||
set "DB_MODE=unknown"
|
||||
|
||||
where docker >nul 2>&1
|
||||
if %errorlevel%==0 if exist "%~dp0docker-compose.yml" (
|
||||
echo Trying Docker...
|
||||
docker compose up -d 2>nul
|
||||
if !errorlevel!==0 set "DB_MODE=docker"
|
||||
)
|
||||
|
||||
if "!DB_MODE!"=="unknown" (
|
||||
sc query postgresql-x64-16 2>nul | findstr /i "RUNNING" >nul 2>&1
|
||||
if !errorlevel!==0 (
|
||||
set "DB_MODE=windows"
|
||||
echo Windows PostgreSQL running.
|
||||
) else (
|
||||
sc query postgresql-x64-16 >nul 2>&1
|
||||
if !errorlevel!==0 (
|
||||
echo Starting Windows PostgreSQL...
|
||||
net start postgresql-x64-16 >nul 2>&1
|
||||
if !errorlevel!==0 set "DB_MODE=windows"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if "!DB_MODE!"=="unknown" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\wait-db.ps1" >nul 2>&1
|
||||
if !errorlevel!==0 (
|
||||
set "DB_MODE=local"
|
||||
echo PostgreSQL on port 5432 detected.
|
||||
)
|
||||
)
|
||||
|
||||
if "!DB_MODE!"=="unknown" (
|
||||
echo.
|
||||
echo [ERROR] No database found.
|
||||
echo - Start postgresql-x64-16 service, OR
|
||||
echo - Start Docker Desktop and retry
|
||||
echo - Check backend\.env DATABASE_URL:
|
||||
echo postgresql://postgres:eee_password@localhost:5432/eee_dashboard
|
||||
echo.
|
||||
goto :end_error
|
||||
)
|
||||
|
||||
echo DB mode: !DB_MODE!
|
||||
echo.
|
||||
|
||||
REM --- [3/5] Wait DB port ---
|
||||
echo [3/5] Waiting for port 5432...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\wait-db.ps1"
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Port 5432 not ready.
|
||||
goto :end_error
|
||||
)
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
REM --- [4/5] Schema sync ---
|
||||
echo [4/5] DB schema sync...
|
||||
cd /d "%~dp0backend"
|
||||
if not exist "node_modules\" (
|
||||
echo npm install - backend...
|
||||
call npm install
|
||||
if errorlevel 1 goto :end_error
|
||||
)
|
||||
call npm run db:sync
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [ERROR] DB sync failed. Check backend\.env DATABASE_URL
|
||||
echo postgresql://postgres:eee_password@localhost:5432/eee_dashboard
|
||||
goto :end_error
|
||||
)
|
||||
call npx prisma generate
|
||||
if errorlevel 1 goto :end_error
|
||||
cd /d "%~dp0"
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
REM --- [5/5] Start servers ---
|
||||
echo [5/5] Starting Backend + Frontend...
|
||||
if not exist "%~dp0frontend\node_modules\" (
|
||||
echo npm install - frontend...
|
||||
cd /d "%~dp0frontend"
|
||||
call npm install
|
||||
if errorlevel 1 goto :end_error
|
||||
cd /d "%~dp0"
|
||||
)
|
||||
if not exist "%~dp0uploads\team\" mkdir "%~dp0uploads\team" 2>nul
|
||||
|
||||
set "LAN_IP=localhost"
|
||||
for /f "delims=" %%I in ('powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\get-lan-ip.ps1" 2^>nul') do set "LAN_IP=%%I"
|
||||
|
||||
echo.
|
||||
echo Dashboard : http://localhost:3000
|
||||
echo Admin : http://localhost:3000/admin
|
||||
echo LAN : http://!LAN_IP!:3000
|
||||
echo Stop : stop-server.bat
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
npx --yes concurrently -k -n "API,WEB" -c "cyan,green" "cd /d %~dp0backend && npm run dev" "cd /d %~dp0frontend && npm run dev"
|
||||
|
||||
echo.
|
||||
echo Server stopped.
|
||||
goto :end_ok
|
||||
|
||||
:end_error
|
||||
echo.
|
||||
echo Press any key to close...
|
||||
pause >nul
|
||||
exit /b 1
|
||||
|
||||
:end_ok
|
||||
pause
|
||||
33
stop-server.bat
Normal file
33
stop-server.bat
Normal file
@@ -0,0 +1,33 @@
|
||||
@echo off
|
||||
if /i not "%~1"=="_run" (
|
||||
cd /d "%~dp0"
|
||||
cmd /k "%~f0" _run %2 %3
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
setlocal
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
title EENE Dashboard - Stop
|
||||
|
||||
echo ================================
|
||||
echo EENE Dashboard - Server Stop
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
echo [1/2] Stopping API/WEB...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\kill-ports.ps1"
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
echo [2/2] Database...
|
||||
if /i "%~2"=="docker" (
|
||||
docker compose down 2>nul
|
||||
echo Docker DB stopped.
|
||||
) else (
|
||||
echo DB kept running. To stop Docker: stop-server.bat docker
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Done.
|
||||
pause
|
||||
29
데이터가져오기.bat
Normal file
29
데이터가져오기.bat
Normal file
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
if /i not "%~1"=="_run" (
|
||||
cd /d "%~dp0"
|
||||
cmd /k "%~f0" _run
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0backend"
|
||||
|
||||
echo ================================
|
||||
echo EENE - Import data from Vercel
|
||||
echo Source: eene-dashboard-backend.onrender.com
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
call npm run db:sync-remote
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
echo.
|
||||
echo Done. Open http://localhost:3000
|
||||
goto :end
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo Import failed. Check error above.
|
||||
|
||||
:end
|
||||
pause
|
||||
31
데이터배포.bat
Normal file
31
데이터배포.bat
Normal file
@@ -0,0 +1,31 @@
|
||||
@echo off
|
||||
if /i not "%~1"=="_run" (
|
||||
cd /d "%~dp0"
|
||||
cmd /k "%~f0" _run
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0backend"
|
||||
|
||||
echo ================================
|
||||
echo EENE - Push data to Render
|
||||
echo Target: eene-dashboard-backend.onrender.com
|
||||
echo ================================
|
||||
echo.
|
||||
echo NOTE: Code must be deployed first (git push).
|
||||
echo.
|
||||
|
||||
call npm run db:push-remote
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
echo.
|
||||
echo Open https://eene-dashboard.vercel.app/
|
||||
goto :end
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo Push failed. Deploy code first, then retry.
|
||||
|
||||
:end
|
||||
pause
|
||||
66
서버시작.bat
66
서버시작.bat
@@ -1,64 +1,8 @@
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ================================
|
||||
echo EENE Dashboard - Server Start
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
:: 0. 기존 서버 종료 (title 설정 전에 실행 — 자기 자신 종료 방지)
|
||||
echo [1/4] Stopping old servers...
|
||||
taskkill /fi "WindowTitle eq EENE Dashboard - Running*" /f > nul 2>&1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { @(4000,3000,3001) -contains $_.LocalPort } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }" > nul 2>&1
|
||||
timeout /t 2 /nobreak > nul
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
title EENE Dashboard - Running
|
||||
|
||||
:: 1. PostgreSQL 서비스 확인
|
||||
echo [2/4] 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
|
||||
if not exist "%~dp0start-server.bat" (
|
||||
echo [ERROR] start-server.bat not found.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Database is ready.
|
||||
echo.
|
||||
|
||||
:: 2. DB 마이그레이션 + Prisma 클라이언트 갱신
|
||||
echo [3/4] DB migrate + Prisma generate...
|
||||
cd /d "%~dp0backend"
|
||||
call npx prisma migrate deploy
|
||||
if errorlevel 1 (
|
||||
echo Migration failed. backend\.env DATABASE_URL 확인
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
call npx prisma generate
|
||||
if errorlevel 1 (
|
||||
echo Prisma generate failed. 다른 터미널/서버가 켜져 있으면 종료 후 다시 시도하세요.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
cd /d "%~dp0"
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
:: 3. 백엔드 + 프론트엔드
|
||||
echo [4/4] Starting Backend + Frontend...
|
||||
echo.
|
||||
echo Dashboard : http://localhost:3000
|
||||
echo Detail : http://localhost:3000/detail
|
||||
echo Team : http://172.16.8.248:3000
|
||||
echo.
|
||||
echo 종료: Ctrl+C
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
npx --yes concurrently -k -n "API,WEB" -c "cyan,green" "cd /d %~dp0backend && npm run dev" "cd /d %~dp0frontend && npm run dev"
|
||||
|
||||
echo.
|
||||
echo Server stopped.
|
||||
pause
|
||||
call "%~dp0start-server.bat"
|
||||
|
||||
22
서버종료.bat
22
서버종료.bat
@@ -1,23 +1,3 @@
|
||||
@echo off
|
||||
chcp 65001 > nul
|
||||
title EENE Dashboard - Stop
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ================================
|
||||
echo EENE Dashboard - Server Stop
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
echo [1/2] Closing server window...
|
||||
taskkill /fi "WindowTitle eq EENE Dashboard - Running*" /f > nul 2>&1
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
echo [2/2] Stopping API / WEB (ports 4000, 3000, 3001)...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { @(4000,3000,3001) -contains $_.LocalPort } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }" > nul 2>&1
|
||||
echo Done.
|
||||
echo.
|
||||
|
||||
echo All servers stopped.
|
||||
echo (PostgreSQL keeps running as Windows service)
|
||||
pause
|
||||
call "%~dp0stop-server.bat" %*
|
||||
|
||||
Reference in New Issue
Block a user