commit d9023abed648b4fa31885535d859b67a5b567f65 Author: hyunho Date: Wed Mar 25 10:26:33 2026 +0900 Initial dashboard organization setup diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..a59e98f --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +POSTGRES_DB=orgdb +POSTGRES_USER=orgapp +POSTGRES_PASSWORD=change-me +DATABASE_URL=postgresql://orgapp:change-me@db:5432/orgdb +UPLOAD_DIR=/data/uploads +SNAPSHOT_DIR=/data/snapshots +MOCK_LOGIN_ENABLED=true + diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..826e7a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.gitea_token +.env +__pycache__/ +*.pyc +.pytest_cache/ +backend/.venv/ +backend/data/ +uploads/ +snapshots/ +node_modules/ + diff --git a/DashBoard-organization-backup.html b/DashBoard-organization-backup.html new file mode 100755 index 0000000..c63aed2 --- /dev/null +++ b/DashBoard-organization-backup.html @@ -0,0 +1,2014 @@ + + + + + + + MH 조직현황 관리 - 레이아웃 및 정렬 최적화 + + + + + + +
+

MH 조직현황 관리 - 레이아웃 최적화

+
+ + +
+
+ +
+
+
+ + + + +
+
+
+
+
+
+

📊 인원 현황 통계 0명

+ +
+ +
+ +
+
파일을 업로드하면 고정 순서 및 레이아웃이 적용됩니다.
+
+ + + + +
+ +
+ +
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/DashBoard-organization.html b/DashBoard-organization.html new file mode 100644 index 0000000..779e1e6 --- /dev/null +++ b/DashBoard-organization.html @@ -0,0 +1,70 @@ + + + + + + MH 조직현황 관리 + + + + + + + +
+

MH 조직현황 관리

+
+ + +
+
+ +
+
+
+ + + + +
+
+
+
+ +
+
+

인원 현황 통계 0명

+ +
+ +
+ +
+
서버에서 조직 데이터를 불러오는 중입니다.
+
+ + + +
+ +
+ +
+
+ + + + + + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100755 index 0000000..31baf1d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY backend/requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY backend/app /app/backend/app +COPY DashBoard-organization.html /app/legacy/DashBoard-organization.html +COPY DashBoard-organization-backup.html /app/legacy/DashBoard-organization-backup.html +COPY organization.xlsx /app/legacy/organization.xlsx +COPY legacy/static /app/legacy/static + +EXPOSE 8000 + +CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100755 index 0000000..8ab3959 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,14 @@ +from pathlib import Path +import os + + +BASE_DIR = Path("/app") +LEGACY_DIR = BASE_DIR / "legacy" +UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/data/uploads")) +SNAPSHOT_DIR = Path(os.getenv("SNAPSHOT_DIR", "/data/snapshots")) +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://orgapp:change-me@db:5432/orgdb", +) +MOCK_LOGIN_ENABLED = os.getenv("MOCK_LOGIN_ENABLED", "true").lower() == "true" + diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100755 index 0000000..174194d --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,75 @@ +from contextlib import contextmanager +import time +from typing import Iterator + +from psycopg.rows import dict_row +import psycopg + +from .config import DATABASE_URL + + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS members ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + company TEXT, + rank TEXT, + role TEXT, + department TEXT, + grp TEXT, + division TEXT, + team TEXT, + cell TEXT, + work_status TEXT, + work_time TEXT, + phone TEXT, + email TEXT, + seat_label TEXT, + photo_url TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS seat_positions ( + member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE, + x INTEGER NOT NULL DEFAULT 0, + y INTEGER NOT NULL DEFAULT 0, + floor_label TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS snapshots ( + id SERIAL PRIMARY KEY, + snapshot_month TEXT NOT NULL, + file_path TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +""" + +MIGRATION_SQL = """ +ALTER TABLE members ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0; +""" + + +@contextmanager +def get_conn() -> Iterator[psycopg.Connection]: + with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn: + yield conn + + +def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None: + last_error: Exception | None = None + for _ in range(max_retries): + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(SCHEMA_SQL) + cur.execute(MIGRATION_SQL) + conn.commit() + return + except psycopg.OperationalError as exc: + last_error = exc + time.sleep(retry_delay) + if last_error is not None: + raise last_error diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100755 index 0000000..fb6f029 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import csv +from io import BytesIO, StringIO +import shutil +import uuid + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from openpyxl import load_workbook +from pydantic import BaseModel, Field + +from .config import LEGACY_DIR, MOCK_LOGIN_ENABLED, SNAPSHOT_DIR, UPLOAD_DIR +from .db import get_conn, init_db + + +app = FastAPI(title="MH Dashboard Organization API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +LEGACY_STATIC_DIR = LEGACY_DIR / "static" + + +class MemberPayload(BaseModel): + id: int | None = None + name: str = Field(min_length=1) + company: str = "" + rank: str = "" + role: str = "" + department: str = "" + grp: str = "" + division: str = "" + team: str = "" + cell: str = "" + work_status: str = "" + work_time: str = "" + phone: str = "" + email: str = "" + seat_label: str = "" + photo_url: str = "" + sort_order: int | None = None + + +class MemberBulkPayload(BaseModel): + items: list[MemberPayload] + + +LEGACY_HEADER_MAP = { + "이름": "name", + "소속회사": "company", + "직급": "rank", + "직책": "role", + "부서": "department", + "그룹": "grp", + "디비전": "division", + "팀": "team", + "셀": "cell", + "근무상태": "work_status", + "근무시간": "work_time", + "전화번호": "phone", + "이메일": "email", + "자리위치": "seat_label", + "사진": "photo_url", +} + + +def serialize_member_payload(item: MemberPayload, sort_order: int) -> tuple[object, ...]: + return ( + item.name.strip(), + item.company.strip(), + item.rank.strip(), + item.role.strip(), + item.department.strip(), + item.grp.strip(), + item.division.strip(), + item.team.strip(), + item.cell.strip(), + item.work_status.strip(), + item.work_time.strip(), + item.phone.strip(), + item.email.strip(), + item.seat_label.strip(), + item.photo_url.strip(), + sort_order, + ) + + +def fetch_members() -> list[dict[str, object]]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, name, company, rank, role, department, grp, division, team, cell, + work_status, work_time, phone, email, seat_label, photo_url, + sort_order, created_at, updated_at + FROM members + ORDER BY sort_order ASC, id ASC + """ + ) + return cur.fetchall() + + +def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("TRUNCATE TABLE members RESTART IDENTITY CASCADE") + for index, item in enumerate(items): + cur.execute( + """ + INSERT INTO members ( + name, company, rank, role, department, grp, division, team, cell, + work_status, work_time, phone, email, seat_label, photo_url, sort_order + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + serialize_member_payload(item, index), + ) + conn.commit() + return fetch_members() + + +def rows_to_member_payloads(rows: list[list[object]]) -> list[MemberPayload]: + header_idx = next( + ( + idx + for idx, row in enumerate(rows) + if "이름" in row and "부서" in row + ), + -1, + ) + if header_idx < 0: + raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식입니다. '이름'과 '부서' 헤더를 찾지 못했습니다.") + + headers = [str(value).strip() for value in rows[header_idx]] + payloads: list[MemberPayload] = [] + + for row in rows[header_idx + 1 :]: + if not any(str(value or "").strip() for value in row): + continue + record: dict[str, object] = {} + for col_idx, header in enumerate(headers): + mapped = LEGACY_HEADER_MAP.get(header) + if not mapped: + continue + record[mapped] = str(row[col_idx] if col_idx < len(row) and row[col_idx] is not None else "").strip() + if not str(record.get("name", "")).strip(): + continue + payloads.append(MemberPayload(**record)) + return payloads + + +def parse_import_rows(file: UploadFile, content: bytes) -> list[MemberPayload]: + suffix = Path(file.filename or "").suffix.lower() + if suffix == ".csv": + text = content.decode("utf-8-sig") + rows = list(csv.reader(StringIO(text))) + return rows_to_member_payloads(rows) + if suffix in {".xlsx", ".xlsm", ".xltx", ".xltm"}: + workbook = load_workbook(BytesIO(content), data_only=True) + sheet = workbook[workbook.sheetnames[0]] + rows = [list(row) for row in sheet.iter_rows(values_only=True)] + return rows_to_member_payloads(rows) + raise HTTPException(status_code=400, detail="xlsx 또는 csv 파일만 업로드할 수 있습니다.") + + +@app.on_event("startup") +def startup() -> None: + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) + LEGACY_STATIC_DIR.mkdir(parents=True, exist_ok=True) + init_db() + + +app.mount("/legacy/static", StaticFiles(directory=LEGACY_STATIC_DIR, check_dir=False), name="legacy-static") + + +@app.get("/api/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/api/mock-login") +def mock_login(username: str = Form(...), password: str = Form(...)) -> dict[str, object]: + if not MOCK_LOGIN_ENABLED: + raise HTTPException(status_code=403, detail="Mock login is disabled.") + if not username.strip() or not password.strip(): + raise HTTPException(status_code=400, detail="Username and password are required.") + return { + "user": { + "username": username.strip(), + "display_name": username.strip(), + "role": "admin", + }, + "session_expires_at": datetime.utcnow().isoformat() + "Z", + } + + +@app.get("/api/members") +def list_members() -> dict[str, list[dict[str, object]]]: + return {"items": fetch_members()} + + +@app.post("/api/members") +def create_member(payload: MemberPayload) -> dict[str, object]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM members") + next_order = int(cur.fetchone()["next_order"]) + cur.execute( + """ + INSERT INTO members ( + name, company, rank, role, department, grp, division, team, cell, + work_status, work_time, phone, email, seat_label, photo_url, sort_order + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, company, rank, role, department, grp, division, team, cell, + work_status, work_time, phone, email, seat_label, photo_url, + sort_order, created_at, updated_at + """, + serialize_member_payload(payload, payload.sort_order if payload.sort_order is not None else next_order), + ) + member = cur.fetchone() + conn.commit() + return {"item": member} + + +@app.put("/api/members/{member_id}") +def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE members + SET name = %s, + company = %s, + rank = %s, + role = %s, + department = %s, + grp = %s, + division = %s, + team = %s, + cell = %s, + work_status = %s, + work_time = %s, + phone = %s, + email = %s, + seat_label = %s, + photo_url = %s, + sort_order = COALESCE(%s, sort_order), + updated_at = NOW() + WHERE id = %s + RETURNING id, name, company, rank, role, department, grp, division, team, cell, + work_status, work_time, phone, email, seat_label, photo_url, + sort_order, created_at, updated_at + """, + (*serialize_member_payload(payload, payload.sort_order or 0)[:-1], payload.sort_order, member_id), + ) + member = cur.fetchone() + if member is None: + raise HTTPException(status_code=404, detail="Member not found.") + conn.commit() + return {"item": member} + + +@app.delete("/api/members/{member_id}") +def delete_member(member_id: int) -> dict[str, bool]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM members WHERE id = %s", (member_id,)) + deleted = cur.rowcount > 0 + conn.commit() + if not deleted: + raise HTTPException(status_code=404, detail="Member not found.") + return {"ok": True} + + +@app.put("/api/members/bulk-sync") +def bulk_sync_members(payload: MemberBulkPayload) -> dict[str, list[dict[str, object]]]: + return {"items": replace_members(payload.items)} + + +@app.post("/api/members/import") +async def import_members(file: UploadFile = File(...)) -> dict[str, list[dict[str, object]]]: + content = await file.read() + items = parse_import_rows(file, content) + return {"items": replace_members(items)} + + +@app.post("/api/uploads/profile-photo") +def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form("")) -> dict[str, str]: + suffix = Path(file.filename or "").suffix.lower() + if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: + raise HTTPException(status_code=400, detail="Only image files are allowed.") + stem = member_name.strip().replace(" ", "-") or "member" + filename = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}" + target = UPLOAD_DIR / filename + with target.open("wb") as out_file: + shutil.copyfileobj(file.file, out_file) + return {"url": f"/uploads/{filename}"} + + +@app.post("/api/snapshots/monthly") +def create_monthly_snapshot(snapshot_month: str = Form(...)) -> dict[str, str]: + filename = f"organization-snapshot-{snapshot_month}.csv" + target = SNAPSHOT_DIR / filename + + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT name, company, rank, role, department, grp, division, team, cell, + work_status, work_time, phone, email, seat_label, photo_url, updated_at + FROM members + ORDER BY sort_order ASC, id ASC + """ + ) + rows = cur.fetchall() + + with target.open("w", newline="", encoding="utf-8-sig") as csv_file: + writer = csv.DictWriter( + csv_file, + fieldnames=[ + "name", + "company", + "rank", + "role", + "department", + "grp", + "division", + "team", + "cell", + "work_status", + "work_time", + "phone", + "email", + "seat_label", + "photo_url", + "updated_at", + ], + ) + writer.writeheader() + writer.writerows(rows) + + with conn.cursor() as cur: + cur.execute( + "INSERT INTO snapshots (snapshot_month, file_path) VALUES (%s, %s)", + (snapshot_month, str(target)), + ) + conn.commit() + + return {"file": f"/snapshots/{filename}"} + + +@app.get("/api/snapshots") +def list_snapshots() -> dict[str, list[dict[str, object]]]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, snapshot_month, file_path, created_at FROM snapshots ORDER BY created_at DESC" + ) + snapshots = cur.fetchall() + return {"items": snapshots} + + +@app.get("/legacy/organization") +def legacy_organization() -> FileResponse: + target = LEGACY_DIR / "DashBoard-organization.html" + if not target.exists(): + raise HTTPException(status_code=404, detail="Legacy dashboard file not found.") + return FileResponse(target) + + +@app.get("/legacy/organization-backup") +def legacy_organization_backup() -> FileResponse: + target = LEGACY_DIR / "DashBoard-organization-backup.html" + if not target.exists(): + raise HTTPException(status_code=404, detail="Legacy dashboard backup not found.") + return FileResponse(target) + + +@app.get("/uploads/{filename}") +def get_upload(filename: str) -> FileResponse: + target = UPLOAD_DIR / filename + if not target.exists(): + raise HTTPException(status_code=404, detail="Upload not found.") + return FileResponse(target) + + +@app.get("/snapshots/{filename}") +def get_snapshot(filename: str) -> FileResponse: + target = SNAPSHOT_DIR / filename + if not target.exists(): + raise HTTPException(status_code=404, detail="Snapshot not found.") + return FileResponse(target, media_type="text/csv") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100755 index 0000000..8f46ac0 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +psycopg[binary]==3.2.9 +python-multipart==0.0.20 +openpyxl==3.1.5 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..87dde97 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + proxy: + image: nginx:1.27-alpine + depends_on: + - frontend + - backend + ports: + - "8080:80" + volumes: + - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro + + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + + backend: + build: + context: . + dockerfile: backend/Dockerfile + env_file: + - .env + depends_on: + - db + volumes: + - uploads_data:/data/uploads + - snapshots_data:/data/snapshots + + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: + uploads_data: + snapshots_data: + diff --git a/docs/DEPLOYMENT_GUIDE.md b/docs/DEPLOYMENT_GUIDE.md new file mode 100755 index 0000000..032f07f --- /dev/null +++ b/docs/DEPLOYMENT_GUIDE.md @@ -0,0 +1,76 @@ +# MH Dashboard Organization 배포 가이드 + +## 1. "도메인 발급/등록"이란? +- 도메인은 사용자가 브라우저에 입력하는 주소입니다. 예를 들면 `orgdash.intra.company.local` 같은 형태입니다. +- 사내망에서는 보통 IT팀이나 인프라 담당자가 이 주소를 실제 Ubuntu 서버 IP와 연결하는 DNS 설정을 등록합니다. +- 아직 사내 DNS 등록 절차가 없다면, 우선은 `http://10.10.10.15:8080` 같은 IP 주소로 먼저 접속하고 나중에 도메인을 붙여도 됩니다. + +## 2. 이 프로젝트의 권장 구성 +- `proxy`: 사내 접속용 단일 진입점 역할을 하는 Nginx 리버스 프록시 +- `frontend`: 화면상 로그인과 허브 화면을 제공하는 정적 프론트엔드 +- `backend`: 구성원 데이터, 이미지 업로드, 스냅샷 생성을 처리하는 FastAPI 서버 +- `db`: 영구 저장을 담당하는 PostgreSQL 데이터베이스 + +## 3. 왜 이 구조가 지금 프로젝트에 맞는가 +- 기존 조직도 HTML 화면을 그대로 레거시 모듈로 유지할 수 있습니다. +- 프로필 사진 업로드를 서버에 저장할 수 있습니다. +- 월말 조직 데이터 스냅샷을 서버에서 생성하고 보관할 수 있습니다. +- 요청하신 대로 로그인은 우선 화면상으로만 구현해 둘 수 있습니다. + +## 4. Ubuntu 서버 준비 +- 사내망 안에 Ubuntu 24.04 서버를 새로 준비합니다. +- 사내망에서 필요한 포트만 열어둡니다. 보통 `80` 또는 `8080` 정도면 시작할 수 있습니다. +- Docker Engine과 Docker Compose 플러그인을 설치합니다. +- 이 저장소를 서버로 복사합니다. +- `.env.example`을 기준으로 `.env` 파일을 만들고 실제 DB 비밀번호를 넣습니다. + +## 4-1. 현재 로컬 PC 기준 WSL 작업 표준 +- 현재 로컬 개발 서버는 `WSL2 + Ubuntu-24.04` 기준으로 구성했습니다. +- 기본 작업 사용자는 `hyunho` 입니다. +- 앞으로의 기준 작업 경로는 아래입니다. + - `/home/hyunho/projects/mh-dashboard-organization` +- Windows 폴더는 원본 참고용으로 남아 있을 수 있지만, 실제 실행과 개발 기준은 WSL 내부 경로로 맞추는 것을 권장합니다. + +## 4-2. VS Code는 어떤 경로를 열어야 하나 +- VS Code 좌측 아래에 `WSL: Ubuntu-24.04` 가 보이는 상태로 여는 것이 가장 안전합니다. +- VS Code에서 `Remote-WSL: Reopen Folder in WSL` 기능으로 다시 열 수 있습니다. +- 다시 열어야 할 권장 경로는 아래입니다. + - `/home/hyunho/projects/mh-dashboard-organization` +- 이렇게 열면 Docker, Python, Linux 경로, 실행 환경이 실제 서버와 가장 비슷하게 맞춰집니다. + +## 5. Docker 설치 관련 메모 +- 메신저에 공유된 명령어는 Ubuntu 서버에 Docker를 설치하기 위한 절차입니다. +- 최종 기준 문서는 Docker 공식 문서를 보는 것을 권장합니다. + https://docs.docker.com/engine/install/ubuntu/ +- 회사에서 apt 미러나 보안 기준을 따로 관리한다면, 패키지 소스를 바꾸기 전에 그 정책을 먼저 확인하는 것이 좋습니다. + +## 6. 최초 배포 순서 +1. `.env.example`을 `.env`로 복사합니다. +2. `POSTGRES_PASSWORD` 값을 실제 비밀번호로 수정합니다. +3. `docker compose build`를 실행합니다. +4. `docker compose up -d`를 실행합니다. +5. 브라우저에서 `http://SERVER_IP:8080` 으로 접속합니다. + +## 7. 현재 단계의 데이터 및 백업 정책 +- 데이터베이스: PostgreSQL 볼륨 `postgres_data` +- 업로드 파일: Docker 볼륨 `uploads_data` +- 월말 스냅샷 파일: Docker 볼륨 `snapshots_data` +- 백업 주기: 월말 스냅샷 생성 + DB 볼륨 백업 +- 복구 기준: 아직 정해지지 않았으므로, 우선은 수동 복구 절차를 먼저 문서화하고 이후에 기준을 구체화합니다. + +## 8. 현재 구조의 한계 +- 로그인은 화면상 동작만 구현되어 있고, 아직 백엔드 보호 기능은 없습니다. +- 레거시 조직도 화면은 iframe으로 연결만 해둔 상태이며, DB 기반 API와 완전히 연동되지는 않았습니다. +- 레거시 화면은 CDN 자산을 사용합니다. 사내망이 외부 인터넷 접속을 막는 환경이라면 추후 로컬 자산으로 바꿔야 합니다. + +## 9. 다음 구현 권장 순서 +1. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다. +2. 구성원 데이터를 브라우저 메모리 방식에서 PostgreSQL 기반 API 저장 방식으로 전환합니다. +3. 사무실 자리배치 좌표 저장 기능을 추가합니다. +4. 인증 정책이 정해지면 화면상 로그인 대신 실제 로그인으로 교체합니다. + +## 10. 현재 로컬 테스트 접속 정보 +- 접속 주소: `http://localhost:8080` +- 상태 확인 API: `http://localhost:8080/api/health` +- WSL 내부 실행 경로: + - `/home/hyunho/projects/mh-dashboard-organization` diff --git a/docs/WSL_WORKSPACE_GUIDE.md b/docs/WSL_WORKSPACE_GUIDE.md new file mode 100755 index 0000000..af03ac6 --- /dev/null +++ b/docs/WSL_WORKSPACE_GUIDE.md @@ -0,0 +1,34 @@ +# WSL 작업 기준 가이드 + +## 1. 왜 WSL 기준으로 작업하나 +- 현재 이 프로젝트는 Ubuntu 24.04 기반 Docker 환경에서 실행되고 있습니다. +- Windows 폴더에서 바로 작업하면 실행 경로와 편집 경로가 달라질 수 있습니다. +- 그래서 앞으로는 `WSL Ubuntu 내부 경로`를 기준 작업공간으로 사용하는 것을 권장합니다. + +## 2. 기준 작업 경로 +- 사용자: `hyunho` +- 프로젝트 경로: `/home/hyunho/projects/mh-dashboard-organization` + +## 3. VS Code에서 여는 방법 +1. VS Code 명령 팔레트를 엽니다. +2. `Remote-WSL: Reopen Folder in WSL` 를 실행합니다. +3. 아래 경로를 엽니다. + - `/home/hyunho/projects/mh-dashboard-organization` +4. 좌측 아래 상태 표시줄에 `WSL: Ubuntu-24.04` 가 보이면 정상입니다. + +## 4. 앞으로의 작업 규칙 +- 코드 수정은 가능하면 WSL 경로 기준으로 진행합니다. +- Docker 실행, Python 실행, 배포 테스트도 WSL 안에서 진행합니다. +- Windows 경로는 참고용 또는 백업용으로만 보고, 실행 기준으로 사용하지 않는 것이 좋습니다. + +## 5. 자주 쓰는 명령 +```bash +cd /home/hyunho/projects/mh-dashboard-organization +docker compose ps +docker compose logs backend +docker compose up -d +``` + +## 6. 현재 확인 가능한 주소 +- 메인 화면: `http://localhost:8080` +- API 상태 확인: `http://localhost:8080/api/health` diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100755 index 0000000..e39287b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.27-alpine + +COPY frontend/public /usr/share/nginx/html + diff --git a/frontend/public/app.js b/frontend/public/app.js new file mode 100755 index 0000000..a43b19e --- /dev/null +++ b/frontend/public/app.js @@ -0,0 +1,85 @@ +const sessionKey = "mh-dashboard-session"; + +const loginPanel = document.getElementById("login-panel"); +const dashboardPanel = document.getElementById("dashboard-panel"); +const loginForm = document.getElementById("login-form"); +const loginMessage = document.getElementById("login-message"); +const logoutBtn = document.getElementById("logout-btn"); +const userBadge = document.getElementById("user-badge"); +const healthStatus = document.getElementById("health-status"); +const refreshHealthBtn = document.getElementById("refresh-health-btn"); + +function getSession() { + try { + return JSON.parse(sessionStorage.getItem(sessionKey) || "null"); + } catch { + return null; + } +} + +function setSession(session) { + sessionStorage.setItem(sessionKey, JSON.stringify(session)); +} + +function clearSession() { + sessionStorage.removeItem(sessionKey); +} + +function renderAuth() { + const session = getSession(); + const authenticated = Boolean(session?.user?.display_name); + loginPanel.classList.toggle("hidden", authenticated); + dashboardPanel.classList.toggle("hidden", !authenticated); + if (authenticated) { + userBadge.textContent = `${session.user.display_name} / ${session.user.role}`; + refreshHealth(); + } +} + +async function refreshHealth() { + if (!healthStatus) return; + healthStatus.textContent = "서버 상태를 확인하는 중입니다."; + try { + const response = await fetch("/api/health"); + if (!response.ok) throw new Error("unhealthy"); + const payload = await response.json(); + healthStatus.textContent = `API 상태: ${payload.status}`; + } catch (error) { + healthStatus.textContent = "API에 연결할 수 없습니다. backend 컨테이너를 확인해주세요."; + } +} + +if (loginForm) { + loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + loginMessage.textContent = "로그인 처리 중입니다."; + const formData = new FormData(loginForm); + try { + const response = await fetch("/api/mock-login", { + method: "POST", + body: formData, + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.detail || "login failed"); + setSession(payload); + loginForm.reset(); + renderAuth(); + } catch (error) { + loginMessage.textContent = "로그인에 실패했습니다. backend 연결 상태를 확인해주세요."; + } + }); +} + +if (logoutBtn) { + logoutBtn.addEventListener("click", () => { + clearSession(); + renderAuth(); + }); +} + +if (refreshHealthBtn) { + refreshHealthBtn.addEventListener("click", refreshHealth); +} + +renderAuth(); + diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100755 index 0000000..ab9d195 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,79 @@ + + + + + + MH Dashboard Hub + + + + + + +
+
+
+

Intranet Preview

+

MH Dashboard Hub

+

+ 현재 단계에서는 화면상 로그인만 우선 적용합니다. 로그인 후 조직도 레거시 화면과 + 서버 준비 상태를 한 곳에서 확인할 수 있습니다. +

+
+ +
+ + +
+ + + + diff --git a/frontend/public/organization.html b/frontend/public/organization.html new file mode 100755 index 0000000..97850c3 --- /dev/null +++ b/frontend/public/organization.html @@ -0,0 +1,25 @@ + + + + + + 조직도 관리 + + + + + + +
+
+

Legacy Bridge

+

조직도 레거시 화면

+
+ 허브로 돌아가기 +
+
+ +
+ + + diff --git a/frontend/public/styles.css b/frontend/public/styles.css new file mode 100755 index 0000000..7d06f46 --- /dev/null +++ b/frontend/public/styles.css @@ -0,0 +1,210 @@ +:root { + --bg: #eef4f1; + --panel: rgba(255, 255, 255, 0.86); + --line: rgba(15, 23, 42, 0.08); + --text: #173028; + --muted: #5f746d; + --accent: #0f766e; + --accent-soft: #d7f3ee; + --shadow: 0 24px 60px rgba(14, 48, 41, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "SUIT", sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.22), transparent 28%), + radial-gradient(circle at bottom right, rgba(20, 184, 166, 0.18), transparent 24%), + linear-gradient(135deg, #f5fbf7 0%, #e9f0fb 100%); +} + +.app-shell { + max-width: 1240px; + margin: 0 auto; + padding: 48px 20px 56px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 28px; + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.hero { + display: grid; + gap: 28px; + grid-template-columns: 1.2fr 0.8fr; + padding: 36px; +} + +.eyebrow { + margin: 0 0 10px; + color: var(--accent); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.hero h1, +.topbar h2, +.card h3 { + margin: 0; +} + +.hero h1 { + font-size: clamp(2.3rem, 5vw, 4.5rem); + line-height: 0.95; +} + +.hero-text, +.card p, +.helper-text, +.roadmap { + color: var(--muted); + line-height: 1.6; +} + +.login-card { + display: grid; + gap: 16px; + padding: 24px; + border-radius: 24px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.login-card label, +.topbar { + display: flex; + flex-direction: column; + gap: 8px; +} + +input, +button, +.anchor-button, +.primary-link { + font: inherit; +} + +input { + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 14px; + padding: 14px 16px; + background: #fff; +} + +button, +.primary-link, +.anchor-button { + display: inline-flex; + justify-content: center; + align-items: center; + gap: 8px; + min-height: 48px; + border-radius: 14px; + border: none; + cursor: pointer; + font-weight: 800; + text-decoration: none; +} + +button, +.primary-link { + background: var(--accent); + color: #fff; +} + +.secondary, +.anchor-button { + background: var(--accent-soft); + color: var(--accent); +} + +.hidden { + display: none !important; +} + +.topbar { + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.topbar.compact { + max-width: 1240px; + margin: 0 auto; + padding: 28px 20px 0; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.badge { + padding: 10px 14px; + border-radius: 999px; + background: rgba(15, 118, 110, 0.12); + color: var(--accent); + font-weight: 700; +} + +.grid { + display: grid; + gap: 20px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.card { + padding: 24px; +} + +.roadmap { + padding-left: 18px; + margin: 0; +} + +.iframe-wrap { + max-width: 1240px; + margin: 0 auto; + padding: 20px; +} + +.iframe-wrap iframe { + width: 100%; + min-height: calc(100vh - 130px); + border: 1px solid var(--line); + border-radius: 24px; + background: #fff; + box-shadow: var(--shadow); +} + +.subpage-body { + padding-bottom: 20px; +} + +@media (max-width: 920px) { + .hero, + .grid { + grid-template-columns: 1fr; + } + + .topbar, + .topbar-actions { + flex-direction: column; + align-items: flex-start; + } +} + diff --git a/legacy/static/organization.css b/legacy/static/organization.css new file mode 100644 index 0000000..cc85d65 --- /dev/null +++ b/legacy/static/organization.css @@ -0,0 +1,798 @@ +body { + margin: 0; + background: #f1f5f9; + font-family: 'Pretendard', sans-serif; + color: #1e293b; + overflow-x: hidden; +} + +.top-wrap { + position: sticky; + top: 0; + z-index: 1000; + background: white; + border-bottom: 1px solid #e2e8f0; + padding: 10px 20px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.btn-primary { + background: #4f46e5; + color: white; + padding: 7px 16px; + border-radius: 8px; + font-weight: 800; + font-size: 12px; + cursor: pointer; + border: none; +} + +.org-canvas { + padding: 40px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 40px; + width: 100%; + position: relative; +} + +.dept-section { + width: 100%; + max-width: 1900px; + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 40px; + position: relative; +} + +.dept-box { + width: fit-content; + min-width: 320px; + background: white; + border: 1px solid #cbd5e1; + border-radius: 10px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); + position: relative; + z-index: 20; + margin-bottom: 40px; +} + +.dept-header { + background: #1e293b; + color: white; + padding: 12px; + text-align: center; + font-size: 17px; + font-weight: 900; + border-radius: 10px; +} + +.dept-header.has-members { + border-radius: 10px 10px 0 0; + border-bottom: none; + margin-bottom: 15px; +} + +.node-group { + display: flex; + flex-wrap: wrap; + justify-content: center; + width: 100%; + position: relative; + gap: 12px; +} + +.node-item { + display: flex; + flex-direction: column; + align-items: center; + position: relative; +} + +.box { + width: fit-content; + min-width: 112px; + background: white; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + position: relative; + z-index: 10; + margin-bottom: 40px; +} + +.box-name { + font-size: 13px; + font-weight: 800; + color: #475569; + text-align: center; + border-bottom: 1px solid #f1f5f9; + padding-bottom: 4px; + margin-bottom: 6px; + word-break: keep-all; +} + +.box-level-그룹 { + min-width: 250px; +} + +.box-level-그룹 .box-name { + background: #3f516a; + color: #ffffff; + padding: 8px; + border-radius: 6px 6px 0 0; + margin: -6px -6px 8px -6px; + border-bottom: none; +} + +.box-level-디비전 { + min-width: 150px; +} + +.box-level-디비전 .box-name { + background: #869fb7; + color: #ffffff; + padding: 8px; + border-radius: 6px 6px 0 0; + margin: -6px -6px 8px -6px; + border-bottom: none; +} + +.box-level-팀 { + width: auto; + min-width: 120px; +} + +.box-team { + width: auto; + min-width: 120px; +} + +.member-grid { + display: grid; + grid-template-rows: repeat(10, auto); + grid-auto-flow: column; + gap: 3px; + column-gap: 8px; +} + +.cell-label { + grid-column: span 1; + background: #e2e8f0; + color: #475569; + font-size: 10px; + font-weight: 900; + text-align: center; + padding: 3px; + border-radius: 4px; + margin: 2px 0; + height: fit-content; +} + +.spacer-box { + width: 100px; + height: 26px; + visibility: hidden; +} + +.member-card { + width: 100px; + padding: 4px 6px; + border-radius: 4px; + font-size: 11.5px; + text-align: left; + border: 1px solid #f1f5f9; + border-left: 4px solid #94a3b8; + background: #f8fafc; + cursor: pointer; + transition: all 0.2s ease-in-out; + display: flex; + flex-direction: column; + position: relative; +} + +.member-card.full-width { + width: 100% !important; +} + +.member-card:hover { + background: white; + border-color: #4f46e5; + box-shadow: 0 4px 10px rgba(79, 70, 229, 0.2); + transform: translateY(-2px); + z-index: 50; +} + +.drop-left::before { + content: ''; + position: absolute; + left: -6px; + top: 0; + width: 4px; + height: 100%; + background: #4f46e5; + border-radius: 4px; + z-index: 20; +} + +.drop-right::after { + content: ''; + position: absolute; + right: -6px; + top: 0; + width: 4px; + height: 100%; + background: #4f46e5; + border-radius: 4px; + z-index: 20; +} + +.co-삼안 { + border-left-color: #ffb366 !important; +} + +.co-한맥 { + border-left-color: #ef4444 !important; +} + +.co-피티씨 { + border-left-color: #a855f7 !important; +} + +.co-바론 { + border-left-color: #3b82f6 !important; +} + +.m-top { + display: flex; + align-items: baseline; + gap: 4px; +} + +.m-name { + font-weight: 900; + color: #1e293b; + font-size: 12px; +} + +.m-rank { + color: #94a3b8; + font-size: 8px; + font-weight: 500; + margin-left: auto; +} + +.m-role { + color: #4f46e5; + font-weight: 800; + font-size: 8.5px; + margin-left: 3px; +} + +#modal { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(6px); + z-index: 2000; + display: none; + align-items: center; + justify-content: center; +} + +.modal-content { + background: white; + width: 100%; + max-width: 650px; + padding: 35px; + border-radius: 20px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + position: relative; + z-index: 2010; +} + +#last-updated { + z-index: 4000; +} + +@media print { + @page { + size: A3 landscape; + margin: 10mm; + } + + body { + background: white !important; + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + .top-wrap, + .search-container, + .tab-container, + .stat-section, + .fab-container, + .admin-mode-btn, + #last-updated, + .admin-mode-btn { + display: none !important; + } + + .main-content { + padding: 0 !important; + margin: 0 !important; + overflow: visible !important; + width: 100% !important; + } + + .dept-container { + page-break-inside: avoid; + margin-bottom: 20px !important; + } + + .member-card { + box-shadow: none !important; + border: 1px solid #e2e8f0 !important; + } +} + +.modal-content.wide { + max-width: 1200px; +} + +.list-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.list-table th { + background: #f8fafc; + color: #64748b; + font-weight: 800; + padding: 10px; + border: 1px solid #e2e8f0; + position: sticky; + top: 0; + z-index: 20; + text-align: center; +} + +.list-table td { + padding: 8px 10px; + border: 1px solid #e2e8f0; + text-align: center; + background: white; + vertical-align: middle; +} + +.col-name { + width: 90px; +} + +.col-rank { + width: 80px; +} + +.col-pos { + width: 80px; +} + +.col-unit-sm { + width: 70px; +} + +.col-unit-lg { + width: 100px; +} + +.col-corp { + width: 110px; +} + +.col-action { + width: 90px; +} + +.list-header-row { + color: #334155; + font-weight: 800; + cursor: pointer; + user-select: none; + border-bottom: 1px solid #f1f5f9; +} + +.list-header-row td { + font-size: 13px; + text-align: left !important; + padding: 10px 15px !important; +} + +.list-header-row.lvl-0 td { + background: #1e293b !important; + color: white !important; + font-size: 13.5px; + font-weight: 900; +} + +.list-header-row.lvl-1 td { + background: #3f516a !important; + color: white !important; +} + +.list-header-row.lvl-2 td { + background: #869fb7 !important; + color: white !important; +} + +.list-header-row.lvl-3 td { + background: #4f46e5 !important; + color: white !important; +} + +.list-header-row.lvl-4 td { + background: #e2e8f0 !important; + color: #475569 !important; +} + +.list-header-row:hover { + filter: brightness(1.1); +} + +.collapse-icon { + margin-right: 8px; + transition: transform 0.2s; + display: inline-block; +} + +.collapsed .collapse-icon { + transform: rotate(-90deg); +} + +.hidden-row { + display: none !important; +} + +.list-table tr:hover td { + background: #f8fafc; +} + +.list-table tr.dragging { + opacity: 0.5; + background: #eef2ff; +} + +.list-search-target td { + background: #eff6ff !important; + border-top: 2px solid #3b82f6; + border-bottom: 2px solid #3b82f6; +} + +.list-action-btn { + padding: 4px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + transition: all 0.2s; +} + +.btn-edit { + background: #eef2ff; + color: #4f46e5; +} + +.btn-delete { + background: #fef2f2; + color: #ef4444; +} + +.fab-container { + position: fixed; + bottom: 30px; + right: 30px; + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 15px; + z-index: 5000; +} + +.fab-main { + width: 60px; + height: 60px; + background: #4f46e5; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 30px; + box-shadow: 0 10px 25px rgba(79, 70, 229, 0.4); + cursor: pointer; + transition: all 0.3s; + border: none; +} + +.fab-menu { + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 10px; + opacity: 0; + visibility: hidden; + transform: translateY(20px); + transition: all 0.3s; +} + +.fab-container.active .fab-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.fab-container.active .fab-main { + transform: rotate(45deg); + background: #4338ca; +} + +.fab-sub { + width: 50px; + height: 50px; + background: white; + color: #4f46e5; + border: 2px solid #4f46e5; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.fab-sub:hover { + background: #4f46e5; + color: white; + transform: scale(1.1); +} + +.fab-sub::after { + content: attr(data-label); + position: absolute; + right: 65px; + background: #1e293b; + color: white; + padding: 4px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 800; + white-space: nowrap; + opacity: 0; + transition: 0.2s; + pointer-events: none; +} + +.fab-sub:hover::after { + opacity: 1; + right: 75px; +} + +.clickable-title { + cursor: pointer; + transition: color 0.2s; + position: relative; +} + +.clickable-title:hover { + color: #818cf8 !important; + text-decoration: underline; +} + +.search-section { + position: fixed; + top: 75px; + left: 25px; + background: rgba(255, 255, 255, 0.9); + border-radius: 12px; + padding: 10px 18px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); + border: 1px solid #e2e8f0; + z-index: 1010; + display: flex; + align-items: center; + gap: 12px; + backdrop-filter: blur(8px); + transition: all 0.3s; +} + +.search-input { + border: none; + outline: none; + background: transparent; + font-size: 13px; + font-weight: 700; + color: #1e293b; + width: 180px; +} + +.search-icon { + color: #64748b; + display: flex; + align-items: center; +} + +.stats-section { + position: fixed; + top: 75px; + right: 25px; + width: 400px; + background: rgba(255, 255, 255, 0.9); + border-radius: 12px; + padding: 15px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + border: 1px solid #e2e8f0; + z-index: 1010; + backdrop-filter: blur(8px); + transition: all 0.3s; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + border-radius: 8px; + overflow: hidden; + border-style: hidden; + box-shadow: 0 0 0 1px #e2e8f0; +} + +.stats-table th { + background: #f8fafc; + color: #64748b; + font-weight: 800; + padding: 8px 4px; + border: 1px solid #e2e8f0; + text-align: center; +} + +.stats-table td { + padding: 8px 4px; + border: 1px solid #e2e8f0; + text-align: center; + font-weight: 700; + color: #1e293b; +} + +.stats-table .row-label { + background: #f8fafc; + color: #475569; + font-weight: 800; + width: 80px; +} + +.stats-table .total-cell { + background: #eff6ff; + color: #2563eb; + font-weight: 900; +} + +.sum-row { + background: #f1f5f9; +} + +.sum-row td { + font-weight: 900 !important; + color: #0f172a !important; +} + +@keyframes target-pulse { + 0% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.7); transform: scale(1); } + 50% { box-shadow: 0 0 0 10px rgba(79, 70, 229, 0); transform: scale(1.05); } + 100% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0); transform: scale(1); } +} + +.search-target { + animation: target-pulse 1.5s ease-in-out 2; + position: relative; + z-index: 1000 !important; + border-color: #4f46e5 !important; +} + +.admin-mode-btn { + position: fixed; + bottom: 37.5px; + right: 105px; + z-index: 5001; + width: 45px; + height: 45px; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(4px); + border: 1px solid #e2e8f0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transition: all 0.3s; + cursor: pointer; +} + +.admin-mode-btn::after { + content: attr(data-label); + position: absolute; + bottom: 55px; + right: 0; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all 0.2s; + pointer-events: none; +} + +.admin-mode-btn:hover::after { + opacity: 1; + visibility: visible; +} + +.admin-mode-btn.is-admin { + background: #4f46e5; + border-color: #4f46e5; + box-shadow: 0 4px 15px rgba(79, 70, 229, 0.3); +} + +.admin-mode-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); +} + +.dept-tabs-container { + display: flex; + gap: 8px; + margin-top: 15px; + padding: 5px 0; + overflow-x: auto; + scrollbar-width: none; +} + +.dept-tabs-container::-webkit-scrollbar { + display: none; +} + +.dept-tab { + padding: 6px 14px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 20px; + font-size: 11px; + font-weight: 800; + color: #64748b; + cursor: pointer; + white-space: nowrap; + transition: all 0.2s; +} + +.dept-tab:hover { + border-color: #cbd5e1; + background: #f8fafc; +} + +.dept-tab.active { + background: #4f46e5; + color: white; + border-color: #4f46e5; + box-shadow: 0 4px 10px rgba(79, 70, 229, 0.2); +} diff --git a/legacy/static/organization.js b/legacy/static/organization.js new file mode 100644 index 0000000..db1202d --- /dev/null +++ b/legacy/static/organization.js @@ -0,0 +1,1324 @@ +let members = []; +let isAdmin = false; +let selectedDept = '전체'; +let editingMembers = []; +let collapsedUnits = new Set(); +let isListMode = false; +let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; + +const levelOrder = ['부서', '그룹', '디비전', '팀', '셀']; +const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder]; + +function pad(value) { + return String(value).padStart(2, '0'); +} + +function updateTimestamp() { + const now = new Date(); + const dateStr = `${now.getFullYear()}.${pad(now.getMonth() + 1)}.${pad(now.getDate())}`; + const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`; + document.getElementById('last-updated').innerText = `WSL 서버 기준 동기화: ${dateStr} ${timeStr}`; +} + +function rebuildMemberPath(member) { + member._path = levelOrder + .map((level) => ({ level, name: member[level] || '' })) + .filter((item) => item.name !== ''); + return member; +} + +function cloneMembers(items) { + return JSON.parse(JSON.stringify(items)); +} + +function toLegacyMember(item) { + return rebuildMemberPath({ + _id: String(item.id), + id: item.id, + 이름: item.name || '', + 소속회사: item.company || '', + 직급: item.rank || '', + 직책: item.role || '', + 부서: item.department || '', + 그룹: item.grp || '', + 디비전: item.division || '', + 팀: item.team || '', + 셀: item.cell || '', + 근무상태: item.work_status || '', + 근무시간: item.work_time || '', + 전화번호: item.phone || '', + 이메일: item.email || '', + 자리위치: item.seat_label || '', + 사진: item.photo_url || '', + sort_order: item.sort_order ?? 0, + }); +} + +function toApiMember(member, sortOrder) { + return { + name: member['이름'] || '', + company: member['소속회사'] || '', + rank: member['직급'] || '', + role: member['직책'] || '', + department: member['부서'] || '', + grp: member['그룹'] || '', + division: member['디비전'] || '', + team: member['팀'] || '', + cell: member['셀'] || '', + work_status: member['근무상태'] || '', + work_time: member['근무시간'] || '', + phone: member['전화번호'] || '', + email: member['이메일'] || '', + seat_label: member['자리위치'] || '', + photo_url: member['사진'] || '', + sort_order: sortOrder, + }; +} + +async function apiFetch(url, options = {}) { + const config = { ...options }; + config.headers = { ...(options.headers || {}) }; + if (config.body && !(config.body instanceof FormData) && !config.headers['Content-Type']) { + config.headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(url, config); + const contentType = response.headers.get('content-type') || ''; + const payload = contentType.includes('application/json') ? await response.json() : null; + if (!response.ok) { + const detail = payload?.detail || '요청 처리에 실패했습니다.'; + throw new Error(detail); + } + return payload; +} + +function setMembers(items) { + members = items.map(toLegacyMember); + if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) { + selectedDept = '전체'; + } + updateTimestamp(); +} + +async function loadMembers(message) { + if (message) { + emptyStateMessage = message; + } + const payload = await apiFetch('/api/members'); + setMembers(payload.items || []); + if (!members.length) { + emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; + } + render(); +} + +async function syncMembers(nextMembers) { + const payload = await apiFetch('/api/members/bulk-sync', { + method: 'PUT', + body: JSON.stringify({ + items: nextMembers.map((member, index) => toApiMember(member, index)), + }), + }); + setMembers(payload.items || []); + render(); +} + +function jsString(value) { + return String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'"); +} + +function updateDeptTabs(deptList) { + const tabsContainer = document.getElementById('dept-tabs'); + tabsContainer.innerHTML = deptList.map((dept) => ` +
${dept}
+ `).join(''); +} + +function selectDept(dept) { + selectedDept = dept; + render(); +} + +function calculateTotalCount(node) { + let count = node.members.length; + if (node.children) { + node.children.forEach((child) => { + count += calculateTotalCount(child); + }); + } + node.totalCount = count; + return count; +} + +function buildHierarchy(data, depth) { + if (!data || data.length === 0) { + return []; + } + + const orderedGroups = []; + const groupMap = {}; + + data.forEach((member) => { + const currentStep = member._path[depth]; + if (!currentStep) { + return; + } + + const currentName = currentStep.name; + if (!groupMap[currentName]) { + groupMap[currentName] = { + name: currentName, + level: currentStep.level, + members: [], + subData: [], + }; + orderedGroups.push(groupMap[currentName]); + } + + if (member._path.length === depth + 1) { + groupMap[currentName].members.push(member); + } else { + groupMap[currentName].subData.push(member); + } + }); + + return orderedGroups.map((group) => ({ + ...group, + children: buildHierarchy(group.subData, depth + 1), + })); +} + +function createMemberCard(member, isFullWidth = false) { + const card = document.createElement('div'); + card.id = `card-${member._id}`; + card.className = `member-card co-${member['소속회사'] || 'default'} transition-all duration-200 mb-1 last:mb-0`; + if (isFullWidth) { + card.classList.add('full-width'); + } + + card.onclick = (event) => { + event.stopPropagation(); + openModal(member._id); + }; + + if (isAdmin) { + card.setAttribute('draggable', 'true'); + card.ondragstart = (event) => handleDragStart(event, 'member', member._id); + card.ondragend = (event) => handleDragEnd(event); + card.ondragover = (event) => handleDragOverMember(event); + card.ondragleave = (event) => handleDragLeaveMember(event); + card.ondrop = (event) => handleDropMember(event, member._id); + } + + const isLeave = member['근무상태'] === '휴직'; + const roleDisplay = isLeave + ? '휴직' + : ((member['직책'] && member['직책'] !== '팀원') ? `${member['직책']}` : ''); + + card.innerHTML = `
${member['이름']}${roleDisplay}${member['직급'] || ''}
`; + return card; +} + +function getAllSubMembers(node) { + let memberList = [...node.members]; + node.children.forEach((child) => { + memberList = memberList.concat(getAllSubMembers(child)); + }); + return memberList; +} + +function collectTeamItems(teamNode) { + let items = [...teamNode.members]; + teamNode.children.forEach((cell) => { + items.push({ isCellHeader: true, name: cell.name }); + items = items.concat(getAllSubMembers(cell)); + }); + return items; +} + +function createNodeDOM(node, parentId) { + const nodeItem = document.createElement('div'); + nodeItem.className = `node-item${node.children.length || node.members.length ? ' has-children' : ''}`; + + const myId = `node-${encodeURIComponent(`${node.level}_${node.name}`)}`; + const box = document.createElement('div'); + box.className = 'box transition-all duration-200'; + box.id = myId; + box.setAttribute('data-level', node.level); + if (parentId) { + box.setAttribute('data-parent', parentId); + } + + if (isAdmin) { + box.ondragover = (event) => handleDragOver(event); + box.ondragleave = (event) => handleDragLeave(event); + box.ondrop = (event) => handleDrop(event, node.level, node.name); + } + + box.classList.add(`box-level-${node.level}`); + if (node.level === '팀') { + box.classList.add('box-team'); + } + + const displayTitle = `${node.name} (${node.totalCount || 0})`; + const nodeTitleClass = isAdmin ? 'clickable-title' : ''; + box.innerHTML = `
${displayTitle}
`; + + const memberGrid = document.createElement('div'); + const isHighLevel = node.level !== '팀' && node.level !== '셀'; + memberGrid.className = isHighLevel ? 'flex flex-col w-full' : 'member-grid'; + + if (node.level === '팀') { + const teamItems = collectTeamItems(node); + const leaderIdx = teamItems.findIndex((item) => item['직책'] === '팀장'); + const finalItems = []; + if (leaderIdx !== -1) { + finalItems.push(teamItems.splice(leaderIdx, 1)[0]); + } else if (teamItems.length > 0) { + finalItems.push(teamItems.shift()); + } + + while (teamItems.length > 0) { + const nextIndex = finalItems.length; + if (nextIndex > 0 && nextIndex % 10 === 0) { + finalItems.push({ isSpacer: true }); + continue; + } + if (nextIndex % 10 === 9 && teamItems[0].isCellHeader) { + finalItems.push({ isSpacer: true }); + continue; + } + finalItems.push(teamItems.shift()); + } + + finalItems.forEach((item) => { + if (item.isSpacer) { + const spacer = document.createElement('div'); + spacer.className = 'spacer-box'; + memberGrid.appendChild(spacer); + } else if (item.isCellHeader) { + const label = document.createElement('div'); + label.className = `cell-label${isAdmin ? ' clickable-title' : ''}`; + label.innerText = item.name; + if (isAdmin) { + label.onclick = () => openOrgEditModal('셀', item.name); + } + memberGrid.appendChild(label); + } else { + memberGrid.appendChild(createMemberCard(item)); + } + }); + } else { + const isFullWidth = node.level !== '팀' && node.level !== '셀'; + node.members.forEach((member) => memberGrid.appendChild(createMemberCard(member, isFullWidth))); + } + + box.appendChild(memberGrid); + nodeItem.appendChild(box); + + if (node.level !== '팀' && node.children && node.children.length > 0) { + const childrenWrapper = document.createElement('div'); + childrenWrapper.className = 'node-group'; + node.children.forEach((child) => childrenWrapper.appendChild(createNodeDOM(child, myId))); + nodeItem.appendChild(childrenWrapper); + } + + return nodeItem; +} + +function drawLines() { + const container = document.getElementById('tree-root'); + const svg = document.getElementById('svg-canvas'); + if (!svg || !container) { + return; + } + + const containerRect = container.getBoundingClientRect(); + let paths = ''; + + container.querySelectorAll('[data-parent]').forEach((box) => { + const parentId = box.getAttribute('data-parent'); + const parentBox = document.getElementById(parentId); + if (!parentBox) { + return; + } + + const parentLevel = parentBox.getAttribute('data-level'); + const childLevel = box.getAttribute('data-level'); + if (parentLevel === '부서' && childLevel === '그룹') { + return; + } + + const parentRect = parentBox.getBoundingClientRect(); + const childRect = box.getBoundingClientRect(); + const startX = parentRect.left + parentRect.width / 2 - containerRect.left; + const startY = parentRect.bottom - containerRect.top; + const endX = childRect.left + childRect.width / 2 - containerRect.left; + const endY = childRect.top - containerRect.top; + const curveY = endY - 20; + + paths += ``; + }); + + svg.innerHTML = paths; +} + +function updateStatsTable() { + if (!members.length) { + document.getElementById('stats-table-container').innerHTML = ''; + document.getElementById('total-count-badge').innerText = '0명'; + return; + } + + const companies = ['한맥', '삼안', '피티씨', '바론']; + const rankGroups = { + 경영진: ['사장', '부사장'], + 수석: ['수석'], + 책임: ['책임'], + 선임: ['선임'], + 연구: ['연구'], + }; + + const columns = Object.keys(rankGroups); + const stats = {}; + + companies.forEach((company) => { + stats[company] = {}; + columns.forEach((column) => { + stats[company][column] = 0; + }); + stats[company]._total = 0; + }); + + const targetMembers = selectedDept === '전체' + ? members + : members.filter((member) => member['부서'] === selectedDept); + + targetMembers.forEach((member) => { + const company = companies.find((item) => (member['소속회사'] || '').includes(item)); + if (!company) { + return; + } + + const rank = member['직급'] || ''; + for (const [groupName, keywords] of Object.entries(rankGroups)) { + if (keywords.some((keyword) => rank.includes(keyword))) { + stats[company][groupName] += 1; + break; + } + } + stats[company]._total += 1; + }); + + let html = `${columns.map((column) => ``).join('')}`; + const colSums = {}; + columns.forEach((column) => { + colSums[column] = 0; + }); + let grandTotal = 0; + + companies.forEach((company) => { + html += `${columns.map((column) => { + colSums[column] += stats[company][column]; + return ``; + }).join('')}`; + grandTotal += stats[company]._total; + }); + + html += `${columns.map((column) => ``).join('')}
구분${column}합계
${company}${stats[company][column] || '-'}${stats[company]._total}
전체 합계${colSums[column]}${grandTotal}
`; + + document.getElementById('stats-table-container').innerHTML = html; + document.getElementById('total-count-badge').innerText = `${grandTotal}명`; +} + +function render() { + const container = document.getElementById('tree-root'); + container.innerHTML = ''; + + if (!members.length) { + container.innerHTML += `
${emptyStateMessage}
`; + updateStatsTable(); + return; + } + + const allDepts = Array.from(new Set(members.map((member) => member['부서']).filter(Boolean))).sort(); + updateDeptTabs(['전체', ...allDepts]); + + const deptNames = selectedDept === '전체' ? allDepts : [selectedDept]; + deptNames.forEach((deptName) => { + const deptData = members.filter((member) => member['부서'] === deptName); + const hierarchy = buildHierarchy(deptData, 0); + const deptNode = hierarchy[0] || null; + if (deptNode) { + calculateTotalCount(deptNode); + } + + const deptSection = document.createElement('div'); + deptSection.className = 'dept-section'; + const deptId = `node-${encodeURIComponent(`부서_${deptName}`)}`; + const hasMembers = Boolean(deptNode && deptNode.members && deptNode.members.length > 0); + const totalCount = deptNode ? deptNode.totalCount : deptData.length; + + const deptBox = document.createElement('div'); + deptBox.id = deptId; + deptBox.className = 'dept-box'; + deptBox.setAttribute('data-level', '부서'); + deptBox.innerHTML = `
${deptName} (${totalCount})
`; + + if (hasMembers) { + const memberGrid = document.createElement('div'); + memberGrid.className = 'flex flex-col w-full'; + memberGrid.style.padding = '0 15px 15px 15px'; + deptNode.members.forEach((member) => memberGrid.appendChild(createMemberCard(member, true))); + deptBox.appendChild(memberGrid); + } + + deptSection.appendChild(deptBox); + const groupContainer = document.createElement('div'); + groupContainer.className = 'node-group'; + if (deptNode && deptNode.children) { + deptNode.children.forEach((child) => groupContainer.appendChild(createNodeDOM(child, deptId))); + } + deptSection.appendChild(groupContainer); + container.appendChild(deptSection); + }); + + updateStatsTable(); + setTimeout(drawLines, 50); +} + +function toggleAdminMode(checked) { + isAdmin = checked; + const button = document.getElementById('admin-mode-btn'); + if (isAdmin) { + button.classList.add('is-admin'); + button.innerText = '🔓'; + button.setAttribute('data-label', '관리자 모드: ON'); + } else { + button.classList.remove('is-admin'); + button.innerText = '🔐'; + button.setAttribute('data-label', '관리자 모드: OFF'); + } + updateFabMenu(); + render(); +} + +function toggleFab(event) { + if (event) { + event.stopPropagation(); + } + document.getElementById('fab-container').classList.toggle('active'); +} + +function updateFabMenu() { + const menu = document.getElementById('fab-menu'); + let html = ''; + html += ''; + if (isAdmin) { + html += ''; + html += ''; + } + menu.innerHTML = html; +} + +function printA3() { + window.print(); +} + +function toggleStats() { + const container = document.getElementById('stats-table-container'); + const icon = document.getElementById('stats-toggle-icon'); + const area = document.getElementById('stats-area'); + if (container.style.display === 'none') { + container.style.display = 'block'; + icon.style.transform = 'rotate(0deg)'; + area.style.padding = '15px'; + } else { + container.style.display = 'none'; + icon.style.transform = 'rotate(-90deg)'; + area.style.padding = '10px 15px'; + } +} + +function handleSearch(value) { + const query = value.trim().toLowerCase(); + if (!query) { + return; + } + + document.querySelectorAll('.search-target').forEach((element) => element.classList.remove('search-target')); + let targetEl = null; + + const memberMatch = members.find((member) => (member['이름'] || '').toLowerCase().includes(query)); + if (memberMatch) { + targetEl = document.getElementById(`card-${memberMatch._id}`); + } + + if (!targetEl) { + for (const level of levelOrder) { + const orgName = Array.from(new Set(members.map((member) => member[level]).filter(Boolean))) + .find((name) => name.toLowerCase().includes(query)); + if (orgName) { + targetEl = document.getElementById(`node-${encodeURIComponent(`${level}_${orgName}`)}`); + } + if (targetEl) { + break; + } + } + } + + if (!targetEl) { + alert('검색 결과를 찾을 수 없습니다.'); + return; + } + + targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + targetEl.classList.add('search-target'); +} + +async function importMemberFile(file) { + const formData = new FormData(); + formData.append('file', file); + const payload = await apiFetch('/api/members/import', { + method: 'POST', + body: formData, + }); + emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; + setMembers(payload.items || []); + render(); +} + +function openAddModal(event) { + if (event) { + event.stopPropagation(); + } + openModal(null); +} + +function updateParentList() { + const type = document.getElementById('new-unit-type').value; + const parentSelect = document.getElementById('new-unit-parent'); + const typeIdx = levelOrder.indexOf(type); + const parentType = levelOrder[typeIdx - 1]; + const parents = Array.from(new Set(members.map((member) => member[parentType]).filter(Boolean))).sort(); + parentSelect.innerHTML = '' + parents.map((parent) => ``).join(''); +} + +function openUnitAddModal(event) { + if (event) { + event.stopPropagation(); + } + + if (!members.length) { + alert('먼저 조직 데이터를 업로드해주세요.'); + return; + } + + const modal = document.getElementById('modal'); + modal.querySelector('.modal-content').classList.remove('wide'); + document.getElementById('modal-title').innerText = '신규 조직 단위 추가'; + const fieldsArea = document.getElementById('modal-fields'); + fieldsArea.className = 'grid grid-cols-2 gap-x-8 gap-y-5'; + fieldsArea.style.maxHeight = 'none'; + fieldsArea.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+ `; + updateParentList(); + document.getElementById('modal-footer-area').innerHTML = ` + + + `; + modal.style.display = 'flex'; +} + +async function saveNewUnit() { + const type = document.getElementById('new-unit-type').value; + const parentName = document.getElementById('new-unit-parent').value; + const name = document.getElementById('new-unit-name').value.trim(); + + if (!name) { + alert('이름을 입력해주세요.'); + return; + } + + const typeIdx = levelOrder.indexOf(type); + const parentType = levelOrder[typeIdx - 1]; + const template = (parentName && parentType) + ? (members.find((member) => member[parentType] === parentName) || members[0]) + : members[0]; + + const newMember = { + ...cloneMembers([template])[0], + _id: `virtual-${Date.now()}`, + 이름: '공석(신규)', + 직급: '', + 직책: '', + 소속회사: template?.소속회사 || '', + 전화번호: '', + 이메일: '', + 자리위치: '', + 사진: '', + 근무상태: '근무', + 근무시간: '09~18', + }; + + if (!parentName) { + for (let index = 1; index < typeIdx; index += 1) { + newMember[levelOrder[index]] = ''; + } + } else { + newMember[parentType] = parentName; + } + + newMember[type] = name; + for (let index = typeIdx + 1; index < levelOrder.length; index += 1) { + newMember[levelOrder[index]] = ''; + } + + rebuildMemberPath(newMember); + await syncMembers([...members, newMember]); + closeModal(); +} + +function openOrgEditModal(level, oldName) { + const modal = document.getElementById('modal'); + modal.querySelector('.modal-content').classList.remove('wide'); + document.getElementById('modal-title').innerText = `${level} 이름 수정`; + const fieldsArea = document.getElementById('modal-fields'); + fieldsArea.className = 'grid grid-cols-2 gap-x-8 gap-y-5'; + fieldsArea.style.maxHeight = 'none'; + fieldsArea.innerHTML = ` +
+ + +
+ `; + document.getElementById('modal-footer-area').innerHTML = ` + + + + `; + modal.style.display = 'flex'; +} + +async function deleteOrg(level, name) { + if (!confirm(`'${name}' ${level}과 소속된 모든 인원 정보가 삭제됩니다. 정말 삭제하시겠습니까?`)) { + return; + } + const nextMembers = members.filter((member) => member[level] !== name); + await syncMembers(nextMembers); + closeModal(); +} + +async function saveOrgName(level, oldName) { + const newName = document.getElementById('new-org-name').value.trim(); + if (!newName) { + alert('이름을 입력해주세요.'); + return; + } + + if (newName === oldName) { + closeModal(); + return; + } + + const nextMembers = cloneMembers(members); + nextMembers.forEach((member) => { + if (member[level] === oldName) { + member[level] = newName; + rebuildMemberPath(member); + } + }); + await syncMembers(nextMembers); + closeModal(); +} + +function toggleManualInput(field) { + document.getElementById(`manual-${field}`).classList.toggle('hidden', document.getElementById(`sel-${field}`).value !== '__NEW__'); +} + +function toggleFlexibleTime(value) { + document.getElementById('flexible-time-area').classList.toggle('hidden', value !== '유연근무제'); +} + +function switchModalTab(tab) { + const isBasic = tab === 'basic'; + document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic); + document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic); + document.getElementById('modal-tab-basic').className = isBasic + ? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all' + : 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all'; + document.getElementById('modal-tab-org').className = !isBasic + ? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all' + : 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all'; +} + +function openModal(id) { + const sourceList = isListMode ? editingMembers : members; + const modal = document.getElementById('modal'); + modal.querySelector('.modal-content').classList.remove('wide'); + const fieldsArea = document.getElementById('modal-fields'); + const footer = document.getElementById('modal-footer-area'); + const member = id ? (sourceList.find((item) => item._id === id) || {}) : {}; + + if (!isAdmin && id) { + document.getElementById('modal-title').innerText = '구성원 상세 프로필'; + fieldsArea.className = 'flex flex-col items-center gap-6 py-4'; + fieldsArea.style.maxHeight = 'none'; + fieldsArea.innerHTML = ` +
+ +
+
+

${member['이름'] || ''}

+

${member['직급'] || '-'} / ${member['직책'] || '팀원'}

+

${(member._path || []).map((path) => path.name).join(' > ')}

+
+
+
+
+ + ${member['전화번호'] || '정보 없음'} +
+
+ + ${member['이메일'] || '정보 없음'} +
+
+
+ + ${member['자리위치'] || '정보 없음'} +
+
+ `; + footer.innerHTML = ''; + modal.style.display = 'flex'; + return; + } + + document.getElementById('modal-title').innerText = id ? '구성원 정보 수정' : '신규 구성원 추가'; + fieldsArea.className = 'flex flex-col w-full'; + fieldsArea.style.maxHeight = '75vh'; + fieldsArea.style.overflowY = 'auto'; + + const sourceValues = isListMode ? editingMembers : members; + let orgFields = '`; + + fieldsArea.innerHTML = ` +
+ + +
+ + ${orgFields} + `; + + const deleteBtn = id ? `` : ''; + footer.innerHTML = ` + ${deleteBtn} + + + `; + modal.style.display = 'flex'; +} + +function closeModal() { + document.getElementById('modal').style.display = 'none'; + document.getElementById('modal-fields').className = 'grid grid-cols-2 gap-x-8 gap-y-5'; + document.getElementById('modal-fields').style.maxHeight = 'none'; + document.querySelector('.modal-content').classList.remove('wide'); + isListMode = false; +} + +async function saveMember() { + const id = document.getElementById('m-id')?.value || ''; + const name = document.getElementById('m-name').value.trim(); + if (!name) { + alert('이름을 입력해주세요.'); + return; + } + + const targetList = isListMode ? editingMembers : members; + let member = id ? targetList.find((item) => item._id === id) : { _id: `virtual-${Date.now()}` }; + if (!member) { + member = { _id: `virtual-${Date.now()}` }; + } + + member['이름'] = name; + dropdownFields.forEach((field) => { + const selectValue = document.getElementById(`sel-${field}`).value; + if (selectValue === '__NEW__') { + member[field] = document.getElementById(`input-${field}`).value.trim(); + } else if (selectValue === '__NONE__') { + member[field] = ''; + } else { + member[field] = selectValue; + } + }); + + member['근무상태'] = document.getElementById('m-status').value; + member['근무시간'] = document.getElementById('m-worktime').value; + member['전화번호'] = document.getElementById('m-phone').value.trim(); + member['이메일'] = document.getElementById('m-email').value.trim(); + member['자리위치'] = document.getElementById('m-seat').value.trim(); + member['사진'] = document.getElementById('m-photo').value.trim(); + if (member['근무시간'] === '유연근무제') { + member['유연근무_시작'] = document.getElementById('m-work-start').value; + member['유연근무_종료'] = document.getElementById('m-work-end').value; + } else { + member['유연근무_시작'] = ''; + member['유연근무_종료'] = ''; + } + rebuildMemberPath(member); + + if (isListMode) { + if (!id) { + targetList.push(member); + } + renderListViewTable(); + closeModal(); + return; + } + + if (id && member.id) { + await apiFetch(`/api/members/${member.id}`, { + method: 'PUT', + body: JSON.stringify({ + ...toApiMember(member, member.sort_order || 0), + id: member.id, + }), + }); + } else { + await apiFetch('/api/members', { + method: 'POST', + body: JSON.stringify(toApiMember(member, members.length)), + }); + } + + await loadMembers(); + closeModal(); +} + +async function deleteMember(id) { + if (!confirm('해당 구성원을 삭제하시겠습니까?')) { + return; + } + + if (isListMode) { + const idx = editingMembers.findIndex((member) => member._id === id); + if (idx !== -1) { + editingMembers.splice(idx, 1); + } + renderListViewTable(); + return; + } + + const member = members.find((item) => item._id === id); + if (!member?.id) { + return; + } + await apiFetch(`/api/members/${member.id}`, { method: 'DELETE' }); + await loadMembers(); + closeModal(); +} + +function openListViewModal(event) { + if (event) { + event.stopPropagation(); + } + + const modal = document.getElementById('modal'); + modal.querySelector('.modal-content').classList.add('wide'); + document.getElementById('modal-title').innerText = '인원 명단'; + const fieldsArea = document.getElementById('modal-fields'); + fieldsArea.className = 'flex flex-col w-full overflow-hidden'; + fieldsArea.style.maxHeight = '75vh'; + isListMode = true; + editingMembers = cloneMembers(members); + fieldsArea.innerHTML = ` +
+ + +
+
+ `; + renderListViewTable(); + + const footer = document.getElementById('modal-footer-area'); + if (isAdmin) { + footer.innerHTML = ` +
+
+ + +
+
+

항목을 드래그하여 순서를 바꿀 수 있습니다.

+ + +
+
+ `; + } else { + footer.innerHTML = '
'; + } + modal.style.display = 'flex'; +} + +async function applyListViewChanges() { + if (!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) { + return; + } + await syncMembers(editingMembers); + isListMode = false; + closeModal(); +} + +function renderListViewTable() { + const container = document.getElementById('list-table-container'); + if (!container) { + return; + } + + let html = `${isAdmin ? '' : ''}`; + const lastValues = {}; + levelOrder.forEach((level) => { + lastValues[level] = ''; + }); + + editingMembers.forEach((member, index) => { + let isAnyParentCollapsed = false; + levelOrder.forEach((level, depth) => { + const value = (member[level] || '').trim(); + if (!value) { + return; + } + const key = `${level}_${value}`; + const parentLevels = levelOrder.slice(0, depth); + if (parentLevels.some((parentLevel) => member[parentLevel] && collapsedUnits.has(`${parentLevel}_${member[parentLevel].trim()}`))) { + isAnyParentCollapsed = true; + } + if (value !== lastValues[level]) { + const isCollapsed = collapsedUnits.has(key); + const dragAttr = isAdmin ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : ''; + html += ``; + lastValues[level] = value; + levelOrder.slice(depth + 1).forEach((childLevel) => { + lastValues[childLevel] = ''; + }); + } + }); + + const hidden = levelOrder.some((level) => member[level] && collapsedUnits.has(`${level}_${member[level].trim()}`)) || isAnyParentCollapsed; + const rowDragAttr = isAdmin ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : ''; + html += ` + + ${isAdmin ? '' : ''} + + + + + + + + + + + + `; + }); + + html += '
순서이름직급직책디비전그룹부서소속${isAdmin ? '관리' : '조회'}
${value}
${member['이름']}${member['직급'] || '-'}${member['근무상태'] === '휴직' ? '휴직' : (member['직책'] || '-')}${member['셀'] || '-'}${member['팀'] || '-'}${member['디비전'] || '-'}${member['그룹'] || '-'}${member['부서'] || '-'}${member['소속회사'] || '-'}${isAdmin ? `
수정삭제
` : `조회`}
'; + container.innerHTML = html; +} + +function toggleUnitCollapse(level, name) { + const key = `${level}_${name}`; + if (collapsedUnits.has(key)) { + collapsedUnits.delete(key); + } else { + collapsedUnits.add(key); + } + renderListViewTable(); +} + +let draggedGroup = null; +function handleListGroupDragStart(event, level, name) { + draggedGroup = { level, name }; + event.dataTransfer.effectAllowed = 'move'; +} + +function handleListGroupDrop(event, targetLevel, targetName) { + event.preventDefault(); + if (!draggedGroup || (draggedGroup.level === targetLevel && draggedGroup.name === targetName)) { + return; + } + const movingMembers = editingMembers.filter((member) => member[draggedGroup.level] === draggedGroup.name); + if (!movingMembers.length) { + return; + } + editingMembers = editingMembers.filter((member) => member[draggedGroup.level] !== draggedGroup.name); + let targetIdx = editingMembers.findIndex((member) => member[targetLevel] === targetName); + if (targetIdx === -1) { + targetIdx = editingMembers.length; + } + editingMembers.splice(targetIdx, 0, ...movingMembers); + draggedGroup = null; + renderListViewTable(); +} + +function handleListSearch(value) { + const query = value.trim().toLowerCase(); + if (!query) { + return; + } + document.querySelectorAll('.list-search-target').forEach((element) => element.classList.remove('list-search-target')); + const targetMember = editingMembers.find((member) => ( + (member['이름'] || '').toLowerCase().includes(query) + || levelOrder.some((level) => (member[level] || '').toLowerCase().includes(query)) + )); + if (!targetMember) { + alert('검색 결과가 없습니다.'); + return; + } + const row = document.getElementById(`list-row-${targetMember._id}`); + if (row) { + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + row.classList.add('list-search-target'); + setTimeout(() => row.classList.remove('list-search-target'), 2000); + } +} + +let draggedIdx = null; +function handleListDragStart(event, index) { + draggedIdx = index; + event.dataTransfer.effectAllowed = 'move'; + event.target.classList.add('dragging'); +} + +function handleListDrop(event, targetIdx) { + event.preventDefault(); + if (draggedIdx === null || draggedIdx === targetIdx) { + return; + } + const moved = editingMembers.splice(draggedIdx, 1)[0]; + editingMembers.splice(targetIdx, 0, moved); + draggedIdx = null; + renderListViewTable(); +} + +function handleDragStart(event, type, id) { + event.stopPropagation(); + if (type !== 'member') { + return; + } + event.dataTransfer.setData('text/plain', JSON.stringify({ type, id })); + event.dataTransfer.effectAllowed = 'move'; + setTimeout(() => event.target.classList.add('opacity-40', 'scale-95'), 0); +} + +function handleDragEnd(event) { + event.stopPropagation(); + event.target.classList.remove('opacity-40', 'scale-95'); +} + +function handleDragOver(event) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + event.currentTarget.classList.add('ring-4', 'ring-indigo-400', 'bg-indigo-50'); +} + +function handleDragLeave(event) { + event.stopPropagation(); + event.currentTarget.classList.remove('ring-4', 'ring-indigo-400', 'bg-indigo-50'); +} + +async function handleDrop(event, targetLevel, targetName) { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.remove('ring-4', 'ring-indigo-400', 'bg-indigo-50'); + + try { + const data = JSON.parse(event.dataTransfer.getData('text/plain')); + if (data.type !== 'member') { + return; + } + const targetMember = members.find((member) => member[targetLevel] === targetName); + const targetLevelIndex = levelOrder.indexOf(targetLevel); + const memberIndex = members.findIndex((item) => item._id === data.id); + if (memberIndex === -1) { + return; + } + const nextMembers = cloneMembers(members); + const moved = nextMembers[memberIndex]; + for (let index = 0; index <= targetLevelIndex; index += 1) { + moved[levelOrder[index]] = targetMember ? targetMember[levelOrder[index]] : targetName; + } + for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) { + moved[levelOrder[index]] = ''; + } + rebuildMemberPath(moved); + nextMembers.splice(memberIndex, 1); + nextMembers.push(moved); + await syncMembers(nextMembers); + } catch (error) { + console.error(error); + } +} + +function handleDragOverMember(event) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + const rect = event.currentTarget.getBoundingClientRect(); + const relX = event.clientX - rect.left; + if (relX < rect.width / 2) { + event.currentTarget.classList.add('drop-left'); + event.currentTarget.classList.remove('drop-right'); + } else { + event.currentTarget.classList.add('drop-right'); + event.currentTarget.classList.remove('drop-left'); + } +} + +function handleDragLeaveMember(event) { + event.stopPropagation(); + event.currentTarget.classList.remove('drop-left', 'drop-right'); +} + +async function handleDropMember(event, targetId) { + event.preventDefault(); + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + const insertAfter = event.clientX - rect.left >= rect.width / 2; + event.currentTarget.classList.remove('drop-left', 'drop-right'); + + try { + const data = JSON.parse(event.dataTransfer.getData('text/plain')); + if (data.type !== 'member' || data.id === targetId) { + return; + } + const nextMembers = cloneMembers(members); + let movingIdx = nextMembers.findIndex((member) => member._id === data.id); + let targetIdx = nextMembers.findIndex((member) => member._id === targetId); + if (movingIdx === -1 || targetIdx === -1) { + return; + } + const moved = nextMembers[movingIdx]; + const target = nextMembers[targetIdx]; + levelOrder.forEach((level) => { + moved[level] = target[level]; + }); + rebuildMemberPath(moved); + nextMembers.splice(movingIdx, 1); + targetIdx = nextMembers.findIndex((member) => member._id === targetId); + nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved); + await syncMembers(nextMembers); + } catch (error) { + console.error(error); + } +} + +window.addEventListener('resize', () => { + requestAnimationFrame(drawLines); +}); + +window.addEventListener('click', () => { + document.getElementById('fab-container').classList.remove('active'); +}); + +document.addEventListener('DOMContentLoaded', async () => { + document.getElementById('upload-button').addEventListener('click', () => { + document.getElementById('upload-excel').click(); + }); + document.getElementById('upload-excel').addEventListener('change', async (event) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + try { + await importMemberFile(file); + event.target.value = ''; + } catch (error) { + alert(error.message || '업로드에 실패했습니다.'); + } + }); + document.getElementById('admin-mode-btn').addEventListener('click', () => toggleAdminMode(!isAdmin)); + document.getElementById('fab-main').addEventListener('click', (event) => toggleFab(event)); + document.getElementById('search-input').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + handleSearch(event.target.value); + } + }); + document.getElementById('stats-header').addEventListener('click', toggleStats); + document.getElementById('modal-cancel-btn').addEventListener('click', closeModal); + + updateFabMenu(); + try { + await loadMembers('서버에서 조직 데이터를 불러오는 중입니다.'); + } catch (error) { + console.error(error); + emptyStateMessage = `WSL 서버 연결에 실패했습니다. ${error.message || ''}`; + render(); + } +}); diff --git a/organization.xlsx b/organization.xlsx new file mode 100755 index 0000000..2719340 Binary files /dev/null and b/organization.xlsx differ diff --git a/proxy/nginx.conf b/proxy/nginx.conf new file mode 100755 index 0000000..356b2c8 --- /dev/null +++ b/proxy/nginx.conf @@ -0,0 +1,42 @@ +server { + listen 80; + server_name _; + + client_max_body_size 20m; + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /uploads/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /snapshots/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /legacy/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +