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:
EENE Dashboard
2026-06-06 01:41:00 +09:00
parent d14ff1997c
commit fb2956b0ac
45 changed files with 4104 additions and 376 deletions

12
.env.docker.example Normal file
View 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"

View File

@@ -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 ─────────────────────────────────────────────────

View File

@@ -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 | 팀원 사진 업로드 (로컬 저장) |

View File

@@ -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",

View File

@@ -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

View 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());

View 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());

View File

@@ -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;
}

View File

@@ -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})`);

View 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가 보존됩니다.',
);
}
}

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

View 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 이미지만 업로드할 수 있습니다.'));
}
},
});

View File

@@ -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);

View File

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

View 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;

View 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

View 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,
);
}

View File

@@ -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>

View File

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

View File

@@ -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); }}
/>

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

View File

@@ -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); }}
/>

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

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

View File

@@ -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 () => {

View 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,
});
}

View 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

View 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}`;
}

View File

@@ -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,

View 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,
};
}

View 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(' · ')}` : '';
}

View File

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

View File

@@ -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)}
/>
)}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -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"

View File

@@ -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" %*