Initial dashboard organization setup

This commit is contained in:
hyunho
2026-03-25 10:26:33 +09:00
commit d9023abed6
22 changed files with 5340 additions and 0 deletions

8
.env.example Executable file
View File

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

11
.gitignore vendored Executable file
View File

@@ -0,0 +1,11 @@
.gitea_token
.env
__pycache__/
*.pyc
.pytest_cache/
backend/.venv/
backend/data/
uploads/
snapshots/
node_modules/

2014
DashBoard-organization-backup.html Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MH 조직현황 관리</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/legacy/static/organization.css" />
</head>
<body>
<div class="top-wrap">
<h1 class="text-sm font-black text-slate-800 tracking-tight">MH 조직현황 관리</h1>
<div class="flex items-center gap-2">
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
<button id="upload-button" class="btn-primary">조직현황 업로드</button>
</div>
</div>
<div class="search-section">
<div class="flex flex-col w-full">
<div class="relative flex items-center w-full">
<span class="search-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</span>
<input type="text" id="search-input" placeholder="이름 또는 조직 검색" class="search-input" />
</div>
<div id="dept-tabs" class="dept-tabs-container"></div>
</div>
</div>
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header">
<h2 class="text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);"></span>
</div>
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
</div>
<div id="tree-root" class="org-canvas">
<div class="text-slate-400 font-bold mt-20 text-xs text-center">서버에서 조직 데이터를 불러오는 중입니다.</div>
</div>
<button id="admin-mode-btn" class="admin-mode-btn" data-label="관리자 모드 전환">🔐</button>
<div id="last-updated" class="fixed bottom-4 left-5 text-[10px] text-slate-400 font-bold z-[4000] pointer-events-none" style="letter-spacing: 0.02em; opacity: 0.8;"></div>
<div class="fab-container" id="fab-container">
<button class="fab-main text-white" id="fab-main">+</button>
<div class="fab-menu" id="fab-menu"></div>
</div>
<div id="modal">
<div class="modal-content">
<div id="modal-header-area">
<h2 id="modal-title" class="text-xl font-black mb-6 text-slate-800 border-b pb-4">구성원 정보 수정</h2>
</div>
<div id="modal-fields" class="grid grid-cols-2 gap-x-8 gap-y-5"></div>
<div id="modal-footer-area" class="flex gap-4 mt-10">
<button id="modal-cancel-btn" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button id="btn-save" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
</div>
</div>
</div>
<script src="/legacy/static/organization.js"></script>
</body>
</html>

19
backend/Dockerfile Executable file
View File

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

1
backend/app/__init__.py Executable file
View File

@@ -0,0 +1 @@

14
backend/app/config.py Executable file
View File

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

75
backend/app/db.py Executable file
View File

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

404
backend/app/main.py Executable file
View File

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

5
backend/requirements.txt Executable file
View File

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

42
docker-compose.yml Executable file
View File

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

76
docs/DEPLOYMENT_GUIDE.md Executable file
View File

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

34
docs/WSL_WORKSPACE_GUIDE.md Executable file
View File

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

4
frontend/Dockerfile Executable file
View File

@@ -0,0 +1,4 @@
FROM nginx:1.27-alpine
COPY frontend/public /usr/share/nginx/html

85
frontend/public/app.js Executable file
View File

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

79
frontend/public/index.html Executable file
View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MH Dashboard Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=SUIT:wght@400;500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app-shell">
<section class="panel hero" id="login-panel">
<div class="hero-copy">
<p class="eyebrow">Intranet Preview</p>
<h1>MH Dashboard Hub</h1>
<p class="hero-text">
현재 단계에서는 화면상 로그인만 우선 적용합니다. 로그인 후 조직도 레거시 화면과
서버 준비 상태를 한 곳에서 확인할 수 있습니다.
</p>
</div>
<form id="login-form" class="login-card">
<label>
<span>아이디</span>
<input name="username" type="text" placeholder="예: admin" required>
</label>
<label>
<span>비밀번호</span>
<input name="password" type="password" placeholder="아무 값이나 입력" required>
</label>
<button type="submit">대시보드 입장</button>
<p id="login-message" class="helper-text">사내망용 임시 로그인 화면입니다.</p>
</form>
</section>
<section id="dashboard-panel" class="hidden">
<header class="topbar">
<div>
<p class="eyebrow">Internal Dashboard</p>
<h2>조직 관리 허브</h2>
</div>
<div class="topbar-actions">
<span id="user-badge" class="badge"></span>
<button id="logout-btn" class="secondary">로그아웃</button>
</div>
</header>
<main class="grid">
<article class="panel card">
<p class="eyebrow">Legacy Module</p>
<h3>조직도 관리</h3>
<p>기존 단일 HTML 조직도 도구를 보존한 상태로 연결했습니다.</p>
<a class="primary-link" href="/organization.html">레거시 조직도 열기</a>
</article>
<article class="panel card">
<p class="eyebrow">API Readiness</p>
<h3>서버 상태</h3>
<p id="health-status">서버 상태를 확인하는 중입니다.</p>
<button id="refresh-health-btn" class="secondary">상태 새로고침</button>
</article>
<article class="panel card">
<p class="eyebrow">Roadmap</p>
<h3>다음 단계</h3>
<ul class="roadmap">
<li>프로필 사진 업로드 API 연결</li>
<li>좌석 배치도 좌표 저장 기능 연결</li>
<li>월말 스냅샷 자동화</li>
</ul>
</article>
</main>
</section>
</div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>조직도 관리</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=SUIT:wght@400;500;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body class="subpage-body">
<header class="topbar compact">
<div>
<p class="eyebrow">Legacy Bridge</p>
<h2>조직도 레거시 화면</h2>
</div>
<a class="secondary anchor-button" href="/">허브로 돌아가기</a>
</header>
<main class="iframe-wrap">
<iframe src="/legacy/organization" title="조직도 레거시 앱"></iframe>
</main>
</body>
</html>

210
frontend/public/styles.css Executable file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

BIN
organization.xlsx Executable file

Binary file not shown.

42
proxy/nginx.conf Executable file
View File

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