From d9023abed648b4fa31885535d859b67a5b567f65 Mon Sep 17 00:00:00 2001 From: hyunho Date: Wed, 25 Mar 2026 10:26:33 +0900 Subject: [PATCH] Initial dashboard organization setup --- .env.example | 8 + .gitignore | 11 + DashBoard-organization-backup.html | 2014 ++++++++++++++++++++++++++++ DashBoard-organization.html | 70 + backend/Dockerfile | 19 + backend/app/__init__.py | 1 + backend/app/config.py | 14 + backend/app/db.py | 75 ++ backend/app/main.py | 404 ++++++ backend/requirements.txt | 5 + docker-compose.yml | 42 + docs/DEPLOYMENT_GUIDE.md | 76 ++ docs/WSL_WORKSPACE_GUIDE.md | 34 + frontend/Dockerfile | 4 + frontend/public/app.js | 85 ++ frontend/public/index.html | 79 ++ frontend/public/organization.html | 25 + frontend/public/styles.css | 210 +++ legacy/static/organization.css | 798 +++++++++++ legacy/static/organization.js | 1324 ++++++++++++++++++ organization.xlsx | Bin 0 -> 23068 bytes proxy/nginx.conf | 42 + 22 files changed, 5340 insertions(+) create mode 100755 .env.example create mode 100755 .gitignore create mode 100755 DashBoard-organization-backup.html create mode 100644 DashBoard-organization.html create mode 100755 backend/Dockerfile create mode 100755 backend/app/__init__.py create mode 100755 backend/app/config.py create mode 100755 backend/app/db.py create mode 100755 backend/app/main.py create mode 100755 backend/requirements.txt create mode 100755 docker-compose.yml create mode 100755 docs/DEPLOYMENT_GUIDE.md create mode 100755 docs/WSL_WORKSPACE_GUIDE.md create mode 100755 frontend/Dockerfile create mode 100755 frontend/public/app.js create mode 100755 frontend/public/index.html create mode 100755 frontend/public/organization.html create mode 100755 frontend/public/styles.css create mode 100644 legacy/static/organization.css create mode 100644 legacy/static/organization.js create mode 100755 organization.xlsx create mode 100755 proxy/nginx.conf 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 0000000000000000000000000000000000000000..2719340aa7749574e718a86a28a782c024cae4c8 GIT binary patch literal 23068 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 F?mv#9J9Yp7 literal 0 HcmV?d00001 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; + } +} +