feat(erp): ERP 대시보드 + 관리자 권한 시스템 도입

- 로그인: dbxcorp.co.kr 도메인 화이트리스트만 사용. ALLOWED_EMAILS 게이트 제거
- king@dbxcorp.co.kr 슈퍼관리자 자동 부여 (강등/삭제 불가)
- 신규 로그인 사용자는 JSON 저장소에 자동 등록 (기본 모듈 권한 0)
- 관리자 페이지(/admin): 역할/모듈별 토글로 직원 권한 수정
- 메인 대시보드를 ERP 시스템(monochromatic shadcn)으로 재설계
  · 모듈: CORM, Order, 개인경비(준비중), 휴가(준비중)
  · 권한 없는 모듈은 '권한 없음' 배지 + 클릭 불가
- DESIGN.md 토큰 기반 styles → app/static/erp.css
- docker-compose: dbx-main-data 볼륨 마운트 (DATA_DIR=/data)
  으로 권한 JSON 영구 보관

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 23:36:51 +09:00
parent 64c77b36ea
commit 316e3fe8d1
7 changed files with 1351 additions and 165 deletions
+184 -42
View File
@@ -5,12 +5,22 @@ from typing import Any
from authlib.integrations.starlette_client import OAuth, OAuthError
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware
from .store import (
MODULE_KEYS,
SUPER_ADMIN_EMAIL,
UserStore,
allowed_modules,
has_module,
is_admin,
)
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
SESSION_COOKIE_DEFAULT = "session"
@@ -20,14 +30,17 @@ load_dotenv()
BASE_DIR = Path(__file__).resolve().parent
ALLOWED_DOMAIN = "dbxcorp.co.kr"
# ALLOWED_EMAILS 는 운영 시 .env 의 ALLOWED_EMAILS (쉼표 구분)로 덮어쓸 수 있다.
# env 가 비어 있으면 아래 기본 목록을 그대로 사용 — 기존 운영 호환.
DEFAULT_ALLOWED_EMAILS = (
"king@dbxcorp.co.kr",
"julie@dbxcorp.co.kr",
"ellen@dbxcorp.co.kr",
"bj@dbxcorp.co.kr",
)
def _data_dir() -> Path:
"""사용자/권한 JSON 저장소 위치. 컨테이너 재배포에도 살아남도록
DATA_DIR 환경변수로 마운트된 볼륨을 가리킬 수 있다."""
override = os.getenv("DATA_DIR", "").strip()
if override:
return Path(override)
return BASE_DIR / "data"
DATA_DIR = _data_dir()
def env(name: str, default: str = "") -> str:
@@ -35,14 +48,6 @@ def env(name: str, default: str = "") -> str:
return value if value else default
def _parse_email_list(raw: str) -> set[str]:
return {e.strip().lower() for e in raw.split(",") if e.strip()}
_env_emails = _parse_email_list(env("ALLOWED_EMAILS", ""))
ALLOWED_EMAILS: set[str] = _env_emails or _parse_email_list(",".join(DEFAULT_ALLOWED_EMAILS))
def _require_session_secret() -> str:
secret = env("SESSION_SECRET_KEY", "")
if not secret:
@@ -80,6 +85,7 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
oauth = build_google_oauth()
user_store = UserStore(DATA_DIR / "users.json")
def public_url_for(request: Request, route_name: str) -> str:
@@ -89,11 +95,11 @@ def public_url_for(request: Request, route_name: str) -> str:
return str(request.url_for(route_name))
def get_user(request: Request) -> dict[str, Any] | None:
def get_session_user(request: Request) -> dict[str, Any] | None:
"""세션에서 로그인 사용자(이메일/이름/사진) 추출. OMS SSO 호환."""
user = request.session.get("user")
if isinstance(user, dict):
return user
# OMS 가 top-level user_email 만 채워놓은 SSO 세션도 인정
email = request.session.get("user_email")
if email:
return {
@@ -104,6 +110,27 @@ def get_user(request: Request) -> dict[str, Any] | None:
return None
def get_current_user_record(request: Request) -> dict[str, Any] | None:
"""세션 + 저장소를 합쳐 권한이 포함된 사용자 레코드 반환."""
sess = get_session_user(request)
if not sess:
return None
rec = user_store.get(sess["email"])
if rec is None:
# 세션은 살아있지만 저장소에 없음 — 세션 무효화
return None
return rec
def require_admin(request: Request) -> dict[str, Any]:
rec = get_current_user_record(request)
if rec is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if not is_admin(rec):
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
return rec
def safe_next(raw: str | None) -> str:
"""Open redirect 방지: 같은 호스트의 절대 경로만 허용."""
if not raw:
@@ -144,35 +171,79 @@ def is_allowed_google_user(userinfo: dict[str, Any]) -> tuple[bool, str]:
return False, "Google 계정 이메일 인증이 확인되지 않았습니다."
if domain != ALLOWED_DOMAIN:
return False, "회사 Google Workspace 계정만 접속할 수 있습니다."
if email not in ALLOWED_EMAILS:
return False, "접속 허용 목록에 없는 계정입니다."
return True, ""
# ── ERP 메뉴 정의 ──────────────────────────────────────────────
# 각 항목: key(권한키), title, description, url(env override), status(ready|preparing), category
def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
items = [
{
"key": "corm",
"title": "CORM",
"subtitle": "CS · 발주 · 반품 · 코드관리",
"description": "고객 응대와 발주/반품, 코드 관리 업무를 한 곳에서 처리합니다.",
"url": env("CS_ORDER_URL", "/corm/"),
"health_url": "/corm/health/db",
"status": "ready",
"category": "운영",
},
{
"key": "order",
"title": "Order",
"subtitle": "고객 주문 데이터베이스",
"description": "고객 주문 내역을 조회·검색하고 관련 데이터를 관리합니다.",
"url": env("CUSTOMER_ORDER_LIST_URL", "/orderlist/"),
"health_url": "/orderlist/health/db",
"status": "ready",
"category": "운영",
},
{
"key": "expense",
"title": "개인경비",
"subtitle": "Personal Expense",
"description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.",
"url": "#",
"health_url": None,
"status": "preparing",
"category": "관리",
},
{
"key": "vacation",
"title": "휴가",
"subtitle": "Vacation",
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 확인합니다.",
"url": "#",
"health_url": None,
"status": "preparing",
"category": "관리",
},
]
allowed = allowed_modules(user_rec)
for item in items:
item["allowed"] = item["key"] in allowed
return items
@app.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
user = get_user(request)
if not user:
sess = get_session_user(request)
if not sess:
return render_template(request, "login.html")
menu_items = [
{
"title": "CS, 발주, 반품, 코드관리",
"description": "CS, 발주, 반품, 코드관리 페이지로 이동",
"url": env("CS_ORDER_URL", "/corm/"),
"health_url": "/corm/health/db",
},
{
"title": "고객 주문 데이터베이스",
"description": "고객 주문 데이터베이스 페이지로 이동",
"url": env("CUSTOMER_ORDER_LIST_URL", "/orderlist/"),
"health_url": "/orderlist/health/db",
},
]
user_rec = user_store.get(sess["email"])
if user_rec is None:
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
request.session.clear()
return render_template(request, "login.html")
menu_items = _menu_items_for(user_rec)
return render_template(
request,
"main.html",
{"user": user, "menu_items": menu_items},
{
"user": user_rec,
"menu_items": menu_items,
"is_admin": is_admin(user_rec),
},
)
@@ -186,7 +257,6 @@ async def login(request: Request):
status_code=500,
)
# SSO: ?next= 로 들어온 목적지를 OAuth 콜백 뒤에 사용하기 위해 세션에 임시 보관
request.session["_post_login_next"] = safe_next(request.query_params.get("next"))
redirect_uri = public_url_for(request, "auth_google")
@@ -226,11 +296,14 @@ async def auth_google(request: Request):
email = str(userinfo.get("email", "")).lower().strip()
name = userinfo.get("name") or email
picture = userinfo.get("picture", "") or ""
# 저장소에 사용자 등록/갱신 (신규는 권한 0 — 관리자가 부여)
user_store.upsert_login(email=email, name=name, picture=picture)
# OMS 와 공유하는 top-level 키 (SSO 계약)
request.session["user_email"] = email
request.session["user_name"] = name
request.session["user_picture"] = picture
# 기존 코드/템플릿 호환용 dict
request.session["user"] = {
"email": email,
"name": name,
@@ -249,3 +322,72 @@ async def logout(request: Request) -> RedirectResponse:
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
# ── 관리자 페이지 ──────────────────────────────────────────────
@app.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request) -> HTMLResponse:
rec = get_current_user_record(request)
if rec is None:
return RedirectResponse(url="/login", status_code=303)
if not is_admin(rec):
return render_template(
request,
"denied.html",
{"reason": "관리자만 접근할 수 있는 페이지입니다."},
status_code=403,
)
users = sorted(user_store.list_all(), key=lambda u: (u["email"] != SUPER_ADMIN_EMAIL, u["email"]))
return render_template(
request,
"admin.html",
{
"user": rec,
"users": users,
"module_keys": list(MODULE_KEYS),
"super_admin_email": SUPER_ADMIN_EMAIL,
"is_admin": True,
},
)
# ── 관리자 API ──────────────────────────────────────────────
class UpdatePermissionsBody(BaseModel):
role: str | None = None
modules: dict[str, bool] | None = None
@app.get("/api/users")
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
return JSONResponse({"users": user_store.list_all()})
@app.put("/api/users/{email}")
async def api_update_user(
email: str,
body: UpdatePermissionsBody,
_: dict[str, Any] = Depends(require_admin),
) -> JSONResponse:
try:
rec = user_store.update_permissions(
email=email, role=body.role, modules=body.modules
)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"user": rec})
@app.delete("/api/users/{email}")
async def api_delete_user(
email: str,
_: dict[str, Any] = Depends(require_admin),
) -> JSONResponse:
try:
user_store.delete(email)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
return JSONResponse({"ok": True})
+595
View File
@@ -0,0 +1,595 @@
/* ════════════════════════════════════════════════════
ERP 대시보드 — DESIGN.md 기반 (Monochromatic / shadcn)
════════════════════════════════════════════════════ */
/* Geist 폰트 — CDN. 실패시 Inter / 시스템 폰트 fallback */
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono&family=Inter:wght@400;500;600&display=swap");
:root {
/* Colors */
--color-canvas-white: #ffffff;
--color-ghost-gray: #f2f2f2;
--color-subtle-ash: #e5e5e5;
--color-midtone-gray: #737373;
--color-rich-black: #0a0a0a;
--color-deep-black: #000000;
--color-callout-red: #c22b10;
--color-success-green:#10c22b;
/* Typography */
--font-geist: 'Geist', 'Inter', ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo,
Monaco, Consolas, monospace;
--text-caption: 12px;
--text-body: 14px;
--text-heading: 18px;
--text-display: 48px;
--leading-body: 1.43;
--tracking-heading: -0.45px;
--tracking-display: -2.4px;
/* Spacing */
--sp-4: 4px; --sp-6: 6px; --sp-8: 8px; --sp-10: 10px;
--sp-12: 12px; --sp-16: 16px; --sp-20: 20px; --sp-24: 24px;
--sp-32: 32px; --sp-40: 40px; --sp-80: 80px;
/* Radii */
--r-pill: 9999px;
--r-badge: 26px;
--r-card: 14px;
--r-input: 10px;
--r-btn: 10px;
/* Shadows */
--shadow-card: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
--shadow-focus: lab(100 0 0) 0px 0px 0px 2px;
}
/* ── 리셋 ── (auth pages 와 충돌 방지 위해 erp-shell 안에서만 적용) */
.erp-shell, .erp-shell * { box-sizing: border-box; }
.erp-shell { margin: 0; padding: 0; }
body.erp-body {
margin: 0;
font-family: var(--font-geist);
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-rich-black);
background: var(--color-canvas-white);
-webkit-font-smoothing: antialiased;
letter-spacing: 0;
}
.erp-body a { color: inherit; text-decoration: none; }
/* ── 상단 네비게이션 ── */
.erp-nav {
position: sticky;
top: 0;
z-index: 50;
background: var(--color-canvas-white);
border-bottom: 1px solid var(--color-subtle-ash);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--sp-24);
height: 56px;
}
.erp-nav-left {
display: flex;
align-items: center;
gap: var(--sp-16);
}
.erp-brand {
display: flex;
align-items: center;
gap: var(--sp-8);
font-weight: 600;
color: var(--color-deep-black);
font-size: var(--text-body);
letter-spacing: -0.2px;
}
.erp-brand-logo {
height: 36px;
width: auto;
object-fit: contain;
}
.erp-brand-divider {
width: 1px;
height: 20px;
background: var(--color-subtle-ash);
}
.erp-brand-system {
font-size: var(--text-body);
font-weight: 500;
color: var(--color-rich-black);
}
.erp-nav-right {
display: flex;
align-items: center;
gap: var(--sp-8);
}
.erp-user-chip {
display: inline-flex;
align-items: center;
gap: var(--sp-8);
padding: 4px var(--sp-12) 4px 4px;
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-pill);
background: var(--color-canvas-white);
font-size: var(--text-caption);
color: var(--color-rich-black);
line-height: 1;
}
.erp-user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.erp-user-avatar-fallback {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-deep-black);
color: var(--color-canvas-white);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--text-caption);
font-weight: 600;
}
.erp-user-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.erp-user-name {
font-weight: 500;
color: var(--color-rich-black);
font-size: var(--text-caption);
}
.erp-user-email {
color: var(--color-midtone-gray);
font-size: 11px;
}
/* ── 버튼 ── */
.erp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-6);
font-family: inherit;
font-size: var(--text-caption);
font-weight: 500;
line-height: 1;
border: 1px solid transparent;
border-radius: var(--r-btn);
cursor: pointer;
white-space: nowrap;
transition: background 120ms, border-color 120ms, color 120ms;
}
.erp-btn-primary {
background: var(--color-deep-black);
color: var(--color-canvas-white);
padding: 8px 16px;
}
.erp-btn-primary:hover {
background: var(--color-rich-black);
}
.erp-btn-ghost {
background: transparent;
color: var(--color-rich-black);
padding: 6px 12px;
border-radius: var(--r-pill);
}
.erp-btn-ghost:hover {
background: var(--color-ghost-gray);
}
.erp-btn-outline {
background: var(--color-canvas-white);
color: var(--color-rich-black);
border-color: var(--color-subtle-ash);
padding: 6px 12px;
}
.erp-btn-outline:hover {
background: var(--color-ghost-gray);
}
.erp-btn-danger {
background: transparent;
color: var(--color-callout-red);
border-color: var(--color-subtle-ash);
padding: 6px 12px;
}
.erp-btn-danger:hover {
border-color: var(--color-callout-red);
background: rgba(194, 43, 16, 0.04);
}
/* ── 본문 컨테이너 ── */
.erp-main {
max-width: 1200px;
margin: 0 auto;
padding: var(--sp-40) var(--sp-24);
}
.erp-greeting {
margin: 0 0 var(--sp-8);
font-size: 28px;
font-weight: 600;
color: var(--color-deep-black);
letter-spacing: -0.6px;
line-height: 1.2;
}
.erp-greeting-sub {
margin: 0 0 var(--sp-32);
color: var(--color-midtone-gray);
font-size: var(--text-body);
}
.erp-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: var(--sp-16);
}
.erp-section-title {
font-size: var(--text-heading);
font-weight: 500;
color: var(--color-deep-black);
letter-spacing: var(--tracking-heading);
line-height: 1.33;
}
.erp-section-meta {
font-size: var(--text-caption);
color: var(--color-midtone-gray);
}
/* ── 카드 그리드 ── */
.erp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--sp-16);
}
.erp-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--sp-12);
background: var(--color-canvas-white);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
padding: var(--sp-20);
min-height: 180px;
transition: box-shadow 120ms, transform 120ms;
}
.erp-card[data-clickable="true"]:hover {
box-shadow: oklab(0.145 -0.00000143796 0.00000340492 / 0.18) 0 0 0 1px,
oklab(0.145 -0.00000143796 0.00000340492 / 0.06) 0 8px 24px;
}
.erp-card[data-clickable="false"] {
background: var(--color-ghost-gray);
}
.erp-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-8);
}
.erp-card-title {
font-size: var(--text-heading);
font-weight: 600;
color: var(--color-deep-black);
letter-spacing: var(--tracking-heading);
line-height: 1.2;
}
.erp-card-subtitle {
font-family: var(--font-geist-mono);
font-size: var(--text-caption);
color: var(--color-midtone-gray);
letter-spacing: 0;
}
.erp-card-desc {
font-size: var(--text-body);
color: var(--color-midtone-gray);
flex: 1;
}
.erp-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-8);
margin-top: auto;
padding-top: var(--sp-12);
border-top: 1px solid var(--color-subtle-ash);
}
.erp-card-link {
font-size: var(--text-caption);
font-weight: 500;
color: var(--color-deep-black);
display: inline-flex;
align-items: center;
gap: var(--sp-4);
}
.erp-card-link svg {
transition: transform 120ms;
}
.erp-card[data-clickable="true"]:hover .erp-card-link svg {
transform: translateX(2px);
}
/* ── 배지 ── */
.erp-badge {
display: inline-flex;
align-items: center;
gap: var(--sp-6);
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
border-radius: var(--r-badge);
line-height: 1.4;
white-space: nowrap;
}
.erp-badge-inverse {
background: var(--color-deep-black);
color: var(--color-canvas-white);
}
.erp-badge-neutral {
background: var(--color-ghost-gray);
color: var(--color-rich-black);
}
.erp-badge-outline {
background: transparent;
color: var(--color-rich-black);
border: 1px solid #a1a1a1;
}
.erp-badge-success { background: #e6f8e9; color: var(--color-success-green); border: 1px solid #c2efc8; }
.erp-badge-danger { background: #fbe9e6; color: var(--color-callout-red); border: 1px solid #efc6bf; }
.erp-badge-dot::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* ── 잠긴 카드 표시 ── */
.erp-locked-overlay {
position: absolute;
top: var(--sp-12);
right: var(--sp-12);
font-size: 11px;
color: var(--color-midtone-gray);
display: inline-flex;
align-items: center;
gap: 4px;
}
/* ── 관리자 페이지 ── */
.erp-admin-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-12);
margin-bottom: var(--sp-16);
flex-wrap: wrap;
}
.erp-input {
font-family: inherit;
font-size: var(--text-body);
color: var(--color-rich-black);
background: var(--color-canvas-white);
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-input);
padding: 6px var(--sp-10);
min-width: 240px;
}
.erp-input:focus {
outline: none;
box-shadow: var(--shadow-focus);
border-color: var(--color-deep-black);
}
.erp-table-wrap {
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
background: var(--color-canvas-white);
overflow: hidden;
}
.erp-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body);
}
.erp-table thead th {
text-align: left;
font-size: var(--text-caption);
font-weight: 500;
color: var(--color-midtone-gray);
background: var(--color-ghost-gray);
padding: var(--sp-10) var(--sp-12);
border-bottom: 1px solid var(--color-subtle-ash);
white-space: nowrap;
}
.erp-table tbody td {
padding: var(--sp-12);
border-bottom: 1px solid var(--color-subtle-ash);
vertical-align: middle;
}
.erp-table tbody tr:last-child td {
border-bottom: none;
}
.erp-table tbody tr[data-dirty="true"] {
background: #fffbeb;
}
.erp-user-cell {
display: flex;
align-items: center;
gap: var(--sp-10);
}
.erp-user-cell .erp-user-avatar,
.erp-user-cell .erp-user-avatar-fallback {
width: 32px;
height: 32px;
font-size: 13px;
}
.erp-user-cell-name {
font-weight: 500;
color: var(--color-rich-black);
line-height: 1.2;
}
.erp-user-cell-email {
font-size: 12px;
color: var(--color-midtone-gray);
line-height: 1.2;
margin-top: 2px;
}
/* shadcn-style 토글 (checkbox 기반) */
.erp-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.erp-switch input { opacity: 0; width: 0; height: 0; }
.erp-switch-slider {
position: absolute;
inset: 0;
background: var(--color-subtle-ash);
border-radius: var(--r-pill);
cursor: pointer;
transition: background 120ms;
}
.erp-switch-slider::before {
content: "";
position: absolute;
width: 16px; height: 16px;
left: 2px; top: 2px;
background: var(--color-canvas-white);
border-radius: 50%;
transition: transform 120ms;
box-shadow: 0 1px 2px rgba(0,0,0,.2);
}
.erp-switch input:checked + .erp-switch-slider { background: var(--color-deep-black); }
.erp-switch input:checked + .erp-switch-slider::before { transform: translateX(16px); }
.erp-switch input:disabled + .erp-switch-slider {
opacity: 0.5;
cursor: not-allowed;
}
/* shadcn-style select */
.erp-select {
font-family: inherit;
font-size: var(--text-body);
color: var(--color-rich-black);
background: var(--color-canvas-white);
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-input);
padding: 4px var(--sp-10);
cursor: pointer;
}
.erp-select:focus {
outline: none;
box-shadow: var(--shadow-focus);
border-color: var(--color-deep-black);
}
.erp-select:disabled {
background: var(--color-ghost-gray);
cursor: not-allowed;
color: var(--color-midtone-gray);
}
/* 토스트 */
.erp-toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--color-deep-black);
color: var(--color-canvas-white);
padding: var(--sp-12) var(--sp-16);
border-radius: var(--r-input);
font-size: var(--text-body);
z-index: 1000;
opacity: 0;
transform: translateY(8px);
transition: opacity 200ms, transform 200ms;
pointer-events: none;
max-width: 360px;
}
.erp-toast[data-visible="true"] {
opacity: 1;
transform: translateY(0);
}
.erp-toast[data-type="error"] {
background: var(--color-callout-red);
}
/* 반응형 */
@media (max-width: 640px) {
.erp-nav { padding: 0 var(--sp-16); height: 52px; }
.erp-brand-system { display: none; }
.erp-user-info { display: none; }
.erp-main { padding: var(--sp-24) var(--sp-16); }
.erp-greeting { font-size: 22px; }
.erp-table thead { display: none; }
.erp-table tbody td { display: block; border: none; padding: 4px var(--sp-12); }
.erp-table tbody tr { display: block; padding: var(--sp-12) 0; border-bottom: 1px solid var(--color-subtle-ash); }
}
+186
View File
@@ -0,0 +1,186 @@
"""JSON 기반 사용자/권한 저장소.
- app/data/users.json 에 사용자 레코드 저장
- 동시성: 파일락 대신 process-내 threading.Lock + 원자적 쓰기(temp → rename)
- 운영시 컨테이너 한 대 가정. 다중 인스턴스 필요해지면 DB 로 교체.
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
# 슈퍼 관리자 — 강등/삭제 불가
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
# 시스템에서 지원하는 모듈 키 — 신규 추가시 여기 + 메뉴/템플릿 동시 갱신
MODULE_KEYS: tuple[str, ...] = ("corm", "order", "expense", "vacation")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class UserStore:
def __init__(self, path: Path):
self._path = path
self._lock = threading.Lock()
self._path.parent.mkdir(parents=True, exist_ok=True)
if not self._path.exists():
self._write_atomic({"users": {}})
def _read(self) -> dict[str, Any]:
try:
with self._path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
data = {"users": {}}
if "users" not in data or not isinstance(data["users"], dict):
data["users"] = {}
return data
def _write_atomic(self, data: dict[str, Any]) -> None:
fd, tmp = tempfile.mkstemp(
prefix=".users.", suffix=".json.tmp", dir=str(self._path.parent)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, self._path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
@staticmethod
def _default_modules(is_admin: bool) -> dict[str, bool]:
return {key: is_admin for key in MODULE_KEYS}
@staticmethod
def _normalize_modules(modules: dict[str, Any] | None, *, is_admin: bool) -> dict[str, bool]:
base = UserStore._default_modules(is_admin)
if isinstance(modules, dict):
for key in MODULE_KEYS:
if key in modules:
base[key] = bool(modules[key])
if is_admin:
for key in MODULE_KEYS:
base[key] = True
return base
def get(self, email: str) -> dict[str, Any] | None:
email = email.lower().strip()
with self._lock:
data = self._read()
user = data["users"].get(email)
if user is None:
return None
return self._enrich(email, user)
def list_all(self) -> list[dict[str, Any]]:
with self._lock:
data = self._read()
return [self._enrich(email, rec) for email, rec in data["users"].items()]
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
email = email.lower().strip()
is_super = email == SUPER_ADMIN_EMAIL
with self._lock:
data = self._read()
now = _now_iso()
existing = data["users"].get(email)
if existing is None:
rec = {
"email": email,
"name": name,
"picture": picture,
"role": "admin" if is_super else "user",
"modules": self._default_modules(is_admin=is_super),
"created_at": now,
"last_login": now,
}
else:
rec = dict(existing)
rec["name"] = name or rec.get("name", email)
rec["picture"] = picture or rec.get("picture", "")
rec["last_login"] = now
# 슈퍼 관리자는 항상 admin + 모든 모듈
if is_super:
rec["role"] = "admin"
rec["modules"] = self._default_modules(is_admin=True)
else:
rec["modules"] = self._normalize_modules(
rec.get("modules"), is_admin=(rec.get("role") == "admin")
)
data["users"][email] = rec
self._write_atomic(data)
return self._enrich(email, rec)
def update_permissions(
self, *, email: str, role: str | None, modules: dict[str, Any] | None
) -> dict[str, Any]:
"""관리자가 다른 사용자의 권한을 수정."""
email = email.lower().strip()
if email == SUPER_ADMIN_EMAIL:
raise PermissionError("슈퍼 관리자 계정은 수정할 수 없습니다.")
if role is not None and role not in ("admin", "user"):
raise ValueError(f"role 값이 잘못되었습니다: {role}")
with self._lock:
data = self._read()
rec = data["users"].get(email)
if rec is None:
raise KeyError(f"사용자를 찾을 수 없습니다: {email}")
if role is not None:
rec["role"] = role
new_is_admin = rec.get("role") == "admin"
rec["modules"] = self._normalize_modules(
modules if modules is not None else rec.get("modules"),
is_admin=new_is_admin,
)
data["users"][email] = rec
self._write_atomic(data)
return self._enrich(email, rec)
def delete(self, email: str) -> None:
email = email.lower().strip()
if email == SUPER_ADMIN_EMAIL:
raise PermissionError("슈퍼 관리자 계정은 삭제할 수 없습니다.")
with self._lock:
data = self._read()
if email in data["users"]:
del data["users"][email]
self._write_atomic(data)
@staticmethod
def _enrich(email: str, rec: dict[str, Any]) -> dict[str, Any]:
out = dict(rec)
out["email"] = email
out["is_super_admin"] = email == SUPER_ADMIN_EMAIL
out["modules"] = UserStore._normalize_modules(
out.get("modules"), is_admin=(out.get("role") == "admin")
)
return out
def is_admin(user_rec: dict[str, Any] | None) -> bool:
return bool(user_rec and user_rec.get("role") == "admin")
def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
if not user_rec:
return False
if is_admin(user_rec):
return True
mods = user_rec.get("modules") or {}
return bool(mods.get(module))
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
return {m for m in MODULE_KEYS if has_module(user_rec, m)}
+232
View File
@@ -0,0 +1,232 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>사용자/권한 관리 — DBX ERP</title>
<link rel="stylesheet" href="/static/erp.css" />
</head>
<body class="erp-body">
<div class="erp-shell">
<!-- ── 상단 네비게이션 ── -->
<nav class="erp-nav">
<div class="erp-nav-left">
<a href="/" class="erp-brand">
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
<span class="erp-brand-divider"></span>
<span class="erp-brand-system">ERP 시스템</span>
</a>
</div>
<div class="erp-nav-right">
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
<div class="erp-user-chip">
{% if user.picture %}
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
{% else %}
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
{% endif %}
<div class="erp-user-info">
<span class="erp-user-name">{{ user.name }}</span>
<span class="erp-user-email">{{ user.email }}</span>
</div>
</div>
<a class="erp-btn erp-btn-outline" href="/logout">로그아웃</a>
</div>
</nav>
<main class="erp-main" style="max-width: 1100px;">
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
<p class="erp-greeting-sub">
회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다.
토글을 변경한 뒤 <strong>저장</strong> 버튼을 누르세요.
</p>
<div class="erp-admin-toolbar">
<input type="search"
id="user-search"
class="erp-input"
placeholder="이메일 또는 이름으로 검색…" />
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table" id="users-table">
<thead>
<tr>
<th style="width: 28%;">사용자</th>
<th style="width: 11%;">역할</th>
{% for key in module_keys %}
<th style="text-align:center; width: 9%;">
{% if key == 'corm' %}CORM
{% elif key == 'order' %}Order
{% elif key == 'expense' %}개인경비
{% elif key == 'vacation' %}휴가
{% else %}{{ key }}{% endif %}
</th>
{% endfor %}
<th style="width: 13%;">최근 로그인</th>
<th style="width: 12%; text-align: right;">동작</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr data-email="{{ u.email }}" data-dirty="false"
data-super="{{ 'true' if u.is_super_admin else 'false' }}">
<td>
<div class="erp-user-cell">
{% if u.picture %}
<img class="erp-user-avatar" src="{{ u.picture }}" alt="{{ u.name }}" />
{% else %}
<span class="erp-user-avatar-fallback">{{ (u.name or u.email)[0] | upper }}</span>
{% endif %}
<div>
<div class="erp-user-cell-name">
{{ u.name or u.email }}
{% if u.is_super_admin %}
<span class="erp-badge erp-badge-inverse" style="margin-left:6px;">슈퍼관리자</span>
{% endif %}
</div>
<div class="erp-user-cell-email">{{ u.email }}</div>
</div>
</div>
</td>
<td>
<select class="erp-select" data-field="role"
{% if u.is_super_admin %}disabled{% endif %}>
<option value="user" {% if u.role == 'user' %}selected{% endif %}>user</option>
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
</select>
</td>
{% for key in module_keys %}
<td style="text-align:center;">
<label class="erp-switch">
<input type="checkbox"
data-field="module"
data-module="{{ key }}"
{% if u.modules[key] %}checked{% endif %}
{% if u.is_super_admin or u.role == 'admin' %}disabled{% endif %} />
<span class="erp-switch-slider"></span>
</label>
</td>
{% endfor %}
<td style="font-size:12px; color: var(--color-midtone-gray);">
{{ (u.last_login or '')[:16].replace('T', ' ') }}
</td>
<td style="text-align: right;">
<button class="erp-btn erp-btn-primary" data-action="save"
{% if u.is_super_admin %}disabled{% endif %}>저장</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
<strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자이므로 권한을 변경할 수 없습니다.
관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한이 자동 부여됩니다.
</p>
</main>
</div>
<div id="toast" class="erp-toast" role="status" aria-live="polite"></div>
<script>
const tbody = document.querySelector("#users-table tbody");
const searchInput = document.getElementById("user-search");
const userCountEl = document.getElementById("user-count");
const toastEl = document.getElementById("toast");
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
function showToast(msg, type) {
toastEl.textContent = msg;
toastEl.dataset.type = type || "info";
toastEl.dataset.visible = "true";
clearTimeout(showToast._t);
showToast._t = setTimeout(() => {
toastEl.dataset.visible = "false";
}, 2400);
}
// 역할 변경시 모듈 토글 활성/비활성 갱신
function syncModuleSwitches(tr) {
const isSuper = tr.dataset.super === "true";
const role = tr.querySelector("select[data-field='role']").value;
const adminRole = role === "admin";
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
if (isSuper || adminRole) {
cb.checked = true;
cb.disabled = true;
} else {
cb.disabled = false;
}
});
}
tbody.addEventListener("change", (e) => {
const tr = e.target.closest("tr");
if (!tr) return;
if (e.target.dataset.field === "role") {
syncModuleSwitches(tr);
}
markDirty(tr);
});
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-action='save']");
if (!btn) return;
const tr = btn.closest("tr");
const email = tr.dataset.email;
const role = tr.querySelector("select[data-field='role']").value;
const modules = {};
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
modules[cb.dataset.module] = cb.checked;
});
btn.disabled = true;
btn.textContent = "저장 중…";
try {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ role, modules }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
clearDirty(tr);
showToast(`${email} 저장 완료`, "info");
} catch (err) {
showToast(`저장 실패: ${err.message}`, "error");
} finally {
btn.disabled = false;
btn.textContent = "저장";
}
});
// 검색
searchInput.addEventListener("input", () => {
const q = searchInput.value.toLowerCase().trim();
let visible = 0;
tbody.querySelectorAll("tr").forEach((tr) => {
const text = tr.textContent.toLowerCase();
const match = !q || text.includes(q);
tr.style.display = match ? "" : "none";
if (match) visible++;
});
userCountEl.textContent = q
? `검색 결과 ${visible}`
: `${tbody.querySelectorAll("tr").length}`;
});
</script>
</body>
</html>
+139 -123
View File
@@ -3,153 +3,169 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>업무 포털 — DBX Corporation</title>
<link rel="stylesheet" href="/static/styles.css" />
<title>ERP 시스템 — DBX Corporation</title>
<link rel="stylesheet" href="/static/erp.css" />
</head>
<body>
<body class="erp-body">
<!-- ── 헤더 ── -->
<header class="header">
<div class="header-brand">
<img src="/static/dbx-logo.png" alt="DBX Corporation" class="header-logo-img" />
<span class="header-portal">업무 포털</span>
</div>
<div class="erp-shell">
<div class="header-user">
<div class="header-user-info">
<span class="header-user-name">{{ user.name }}</span>
<span class="header-user-email">{{ user.email }}</span>
<!-- ── 상단 네비게이션 ── -->
<nav class="erp-nav">
<div class="erp-nav-left">
<a href="/" class="erp-brand">
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
<span class="erp-brand-divider"></span>
<span class="erp-brand-system">ERP 시스템</span>
</a>
</div>
{% if user.picture %}
<img class="header-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
{% else %}
<div class="header-avatar-placeholder">
{{ user.name[0] | upper }}
</div>
{% endif %}
<a class="btn-logout" href="/logout">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>로그아웃</span>
</a>
</div>
</header>
<!-- ── 히어로 배너 ── -->
<section class="hero">
<p class="hero-greeting">안녕하세요, {{ user.name }}님 👋</p>
<p class="hero-sub">오늘도 좋은 하루 되세요.</p>
<p class="hero-date" id="today-date"></p>
</section>
<div class="erp-nav-right">
{% if is_admin %}
<a class="erp-btn erp-btn-ghost" href="/admin">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2 4 6v6c0 5 3.4 9.4 8 10 4.6-.6 8-5 8-10V6l-8-4Z"/>
</svg>
관리자
</a>
{% endif %}
<!-- ── 메뉴 콘텐츠 ── -->
<main class="content">
<div class="section-header">
<h2 class="section-title">업무 바로가기</h2>
<span class="section-count">{{ menu_items | length }}</span>
</div>
<div class="card-grid">
{% for item in menu_items %}
<a class="menu-card" href="{{ item.url }}" target="_blank" rel="noopener noreferrer"
{% if item.health_url %}data-health-url="{{ item.health_url }}"{% endif %}>
<div class="card-icon">
{% if loop.index == 1 %}
<!-- 클립보드 / 발주 아이콘 -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<line x1="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="14" y2="17"/>
</svg>
{% elif loop.index == 2 %}
<!-- 리스트 / 주문 아이콘 -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
{% elif loop.index == 3 %}
<!-- 차트 아이콘 -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
<line x1="2" y1="20" x2="22" y2="20"/>
</svg>
<div class="erp-user-chip">
{% if user.picture %}
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
{% else %}
<!-- 기본 링크 아이콘 -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
{% endif %}
<div class="erp-user-info">
<span class="erp-user-name">{{ user.name }}</span>
<span class="erp-user-email">{{ user.email }}</span>
</div>
</div>
<div class="card-body">
<p class="card-title">
{{ item.title }}
{% if item.health_url %}
<span class="status-badge" data-status="checking" aria-live="polite">확인 중</span>
<a class="erp-btn erp-btn-outline" href="/logout">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
로그아웃
</a>
</div>
</nav>
<!-- ── 본문 ── -->
<main class="erp-main">
<h1 class="erp-greeting">안녕하세요, {{ user.name }}님</h1>
<p class="erp-greeting-sub" id="erp-greeting-sub"></p>
<div class="erp-section-head">
<h2 class="erp-section-title">업무 모듈</h2>
<span class="erp-section-meta">{{ menu_items | length }}개 모듈</span>
</div>
<div class="erp-grid">
{% for item in menu_items %}
{% set clickable = item.allowed and item.status == 'ready' %}
{% if clickable %}
<a class="erp-card" href="{{ item.url }}"
target="_blank" rel="noopener noreferrer"
data-clickable="true"
{% if item.health_url %}data-health-url="{{ item.health_url }}"{% endif %}>
{% else %}
<div class="erp-card" data-clickable="false">
{% endif %}
<div class="erp-card-head">
<div>
<div class="erp-card-title">{{ item.title }}</div>
<div class="erp-card-subtitle">{{ item.subtitle }}</div>
</div>
{% if item.status == 'preparing' %}
<span class="erp-badge erp-badge-neutral">준비중</span>
{% elif not item.allowed %}
<span class="erp-badge erp-badge-outline">권한 없음</span>
{% elif item.health_url %}
<span class="erp-badge erp-badge-neutral erp-badge-dot"
data-health-url="{{ item.health_url }}" data-status="checking">확인 중</span>
{% else %}
<span class="erp-badge erp-badge-inverse">활성</span>
{% endif %}
</p>
<p class="card-desc">{{ item.description }}</p>
</div>
</div>
<div class="card-footer">
<span class="card-link">
바로가기
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</span>
<p class="erp-card-desc">{{ item.description }}</p>
<div class="erp-card-foot">
<span class="erp-badge erp-badge-outline">{{ item.category }}</span>
{% if clickable %}
<span class="erp-card-link">
바로가기
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</span>
{% elif item.status == 'preparing' %}
<span class="erp-card-link" style="color: var(--color-midtone-gray);">출시 예정</span>
{% else %}
<span class="erp-card-link" style="color: var(--color-midtone-gray);">접근 불가</span>
{% endif %}
</div>
{% if clickable %}
</a>
{% else %}
</div>
</a>
{% endfor %}
</div>
</main>
{% endif %}
{% endfor %}
</div>
</main>
</div>
<script>
// 오늘 날짜 표시
const d = new Date();
const days = ["일요일","월요일","화요일","수요일","목요일","금요일","토요일"];
document.getElementById("today-date").textContent =
d.getFullYear() + "년 " + (d.getMonth()+1) + "월 " + d.getDate() + "일 " + days[d.getDay()];
// 인사말 — 시간대별
(function() {
const d = new Date();
const h = d.getHours();
let greet = "오늘도 좋은 하루 되세요.";
if (h < 5) greet = "늦은 시간까지 수고 많으세요.";
else if (h < 12) greet = "활기찬 아침입니다.";
else if (h < 18) greet = "오후 업무 화이팅입니다.";
else greet = "오늘도 고생 많으셨습니다.";
const days = ["일","월","화","수","목","금","토"];
const dateStr = `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,"0")}.${String(d.getDate()).padStart(2,"0")} (${days[d.getDay()]})`;
document.getElementById("erp-greeting-sub").textContent = `${dateStr} · ${greet}`;
})();
// 헬스 배지 — data-health-url 이 있는 카드만 갱신
async function refreshHealthBadges() {
const cards = document.querySelectorAll(".menu-card[data-health-url]");
await Promise.all(Array.from(cards).map(async (card) => {
const url = card.getAttribute("data-health-url");
const badge = card.querySelector(".status-badge");
if (!badge) return;
// 헬스 배지 — data-health-url 이 있는 배지만 갱신
async function refreshHealth() {
const badges = document.querySelectorAll(".erp-badge[data-health-url]");
await Promise.all(Array.from(badges).map(async (badge) => {
const url = badge.getAttribute("data-health-url");
try {
const res = await fetch(url, { cache: "no-store", credentials: "omit" });
const ok = res.ok;
badge.dataset.status = ok ? "online" : "offline";
badge.textContent = ok ? "온라인" : "오프라인";
if (res.ok) {
badge.dataset.status = "online";
badge.className = "erp-badge erp-badge-success erp-badge-dot";
badge.textContent = "온라인";
} else {
badge.dataset.status = "offline";
badge.className = "erp-badge erp-badge-danger erp-badge-dot";
badge.textContent = "오프라인";
}
} catch {
badge.dataset.status = "offline";
badge.className = "erp-badge erp-badge-danger erp-badge-dot";
badge.textContent = "오프라인";
}
}));
}
refreshHealthBadges();
setInterval(refreshHealthBadges, 30000);
refreshHealth();
setInterval(refreshHealth, 30000);
</script>
</body>
</html>
+7
View File
@@ -8,3 +8,10 @@ services:
- "8080:8000"
env_file:
- .env.local
environment:
DATA_DIR: /data
volumes:
- dbx-main-data:/data
volumes:
dbx-main-data:
+8
View File
@@ -9,3 +9,11 @@ services:
- "8080:8000"
env_file:
- .env
environment:
# 사용자/권한 JSON 저장소 — 볼륨에 영구 보관
DATA_DIR: /data
volumes:
- dbx-main-data:/data
volumes:
dbx-main-data: