diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..e00ced9 --- /dev/null +++ b/.env.docker.example @@ -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" diff --git a/.env.example b/.env.example index 7b60b71..8661b8e 100644 --- a/.env.example +++ b/.env.example @@ -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 ───────────────────────────────────────────────── diff --git a/README.md b/README.md index d64357c..1619818 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,45 @@ npm run dev --- +## 로컬 전용 운영 (데이터 PC에 영구 저장) + +Render 용량 제한 시 **이 PC만으로** 운영할 수 있습니다. 모든 데이터는 아래 폴더에 저장됩니다. + +| 데이터 | 저장 위치 | +|--------|-----------| +| 업무·팀원·KPI 등 | `data/postgres/` (Docker PostgreSQL 볼륨) | +| 업무 첨부 파일 | `uploads/` | +| 팀원 프로필 사진 | `uploads/team/` | + +### 빠른 시작 + +**Windows:** `서버시작.bat` 더블클릭 (DB 시작 → 스키마 동기화 → API+WEB 실행) +**종료:** `서버종료.bat` (API/WEB만) · `서버종료.bat docker` (Docker DB까지 중지, 데이터는 유지) + +수동 실행: + +```bash +npm run local:db # PostgreSQL 컨테이너 시작 +npm run local:setup # DB 스키마 동기화 +npm run local:api # 백엔드 :4000 (터미널 1) +npm run local:web # 프론트 :3000 (터미널 2) +``` + +### 접속 주소 + +| 화면 | 주소 | +|------|------| +| 대시보드 | `http://localhost:3000` | +| 팀원 관리 | `http://localhost:3000/admin` | +| API | `http://localhost:4000/api` | + +사설망 IP(`172.x`, `192.168.x`)로 접속하면 **자동으로 같은 IP의 :4000 백엔드**에 연결됩니다. (Render 서버 불필요) + +> `backend/.env` 의 `DATABASE_URL` 이 Docker 계정과 맞아야 합니다. +> 기본: `postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard` + +--- + ## 서버(이 PC) IP 주소 확인 ```powershell @@ -160,3 +199,6 @@ D:\EENE_Dashboard\ | DELETE | /api/files/:id | 파일 삭제 | | GET | /api/kpi | KPI 조회 | | POST | /api/kpi | KPI 등록 | +| GET | /api/team-members | 팀원 목록 | +| POST | /api/team-members | 팀원 등록 | +| POST | /api/team-members/photo | 팀원 사진 업로드 (로컬 저장) | diff --git a/backend/package.json b/backend/package.json index 839a5d5..534414d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b2e83fe..465d61c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/scripts/sync-from-remote.ts b/backend/scripts/sync-from-remote.ts new file mode 100644 index 0000000..f081b6e --- /dev/null +++ b/backend/scripts/sync-from-remote.ts @@ -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(path: string): Promise { + const res = await fetch(`${SOURCE}${path}`); + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); + return res.json() as Promise; +} + +async function ensureUser(remote?: RemoteUser | null, fallbackEmail?: string): Promise { + 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 { + 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(); + 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('/api/tasks'); + console.log(` Found ${list.length} tasks`); + + const fullTasks: RemoteTask[] = []; + for (const item of list) { + const full = await fetchJson(`/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(); + 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(); + 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()); diff --git a/backend/scripts/sync-to-remote.ts b/backend/scripts/sync-to-remote.ts new file mode 100644 index 0000000..acd0ca4 --- /dev/null +++ b/backend/scripts/sync-to-remote.ts @@ -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(method: string, path: string, body?: unknown): Promise { + 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; +} + +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> { + 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('GET', '/api/team-members?all=1'); + } catch { + remotes = await api('GET', '/api/team-members'); + } + + const remoteByKey = new Map(remotes.map((m) => [memberKey(m.name, m.cell), m])); + const idMap = new Map(); + + 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('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>('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) { + 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(); + 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('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()); diff --git a/backend/src/app.ts b/backend/src/app.ts index 1cafa99..2256f97 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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; } diff --git a/backend/src/index.ts b/backend/src/index.ts index 5302277..a94925d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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})`); diff --git a/backend/src/lib/ensureLocalDirs.ts b/backend/src/lib/ensureLocalDirs.ts new file mode 100644 index 0000000..eb0c3e0 --- /dev/null +++ b/backend/src/lib/ensureLocalDirs.ts @@ -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가 보존됩니다.', + ); + } +} diff --git a/backend/src/lib/taskQuery.ts b/backend/src/lib/taskQuery.ts new file mode 100644 index 0000000..15c5aa9 --- /dev/null +++ b/backend/src/lib/taskQuery.ts @@ -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>(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[] | undefined { + if (body.assigneeMemberIds === undefined) return undefined; + const raw = body.assigneeMemberIds; + if (!Array.isArray(raw)) return []; + return raw.map(String).filter(Boolean); +} diff --git a/backend/src/middleware/uploadTeamPhoto.ts b/backend/src/middleware/uploadTeamPhoto.ts new file mode 100644 index 0000000..8a75861 --- /dev/null +++ b/backend/src/middleware/uploadTeamPhoto.ts @@ -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 이미지만 업로드할 수 있습니다.')); + } + }, +}); diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index ee9ecc8..a15a926 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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); diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index af1153a..dc2cca8 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -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; 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; + showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body; if (!title || !quarter) { throw new AppError(400, '제목과 분기는 필수입니다.'); } - const creatorId = await resolveCreatorId((req.body as Record).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; 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; + 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); } diff --git a/backend/src/routes/teamMembers.ts b/backend/src/routes/teamMembers.ts new file mode 100644 index 0000000..f38cbe2 --- /dev/null +++ b/backend/src/routes/teamMembers.ts @@ -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; + + 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; + + 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; diff --git a/frontend/.env.local.example b/frontend/.env.local.example new file mode 100644 index 0000000..42ae8a4 --- /dev/null +++ b/frontend/.env.local.example @@ -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 diff --git a/frontend/src/components/admin/TeamMemberFormModal.tsx b/frontend/src/components/admin/TeamMemberFormModal.tsx new file mode 100644 index 0000000..63a1096 --- /dev/null +++ b/frontend/src/components/admin/TeamMemberFormModal.tsx @@ -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; + onClose: () => void; + saving?: boolean; +} + +export function TeamMemberFormModal({ + mode, + initial = EMPTY_MEMBER_FORM, + onSave, + onClose, + saving = false, +}: TeamMemberFormModalProps) { + const [form, setForm] = useState(initial); + const [uploadingPhoto, setUploadingPhoto] = useState(false); + + const set = (key: K, value: TeamMemberForm[K]) => + setForm((prev) => ({ ...prev, [key]: value })); + + const handlePhotoFile = async (e: React.ChangeEvent) => { + 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( +
+
e.stopPropagation()}> +
+

{mode === 'add' ? '팀원 추가' : '팀원 수정'}

+ +
+ +
+
+ +
+
{form.name || '이름'}
+
+ {[form.rank, form.role].filter(Boolean).join(' · ') || '직급 · 직책'} +
+
{getCellLabel(form.cell) || '셀'}
+
+
+ +
+ + + + + + + + + + + + +
+ 프로필 사진 +
+ + 로컬 서버 uploads/team/ 에 저장됩니다 +
+ set('photoUrl', e.target.value)} + placeholder="또는 URL 직접 입력 (/uploads/team/...)" + /> +
+
+ + + {RANK_OPTIONS.map((r) => + + {ROLE_OPTIONS.map((r) => + +
+ + +
+
+
+
, + document.body, + ); +} diff --git a/frontend/src/components/common/TaskModal.tsx b/frontend/src/components/common/TaskModal.tsx index cd8b184..dac053c 100644 --- a/frontend/src/components/common/TaskModal.tsx +++ b/frontend/src/components/common/TaskModal.tsx @@ -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 = (field: K, value: TaskFormData[K]) => setForm(prev => ({ ...prev, [field]: value })); @@ -226,6 +252,53 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter = /> + {/* PM + 담당자 */} + {teamMembers.length > 0 && ( +
+
+ + +
+
+ +
+ {teamMembers.map((m) => { + const checked = form.assigneeMemberIds.includes(m.id); + return ( + + ); + })} +
+
+
+ )} + {/* 키워드 */}
diff --git a/frontend/src/components/dashboard/DashboardHeader.tsx b/frontend/src/components/dashboard/DashboardHeader.tsx index 5d1324a..3e16057 100644 --- a/frontend/src/components/dashboard/DashboardHeader.tsx +++ b/frontend/src/components/dashboard/DashboardHeader.tsx @@ -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; - 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), - }, - { - label: '이슈', - value: stats.issues, - statusKey: 'ISSUES', - onClick: onToggleIssue, - isActive: issueFilterActive, - }, - ]; - - return ( -
-
- - 총괄기획실 - | - People Growth Hub - - -
- -
-
- {quarterLabel} - · - {statItems.map((item, index) => ( - - {(index === 1 || index === 4) && } - - - ))} -
-
- -
- - -
-
- ); -} - -function StatDivider() { - return
; -} - -interface StatClickProps { - label: string; - value: number; - accent: string; - isActive: boolean; - onClick: () => void; -} - -function StatClick({ label, value, accent, isActive, onClick }: StatClickProps) { - return ( - { - 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}{' '} - {value} - - - ); -} - \ No newline at end of file +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; + + 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 ( + +
+ +
+ + + + 총괄기획실 + + | + + People Growth Hub + + + + + +
+ + + +
+ +
+ + {quarterLabel} + + · + + {statItems.map((item, index) => ( + + + + {(index === 1 || index === 4) && } + + + + + + ))} + +
+ +
+ + + +
+ + + + + +
+ +
+ + ); + +} + + + +function StatDivider() { + + return
; + +} + + + +interface StatClickProps { + + label: string; + + value: number; + + accent: string; + + isActive: boolean; + + onClick: () => void; + +} + + + +function StatClick({ label, value, accent, isActive, onClick }: StatClickProps) { + + return ( + + { + + 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}{' '} + + {value} + + + + + + ); + +} + + diff --git a/frontend/src/components/dashboard/DepartmentColumn.tsx b/frontend/src/components/dashboard/DepartmentColumn.tsx index 97c6793..8de0f87 100644 --- a/frontend/src/components/dashboard/DepartmentColumn.tsx +++ b/frontend/src/components/dashboard/DepartmentColumn.tsx @@ -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); 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); }} /> diff --git a/frontend/src/components/dashboard/MemberTaskTooltip.tsx b/frontend/src/components/dashboard/MemberTaskTooltip.tsx new file mode 100644 index 0000000..eed15e4 --- /dev/null +++ b/frontend/src/components/dashboard/MemberTaskTooltip.tsx @@ -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 ( +
+
+ 참여 중인 업무 ({memberTasks.length}) +
+
+ {memberTasks.map((task) => { + const badge = taskStatusBadge(task); + const isActive = activeProjectId === task.id; + const subtitle = taskSubtitle(task); + + return ( +
{ + 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); + } + }} + > +
+ +
+
+ {task.title} + {subtitle && {subtitle}} +
+ {isActive && ( +
+ + PM: {task.pmMember?.name ?? '미정'} + + + 담당자:{' '} + + {task.assigneeMembers?.length + ? task.assigneeMembers.map((m) => m.name).join(', ') + : '미정'} + + +
+ )} +
+ + {badge.label} + +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/TaskManager.tsx b/frontend/src/components/dashboard/TaskManager.tsx index 3f28828..b1cdc6b 100644 --- a/frontend/src/components/dashboard/TaskManager.tsx +++ b/frontend/src/components/dashboard/TaskManager.tsx @@ -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 = { 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('전체'); const [filterType, setFilterType] = useState('전체'); @@ -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); }} /> diff --git a/frontend/src/components/dashboard/TeamMemberAvatar.tsx b/frontend/src/components/dashboard/TeamMemberAvatar.tsx new file mode 100644 index 0000000..c0d8dce --- /dev/null +++ b/frontend/src/components/dashboard/TeamMemberAvatar.tsx @@ -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 ( + {member.name} setImgError(true)} + /> + ); + } + + return ( +
+ {initial} +
+ ); +} diff --git a/frontend/src/components/dashboard/TeamStatusPanel.tsx b/frontend/src/components/dashboard/TeamStatusPanel.tsx new file mode 100644 index 0000000..a29868d --- /dev/null +++ b/frontend/src/components/dashboard/TeamStatusPanel.tsx @@ -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 ( + + + + + + ); +} + +function CloseIcon({ size = 20 }: { size?: number }) { + return ( + + + + + ); +} + +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 ( + <> + {member.name} + {member.rank && {member.rank}} + {member.role && {member.role}} + {member.contact && {member.contact}} + + ); + } + + if (isLeader) { + return ( + <> +
+ {member.name} + + {[member.rank, member.role].filter(Boolean).join(' · ')} + +
+ {member.contact && {member.contact}} + + ); + } + + return ( + <> +
+ {member.name} + + {[member.rank, member.role].filter(Boolean).join(' · ')} + +
+ {member.contact && {member.contact}} + + ); +} + +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 ( +
+
+
+ + 팀 인원 현황 + 0명 +
+ +
+
+

등록된 팀원이 없습니다.

+

+ 팀원 관리 + 에서 등록할 수 있습니다. +

+
+
+ ); + } + + const renderMemberCard = (member: TeamMember, isLeader: boolean) => { + const highlighted = highlightIds.includes(member.id); + const cardClass = isLeader ? 'tree-leader-card' : 'tree-member-card'; + + return ( +
e.stopPropagation()} + > + +
+ +
+ +
+ ); + }; + + return ( +
+
+
+ + 팀 인원 현황 + {members.length}명 +
+
+ + +
+
+ +
onProjectClick(null)} + > + {leaders.length > 0 && ( + <> +
+ {leaders.map((m) => renderMemberCard(m, true))} +
+ {visibleCells.length > 0 &&
} + + )} + + {visibleCells.length > 0 && ( +
+ {visibleCells.map((cellKey, index, arr) => { + const cellMembers = groups[cellKey] ?? []; + return ( +
+
+
+ {getCellLabel(cellKey)} + {cellMembers.length}명 +
+
+ {cellMembers.map((m) => renderMemberCard(m, false))} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/contexts/SocketContext.tsx b/frontend/src/contexts/SocketContext.tsx index 998171f..a88f708 100644 --- a/frontend/src/contexts/SocketContext.tsx +++ b/frontend/src/contexts/SocketContext.tsx @@ -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(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(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 () => { diff --git a/frontend/src/hooks/useTeamMembers.ts b/frontend/src/hooks/useTeamMembers.ts new file mode 100644 index 0000000..b224b82 --- /dev/null +++ b/frontend/src/hooks/useTeamMembers.ts @@ -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('/team-members'); + return data; + }, + staleTime: 60_000, + }); +} diff --git a/frontend/src/hooks/useTeamMembersAdmin.ts b/frontend/src/hooks/useTeamMembersAdmin.ts new file mode 100644 index 0000000..148d7a6 --- /dev/null +++ b/frontend/src/hooks/useTeamMembersAdmin.ts @@ -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('/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 }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 61a6d8e..9a8930e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -286,3 +286,1139 @@ body, box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.3); } +/* ─── 팀 인원 현황 패널 (참고 사이트) ─── */ +:root { + --bg-beige: #f4f1ea; + --brown-main: #6b4c35; +} + +.team-overlay { + position: absolute; + inset: 48px 0 0; + z-index: 50; + display: flex; + flex-direction: column; + background: var(--bg-beige); + animation: teamFadeSlideIn 0.22s ease; +} + +@keyframes teamFadeSlideIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.team-panel-header { + flex: 0 0 48px; + display: flex; + align-items: center; + gap: 16px; + height: 48px; + padding: 12px 40px 0; + background: transparent; + border-bottom: none; +} + +.team-panel-title { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + font-size: 22px; + font-weight: 800; + color: var(--brown-main); +} + +.team-total-badge { + padding: 3px 10px; + border-radius: 20px; + background: var(--brown-main); + color: #fff; + font-size: 13px; + font-weight: 700; +} + +.team-panel-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.team-view-toggle { + display: flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 16px; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #174c3a; + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 1px 2px #0000000d; +} + +.team-view-toggle:hover { + background: #286953; + border-color: #577e71; +} + +.team-view-toggle.active { + border: 2px solid #53af90; + background: #174c3a; + color: #cdffee; + box-shadow: 0 4px 12px #064b3626; +} + +.team-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid #e6e1dc; + border-radius: 8px; + background: #f4f6f4; + color: #555; + cursor: pointer; + transition: background 0.15s; +} + +.team-close-btn:hover { + background: #eee; +} + +.team-tree-scroll { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 24px 32px; + overflow: auto; + background-image: radial-gradient(#d0dfd8 1px, transparent 1px); + background-size: 32px 32px; + scrollbar-width: none; +} + +.team-tree-scroll::-webkit-scrollbar { + display: none; +} + +.team-empty-state { + justify-content: center; + color: #5a6b62; + font-size: 18px; + font-weight: 600; + text-align: center; + gap: 8px; +} + +.team-empty-hint { + font-size: 14px; + font-weight: 500; + color: #8a9a92; +} + +.tree-leaders-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 20px; + width: 100%; + margin-top: 16px; +} + +.tree-root-vline { + width: 2px; + height: 12px; + background: #064b3680; +} + +.tree-cells-row { + display: flex; + justify-content: center; + gap: 0; + width: 100%; + position: relative; +} + +.tree-cell-col { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + min-width: 280px; + max-width: 360px; + padding: 0 8px; + position: relative; +} + +.tree-cell-hline-wrap { + height: 16px; +} + +.tree-cell-card { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 14px 24px; + border: 1px solid #e0ece7; + border-radius: 12px; + background: #fff; + color: #064b36; + font-size: 20px; + font-weight: 700; + box-shadow: 0 2px 8px #0000000f; +} + +.tree-cell-name { + flex: 1; + text-align: left; +} + +.tree-badge { + padding: 3px 10px; + border-radius: 20px; + background: #24694a; + color: #fff; + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + +.tree-members-list { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + margin-top: 10px; +} + +.tree-leader-card { + position: relative; + display: flex; + align-items: center; + gap: 24px; + min-width: 320px; + padding: 16px 32px; + border: 1.5px solid #c8dfd5; + border-radius: 18px; + background: #fff; + box-shadow: 0 6px 20px #064b361f; + transition: all 0.15s; +} + +.tree-member-card { + position: relative; + display: grid; + grid-template-areas: + "avatar info" + "tasks tasks"; + grid-template-columns: auto 1fr; + align-items: center; + gap: 0 18px; + min-width: 280px; + margin-left: 0; + padding: 4px 24px 12px; + border: 1px solid #e9ede9; + border-radius: 14px; + background: #ffffffb3; + transition: all 0.14s; +} + +.leader-avatar, +.member-avatar { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 50%; + background: #064b36; + color: #fff; + font-weight: 800; + box-shadow: 0 4px 12px #064b3640; +} + +.leader-avatar { + width: 68px; + height: 68px; + font-size: 24px; + background: linear-gradient(135deg, #064b36, #0a7a57); +} + +.leader-avatar img, +.member-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.member-avatar { + grid-area: avatar; + width: 52px; + height: 52px; + margin-top: 8px; + font-size: 18px; +} + +.leader-info, +.member-info-wrap { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.member-info-wrap { + grid-area: info; +} + +.leader-name-row, +.member-name-row { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; +} + +.leader-name { + font-size: 22px; + font-weight: 800; + color: #111; +} + +.leader-sub, +.member-role { + font-size: 14px; + font-weight: 500; + color: #2f5e44; +} + +.member-name { + font-size: 19px; + font-weight: 700; + color: #111; +} + +.member-rank { + font-size: 14px; + font-weight: 500; + color: #2f5e44; +} + +.member-contact { + margin-top: 1px; + font-size: 14px; + color: #666; +} + +.highlighted-member { + border-color: #53af90 !important; + background: #f0faf5 !important; + box-shadow: 0 0 0 2px #53af9066, 0 8px 24px #064b3626 !important; +} + +/* 툴팁 — hover */ +.member-tooltip { + position: absolute; + top: 12px; + left: 95%; + z-index: 1000; + width: 300px; + padding: 8px 12px 12px; + border: 2px solid #0000001a; + border-radius: 0 16px 16px 32px; + background: #064b36f5; + color: #fff; + opacity: 0; + visibility: hidden; + pointer-events: none; + backdrop-filter: blur(12px); + box-shadow: 0 15px 35px #0000004d; + transition: opacity 0.25s, visibility 0.25s; +} + +.tree-member-card:hover .member-tooltip, +.tree-leader-card:hover .member-tooltip { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.member-tooltip.is-static { + position: relative; + top: auto; + left: auto; + grid-area: tasks; + width: 100%; + margin-top: 8px; + opacity: 1; + visibility: visible; + pointer-events: auto; + border-radius: 12px; + background: #f8fcfa; + color: #064b36; + border: 1px solid #c8dfd5; + box-shadow: none; +} + +.tooltip-header { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #ffffff1a; + font-size: 15px; + font-weight: 800; + color: #a8dec0; +} + +.member-tooltip.is-static .tooltip-header { + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom-color: #e1ece7; + color: #064b36; +} + +.tooltip-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.tooltip-item { + cursor: pointer; + border-radius: 8px; + transition: background 0.15s; +} + +.tooltip-item:hover, +.tooltip-item.active { + background: #ffffff14; +} + +.member-tooltip.is-static .tooltip-item:hover, +.member-tooltip.is-static .tooltip-item.active { + background: #e8f5ef; +} + +.tooltip-item-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 2px 8px; + width: 100%; +} + +.tooltip-dot { + flex-shrink: 0; + width: 6px; + height: 6px; + margin-top: 8px; + border-radius: 50%; + background: #14a873; +} + +.member-tooltip.is-static .tooltip-dot { + background: #10b981; +} + +.tooltip-item-body { + flex: 1; + min-width: 0; +} + +.tooltip-title { + font-size: 15px; + font-weight: 600; + line-height: 1.5; + word-break: keep-all; +} + +.member-tooltip.is-static .tooltip-title { + font-size: 14px; + color: #444; +} + +.tooltip-sub { + display: block; + font-size: 12px; + font-weight: 500; + opacity: 0.75; +} + +.tooltip-project-detail { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 6px; + padding: 6px 8px; + border-radius: 6px; + background: #ffffff18; + font-size: 12px; +} + +.member-tooltip.is-static .tooltip-project-detail { + background: #d4ede2; + color: #064b36; +} + +.tooltip-project-detail strong { + color: inherit; + font-weight: 800; +} + +.tooltip-badge { + flex-shrink: 0; + padding: 2px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.tooltip-badge-always, +.tooltip-badge-progress { + background: #14a87333; + color: #a8f0d0; +} + +.member-tooltip.is-static .tooltip-badge-always, +.member-tooltip.is-static .tooltip-badge-progress { + background: #d1fae5; + color: #047857; +} + +.tooltip-badge-hold, +.tooltip-badge-wait { + background: #f59e0b33; + color: #fde68a; +} + +.member-tooltip.is-static .tooltip-badge-hold, +.member-tooltip.is-static .tooltip-badge-wait { + background: #fef3c7; + color: #b45309; +} + +.tooltip-badge-done { + background: #ffffff22; + color: #e5e7eb; +} + +/* 업무 전체보기 모드 */ +.team-tree-scroll.show-all-tooltips .tree-member-card, +.team-tree-scroll.show-all-tooltips .tree-leader-card { + display: grid; + grid-template-areas: + "avatar tooltip" + "info tooltip"; + grid-template-columns: 96px 1fr; + align-items: flex-start; + gap: 12px 18px; + height: auto; + min-height: unset; + padding: 16px 20px; + border: 1.5px solid #c8dfd5; + border-radius: 18px; + background: #fff; +} + +.team-tree-scroll.show-all-tooltips .tree-cell-col { + min-width: 350px; + max-width: 530px; +} + +.team-tree-scroll.show-all-tooltips .member-info-wrap, +.team-tree-scroll.show-all-tooltips .leader-info { + grid-area: info; + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + text-align: left; +} + +.team-tree-scroll.show-all-tooltips .leader-avatar, +.team-tree-scroll.show-all-tooltips .member-avatar { + grid-area: avatar; + justify-self: start; + margin-bottom: 0; +} + +.team-tree-scroll.show-all-tooltips .member-contact { + white-space: nowrap; +} + +.team-status-btn-new.active { + color: #cef1eb; + background: linear-gradient(#072b23 0%, #051f19 100%); + border-color: #53af90; + box-shadow: 0 0 0 1px #000, 0 0 8px #24cc9e66, 0 1px 2px #0009; +} + +/* ─── /admin 팀원 관리 ─── */ +.admin-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg-beige); + background-image: radial-gradient(#d0dfd8 1px, transparent 1px); + background-size: 32px 32px; + color: #1a2e28; +} + +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + padding: 0 24px; + background: linear-gradient(180deg, #37a184 0%, #29724f 20%, #07412e 100%); + border-bottom: 1px solid #135643; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + color: #fff; +} + +.admin-header-left { + display: flex; + align-items: center; + gap: 20px; +} + +.admin-back-link { + font-size: 13px; + font-weight: 600; + color: #cef1eb; + text-decoration: none; + opacity: 0.9; + transition: opacity 0.15s; +} + +.admin-back-link:hover { + opacity: 1; + color: #fff; +} + +.admin-header-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.3px; +} + +.admin-header-org { + color: #bad8ca; +} + +.admin-header-sep { + opacity: 0.5; +} + +.admin-header-actions { + display: flex; + gap: 10px; +} + +.admin-toolbar { + padding: 16px 32px 8px; +} + +.admin-stat-chips { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 8px; +} + +.admin-stat-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 20px; + background: #fff; + border: 1px solid #c8dfd5; + font-size: 14px; + color: #2f5e44; + box-shadow: 0 2px 6px #064b3614; +} + +.admin-stat-chip strong { + font-weight: 800; + color: #064b36; +} + +.admin-stat-total { + background: #064b36; + border-color: #064b36; + color: #e8f5ef; +} + +.admin-stat-total strong { + color: #fff; +} + +.admin-toolbar-hint { + font-size: 13px; + color: #5a6b62; + margin: 0; +} + +.admin-main { + flex: 1; + padding: 8px 32px 40px; + max-width: 1100px; + width: 100%; + margin: 0 auto; +} + +.admin-empty { + text-align: center; + padding: 48px; + color: #6b7c74; + font-size: 16px; +} + +.admin-empty-card { + text-align: center; + padding: 48px 32px; + border-radius: 18px; + background: #fff; + border: 1.5px solid #c8dfd5; + box-shadow: 0 6px 20px #064b3614; +} + +.admin-empty-card h3 { + margin: 0 0 8px; + font-size: 22px; + font-weight: 800; + color: #064b36; +} + +.admin-empty-card p { + margin: 0 0 24px; + color: #5a6b62; + font-size: 15px; +} + +.admin-quick-add { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; +} + +.admin-section { + margin-bottom: 24px; + border-radius: 16px; + background: #fff; + border: 1.5px solid #c8dfd5; + overflow: hidden; + box-shadow: 0 4px 16px #064b3612; +} + +.admin-section-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + background: linear-gradient(90deg, #f0faf5 0%, #fff 100%); + border-bottom: 1px solid #e0ece7; +} + +.admin-section-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 18px; + font-weight: 800; + color: #064b36; +} + +.admin-section-badge { + padding: 3px 10px; + border-radius: 20px; + background: #24694a; + color: #fff; + font-size: 12px; + font-weight: 700; +} + +.admin-member-table-wrap { + overflow-x: auto; +} + +.admin-member-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.admin-member-table th { + padding: 10px 16px; + text-align: left; + font-size: 12px; + font-weight: 700; + color: #5a6b62; + background: #f8fcfa; + border-bottom: 1px solid #e8efeb; +} + +.admin-member-table td { + padding: 12px 16px; + border-bottom: 1px solid #f0f4f2; + vertical-align: middle; +} + +.admin-member-table tr:last-child td { + border-bottom: none; +} + +.admin-member-table tr:hover td { + background: #f8fcfa; +} + +.admin-member-table .w-avatar { + width: 64px; +} + +.admin-member-table .w-actions { + width: 88px; +} + +.admin-member-name { + display: block; + font-weight: 700; + color: #111; + font-size: 15px; +} + +.admin-member-cell-tag { + display: inline-block; + margin-top: 2px; + padding: 1px 8px; + border-radius: 6px; + background: #e8f5ef; + color: #064b36; + font-size: 11px; + font-weight: 600; +} + +.admin-contact { + color: #555; + white-space: nowrap; +} + +.admin-sort { + color: #888; + text-align: center; +} + +.admin-row-actions { + display: flex; + gap: 4px; + justify-content: flex-end; +} + +.admin-icon-btn { + width: 32px; + height: 32px; + border: 1px solid #e0ece7; + border-radius: 8px; + background: #fff; + cursor: pointer; + font-size: 14px; + transition: all 0.15s; +} + +.admin-icon-btn:hover { + background: #e8f5ef; + border-color: #53af90; +} + +.admin-icon-btn-danger:hover { + background: #fef2f2; + border-color: #fca5a5; +} + +.admin-btn-primary { + padding: 8px 18px; + border: none; + border-radius: 8px; + background: #174c3a; + color: #fff; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: background 0.15s; + box-shadow: 0 2px 6px #064b3626; +} + +.admin-btn-primary:hover { + background: #286953; +} + +.admin-btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.admin-btn-outline { + padding: 8px 16px; + border: 1.5px solid #53af90; + border-radius: 8px; + background: #fff; + color: #064b36; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; +} + +.admin-btn-outline:hover { + background: #e8f5ef; +} + +.admin-btn-sm { + padding: 5px 12px; + font-size: 12px; +} + +.admin-footer-note { + margin-top: 16px; + text-align: center; +} + +.admin-preview-link { + font-size: 14px; + font-weight: 600; + color: #064b36; + text-decoration: none; +} + +.admin-preview-link:hover { + text-decoration: underline; +} + +/* 관리 모달 */ +.admin-modal-backdrop { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(6, 75, 54, 0.45); + backdrop-filter: blur(4px); +} + +.admin-modal { + width: 100%; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; + border-radius: 16px; + background: #fff; + box-shadow: 0 24px 48px #00000040; +} + +.admin-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #e8efeb; + background: linear-gradient(90deg, #f0faf5, #fff); +} + +.admin-modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 800; + color: #064b36; +} + +.admin-modal-close { + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background: transparent; + color: #888; + font-size: 18px; + cursor: pointer; +} + +.admin-modal-close:hover { + background: #f0f4f2; +} + +.admin-modal-body { + padding: 20px; +} + +.admin-form-preview { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + padding: 16px; + border-radius: 14px; + background: #f8fcfa; + border: 1.5px solid #c8dfd5; +} + +.admin-preview-name { + font-size: 18px; + font-weight: 800; + color: #111; +} + +.admin-preview-sub { + font-size: 13px; + color: #2f5e44; + margin-top: 2px; +} + +.admin-preview-cell { + display: inline-block; + margin-top: 6px; + padding: 2px 10px; + border-radius: 6px; + background: #064b36; + color: #fff; + font-size: 11px; + font-weight: 700; +} + +.admin-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.admin-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-field-full { + grid-column: 1 / -1; +} + +.admin-field span { + font-size: 12px; + font-weight: 700; + color: #5a6b62; +} + +.admin-field input, +.admin-field select { + padding: 10px 12px; + border: 1px solid #d8e8e0; + border-radius: 10px; + font-size: 14px; + outline: none; + transition: border-color 0.15s; +} + +.admin-field input:focus, +.admin-field select:focus { + border-color: #53af90; + box-shadow: 0 0 0 3px #53af9022; +} + +.admin-modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid #f0f4f2; +} + +.admin-btn-ghost { + padding: 8px 16px; + border: 1px solid #e0ece7; + border-radius: 8px; + background: #fff; + color: #555; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.admin-btn-ghost:hover { + background: #f8fcfa; +} + +.admin-team-manage-link { + font-size: 13px; + font-weight: 600; + color: #064b36; + text-decoration: underline; + text-underline-offset: 2px; +} + +.admin-team-manage-link:hover { + color: #286953; +} + +.admin-photo-field { + gap: 8px !important; +} + +.admin-photo-upload-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.admin-photo-file-btn { + display: inline-flex; + align-items: center; + padding: 8px 14px; + border-radius: 8px; + border: 1.5px dashed #53af90; + background: #f0faf5; + color: #064b36; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: background 0.15s; +} + +.admin-photo-file-btn:hover { + background: #e0f5ec; +} + +.admin-photo-hint { + font-size: 12px; + color: #6b7c74; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + diff --git a/frontend/src/lib/apiBase.ts b/frontend/src/lib/apiBase.ts new file mode 100644 index 0000000..460a111 --- /dev/null +++ b/frontend/src/lib/apiBase.ts @@ -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}`; +} diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index 00f4c94..c2d87c1 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -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, diff --git a/frontend/src/lib/taskFormPayload.ts b/frontend/src/lib/taskFormPayload.ts new file mode 100644 index 0000000..468eedc --- /dev/null +++ b/frontend/src/lib/taskFormPayload.ts @@ -0,0 +1,25 @@ +import type { TaskFormData } from '../components/common/TaskModal'; + +export function taskFormToApiPayload(data: TaskFormData): Record { + 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, + }; +} diff --git a/frontend/src/lib/teamStatus.ts b/frontend/src/lib/teamStatus.ts new file mode 100644 index 0000000..47ebc2e --- /dev/null +++ b/frontend/src/lib/teamStatus.ts @@ -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 = { + 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 = { + 수석연구원: 1, + 책임연구원: 2, + 선임연구원: 3, + 연구원: 4, +}; + +const CELL_ORDER: Record = { + 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 = { 리더: [] }; + 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(' · ')}` : ''; +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 694a1fb..0a1b5d5 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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
관리자 페이지 - 구현 예정
; + const { query, create, update, remove, getApiErrorMessage } = useTeamMembersAdmin(); + const members = query.data ?? []; + + const [modalMode, setModalMode] = useState<'add' | 'edit' | null>(null); + const [editingMember, setEditingMember] = useState(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 ( +
+
+
+ ← 대시보드 +
+ 총괄기획실 + | + 팀원 관리 +
+
+
+ +
+
+ +
+
+ + 전체 {members.length}명 + + + 팀장 {groups.리더?.length ?? 0}명 + + + 인사 {groups.HR?.length ?? 0}명 + + + 총무 {groups.총무?.length ?? 0}명 + +
+

+ 등록한 팀원은 대시보드 팀 인원 현황에 표시됩니다. 업무 PM·담당자 선택에도 사용됩니다. +

+
+ +
+ {query.isLoading && ( +
데이터를 불러오는 중…
+ )} + + {!query.isLoading && members.length === 0 && ( +
+

등록된 팀원이 없습니다

+

팀장 1명, 인사 2명, 총무 2명 순으로 추가해 보세요.

+
+ + + +
+
+ )} + + {sections.map((section) => ( +
+
+
+ {section.label} + {section.members.length}명 +
+ +
+ +
+ + + + + + + + + + + {section.members.map((member) => ( + + + + + + + + + ))} + +
+ 이름직급 · 직책연락처순서 +
+ + + {member.name} + {getCellLabel(member.cell) || '팀장'} + + {[member.rank, member.role].filter(Boolean).join(' · ') || '—'} + {member.contact ?? '—'}{member.sortOrder ?? 0} +
+ + +
+
+
+
+ ))} + + {members.length > 0 && ( +
+ + 대시보드에서 팀 인원 현황 미리보기 → + +
+ )} +
+ + {modalMode && ( + + )} +
+ ); } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index ffb7ba8..86bf9e2 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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([...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(null); const [activeTaskId, setActiveTaskId] = useState(null); const [columnOrders, setColumnOrders] = useState>({}); const saveTimers = useRef>>({}); @@ -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; + }); + }} /> -
+ {teamPanelOpen && ( + setShowAllTeamTasks((v) => !v)} + onProjectClick={setActiveTeamProjectId} + onClose={() => { + setTeamPanelOpen(false); + setActiveTeamProjectId(null); + setShowAllTeamTasks(false); + }} + /> + )} + +
{/* ── 좌측 라벨 컬럼 ── */}
@@ -283,6 +315,7 @@ export default function DashboardPage() { quarter={QUARTER} onSelectTask={(t) => sendTaskSelected(t.id)} sectionOptions={sectionOptions} + teamMembers={teamMembers} /> ))}
@@ -307,6 +340,7 @@ export default function DashboardPage() { tasks={tasks} sectionOptions={sectionOptions} quarter={QUARTER} + teamMembers={teamMembers} onClose={() => setShowTaskManager(false)} /> )} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6c35d82..e790ffb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 | null; creator?: Pick; + pmMember?: TeamMemberBrief | null; + assigneeMembers?: TeamMemberBrief[]; _count?: { files: number; details: number }; } diff --git a/package.json b/package.json new file mode 100644 index 0000000..52ab439 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/get-lan-ip.ps1 b/scripts/get-lan-ip.ps1 new file mode 100644 index 0000000..31e466f --- /dev/null +++ b/scripts/get-lan-ip.ps1 @@ -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 } diff --git a/scripts/kill-ports.ps1 b/scripts/kill-ports.ps1 new file mode 100644 index 0000000..d559570 --- /dev/null +++ b/scripts/kill-ports.ps1 @@ -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 + } + } diff --git a/scripts/wait-db.ps1 b/scripts/wait-db.ps1 new file mode 100644 index 0000000..685d118 --- /dev/null +++ b/scripts/wait-db.ps1 @@ -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 } diff --git a/start-server.bat b/start-server.bat new file mode 100644 index 0000000..ca81aac --- /dev/null +++ b/start-server.bat @@ -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 diff --git a/stop-server.bat b/stop-server.bat new file mode 100644 index 0000000..5ccd669 --- /dev/null +++ b/stop-server.bat @@ -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 diff --git a/데이터가져오기.bat b/데이터가져오기.bat new file mode 100644 index 0000000..ae18dc5 --- /dev/null +++ b/데이터가져오기.bat @@ -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 diff --git a/데이터배포.bat b/데이터배포.bat new file mode 100644 index 0000000..80dda53 --- /dev/null +++ b/데이터배포.bat @@ -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 diff --git a/서버시작.bat b/서버시작.bat index ad2bcc2..a7de3f4 100644 --- a/서버시작.bat +++ b/서버시작.bat @@ -1,64 +1,8 @@ @echo off -chcp 65001 > nul cd /d "%~dp0" - -echo ================================ -echo EENE Dashboard - Server Start -echo ================================ -echo. - -:: 0. 기존 서버 종료 (title 설정 전에 실행 — 자기 자신 종료 방지) -echo [1/4] Stopping old servers... -taskkill /fi "WindowTitle eq EENE Dashboard - Running*" /f > nul 2>&1 -powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { @(4000,3000,3001) -contains $_.LocalPort } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }" > nul 2>&1 -timeout /t 2 /nobreak > nul -echo Done. -echo. - -title EENE Dashboard - Running - -:: 1. PostgreSQL 서비스 확인 -echo [2/4] Checking Database... -sc query postgresql-x64-16 | find "RUNNING" > nul 2>&1 -if %errorlevel% neq 0 ( - echo Starting PostgreSQL service... - net start postgresql-x64-16 +if not exist "%~dp0start-server.bat" ( + echo [ERROR] start-server.bat not found. + pause + exit /b 1 ) -echo Database is ready. -echo. - -:: 2. DB 마이그레이션 + Prisma 클라이언트 갱신 -echo [3/4] DB migrate + Prisma generate... -cd /d "%~dp0backend" -call npx prisma migrate deploy -if errorlevel 1 ( - echo Migration failed. backend\.env DATABASE_URL 확인 - pause - exit /b 1 -) -call npx prisma generate -if errorlevel 1 ( - echo Prisma generate failed. 다른 터미널/서버가 켜져 있으면 종료 후 다시 시도하세요. - pause - exit /b 1 -) -cd /d "%~dp0" -echo Done. -echo. - -:: 3. 백엔드 + 프론트엔드 -echo [4/4] Starting Backend + Frontend... -echo. -echo Dashboard : http://localhost:3000 -echo Detail : http://localhost:3000/detail -echo Team : http://172.16.8.248:3000 -echo. -echo 종료: Ctrl+C -echo ================================ -echo. - -npx --yes concurrently -k -n "API,WEB" -c "cyan,green" "cd /d %~dp0backend && npm run dev" "cd /d %~dp0frontend && npm run dev" - -echo. -echo Server stopped. -pause +call "%~dp0start-server.bat" diff --git a/서버종료.bat b/서버종료.bat index 9267ee1..96cd6f3 100644 --- a/서버종료.bat +++ b/서버종료.bat @@ -1,23 +1,3 @@ @echo off -chcp 65001 > nul -title EENE Dashboard - Stop cd /d "%~dp0" - -echo ================================ -echo EENE Dashboard - Server Stop -echo ================================ -echo. - -echo [1/2] Closing server window... -taskkill /fi "WindowTitle eq EENE Dashboard - Running*" /f > nul 2>&1 -echo Done. -echo. - -echo [2/2] Stopping API / WEB (ports 4000, 3000, 3001)... -powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { @(4000,3000,3001) -contains $_.LocalPort } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }" > nul 2>&1 -echo Done. -echo. - -echo All servers stopped. -echo (PostgreSQL keeps running as Windows service) -pause +call "%~dp0stop-server.bat" %*