Initial dashboard organization setup
This commit is contained in:
8
.env.example
Executable file
8
.env.example
Executable 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
11
.gitignore
vendored
Executable 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
2014
DashBoard-organization-backup.html
Executable file
File diff suppressed because it is too large
Load Diff
70
DashBoard-organization.html
Normal file
70
DashBoard-organization.html
Normal 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
19
backend/Dockerfile
Executable 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
1
backend/app/__init__.py
Executable file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
14
backend/app/config.py
Executable file
14
backend/app/config.py
Executable 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
75
backend/app/db.py
Executable 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
404
backend/app/main.py
Executable 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
5
backend/requirements.txt
Executable 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
42
docker-compose.yml
Executable 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
76
docs/DEPLOYMENT_GUIDE.md
Executable 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
34
docs/WSL_WORKSPACE_GUIDE.md
Executable 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
4
frontend/Dockerfile
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY frontend/public /usr/share/nginx/html
|
||||||
|
|
||||||
85
frontend/public/app.js
Executable file
85
frontend/public/app.js
Executable 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
79
frontend/public/index.html
Executable 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>
|
||||||
|
|
||||||
25
frontend/public/organization.html
Executable file
25
frontend/public/organization.html
Executable 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
210
frontend/public/styles.css
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
798
legacy/static/organization.css
Normal file
798
legacy/static/organization.css
Normal 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);
|
||||||
|
}
|
||||||
1324
legacy/static/organization.js
Normal file
1324
legacy/static/organization.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
organization.xlsx
Executable file
BIN
organization.xlsx
Executable file
Binary file not shown.
42
proxy/nginx.conf
Executable file
42
proxy/nginx.conf
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user