feat(expense): 개인경비 모듈 + ERP 좌측메뉴 셸 + expense_db 연결
- ERP 셸: 좌측 사이드바 + 상단 헤더 + 콘텐츠 영역(erp_base.html)
- 분기: 슈퍼관리자 → 업무모듈 카드, 일반사용자 → ERP 메인(erp_home.html)
- 신규 라우트: /modules (슈퍼관리자 모듈 카드 재진입)
- 개인경비 모듈: app/modules/expense/ (라우터/저장소/템플릿)
- JSON 저장소(ExpenseStore) + PostgreSQL 저장소(ExpenseDBStore)
- 팩토리: EXPENSE_DB_URL 있으면 DB, 없으면 JSON 폴백
- DB: scripts/sql/expense_db_init.sql (멱등 DDL, postgres-db 컨테이너)
- expense_db, 역할 expense_app, 테이블 expense_items, 인덱스/트리거
- 마이그레이션: scripts/migrate_expense_json_to_db.py (멱등, --dry-run)
- 의존성: psycopg[binary,pool]>=3.2
- 환경변수: EXPENSE_DB_URL 추가
- 문서: CLAUDE.md 작업 기준 + docs/{PROJECT_OVERVIEW,SERVER_ARCHITECTURE,DATABASES,DEPLOYMENT}.md
- main-app 배포 경로 /opt/www/main 고정 명시
- 위험 명령(DROP/TRUNCATE/rm -rf/volume 삭제) 사용자 승인 정책
This commit is contained in:
+119
-5
@@ -9,9 +9,12 @@ 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 jinja2 import ChoiceLoader, FileSystemLoader
|
||||
from pydantic import BaseModel
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .modules.expense import build_expense_store
|
||||
from .modules.expense import router as expense_router
|
||||
from .store import (
|
||||
MODULE_KEYS,
|
||||
SUPER_ADMIN_EMAIL,
|
||||
@@ -83,10 +86,32 @@ app.add_middleware(
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
|
||||
# 모듈별 templates 디렉토리를 추가로 검색하도록 ChoiceLoader 설정.
|
||||
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
|
||||
_MODULE_TEMPLATE_DIRS = [
|
||||
BASE_DIR / "modules" / "expense" / "templates",
|
||||
]
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
templates.env.loader = ChoiceLoader(
|
||||
[
|
||||
FileSystemLoader(str(BASE_DIR / "templates")),
|
||||
*[FileSystemLoader(str(p)) for p in _MODULE_TEMPLATE_DIRS if p.exists()],
|
||||
]
|
||||
)
|
||||
|
||||
oauth = build_google_oauth()
|
||||
user_store = UserStore(DATA_DIR / "users.json")
|
||||
|
||||
# 모듈별 데이터 저장소.
|
||||
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
|
||||
app.state.expense_store = build_expense_store(
|
||||
dsn=env("EXPENSE_DB_URL") or None,
|
||||
json_path=DATA_DIR / "expense.json",
|
||||
)
|
||||
|
||||
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
|
||||
app.include_router(expense_router)
|
||||
|
||||
|
||||
def public_url_for(request: Request, route_name: str) -> str:
|
||||
public_base_url = env("PUBLIC_BASE_URL").rstrip("/")
|
||||
@@ -203,9 +228,9 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"title": "개인경비",
|
||||
"subtitle": "Personal Expense",
|
||||
"description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.",
|
||||
"url": "#",
|
||||
"health_url": None,
|
||||
"status": "preparing",
|
||||
"url": "/expense/",
|
||||
"health_url": "/expense/health",
|
||||
"status": "ready",
|
||||
"category": "관리",
|
||||
},
|
||||
{
|
||||
@@ -225,6 +250,62 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return items
|
||||
|
||||
|
||||
def _icon_svg(name: str) -> str:
|
||||
"""좌측 사이드바용 인라인 아이콘. 외부 의존 없는 작은 SVG."""
|
||||
paths = {
|
||||
"home": '<path d="M3 11 12 3l9 8"/><path d="M5 10v10h14V10"/>',
|
||||
"expense": '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M7 15h4"/>',
|
||||
"vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>',
|
||||
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
|
||||
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
|
||||
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
||||
}
|
||||
body = paths.get(name, paths["modules"])
|
||||
return (
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
||||
'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
|
||||
f"{body}</svg>"
|
||||
)
|
||||
|
||||
|
||||
def build_erp_nav(
|
||||
user_rec: dict[str, Any], active: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""좌측 사이드바 메뉴. 모듈 정의(_menu_items_for)와 동기화한다.
|
||||
|
||||
각 항목: key, label, group, url, target, icon, active, disabled, disabled_reason.
|
||||
"""
|
||||
items: list[dict[str, Any]] = [
|
||||
{
|
||||
"key": "home",
|
||||
"label": "홈",
|
||||
"group": "ERP",
|
||||
"url": "/",
|
||||
"target": "_self",
|
||||
"icon": _icon_svg("home"),
|
||||
},
|
||||
]
|
||||
for m in _menu_items_for(user_rec):
|
||||
if not m["allowed"]:
|
||||
continue
|
||||
target = "_blank" if m["url"].startswith("http") else "_self"
|
||||
items.append(
|
||||
{
|
||||
"key": m["key"],
|
||||
"label": m["title"],
|
||||
"group": m["category"],
|
||||
"url": m["url"] if m["status"] == "ready" else "#",
|
||||
"target": target,
|
||||
"icon": _icon_svg(m["key"]),
|
||||
"disabled": m["status"] != "ready",
|
||||
"disabled_reason": "준비중" if m["status"] != "ready" else None,
|
||||
}
|
||||
)
|
||||
for it in items:
|
||||
it["active"] = it["key"] == active
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
sess = get_session_user(request)
|
||||
@@ -235,13 +316,46 @@ async def home(request: Request) -> HTMLResponse:
|
||||
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
|
||||
request.session.clear()
|
||||
return render_template(request, "login.html")
|
||||
menu_items = _menu_items_for(user_rec)
|
||||
|
||||
# 슈퍼 관리자 → 업무 모듈 선택 화면(main.html).
|
||||
# 일반 사용자 → ERP 메인(좌측 메뉴 + 우측 콘텐츠).
|
||||
if user_rec.get("is_super_admin"):
|
||||
menu_items = _menu_items_for(user_rec)
|
||||
return render_template(
|
||||
request,
|
||||
"main.html",
|
||||
{
|
||||
"user": user_rec,
|
||||
"menu_items": menu_items,
|
||||
"is_admin": is_admin(user_rec),
|
||||
},
|
||||
)
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"erp_home.html",
|
||||
{
|
||||
"user": user_rec,
|
||||
"is_admin": is_admin(user_rec),
|
||||
"nav_items": build_erp_nav(user_rec, active="home"),
|
||||
"page_title": "ERP 홈",
|
||||
"page_subtitle": "오늘의 업무를 시작하세요.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/modules", response_class=HTMLResponse)
|
||||
async def modules_page(request: Request) -> HTMLResponse:
|
||||
"""업무 모듈 선택 화면. 슈퍼 관리자가 사이드바에서 다시 진입할 때 사용."""
|
||||
user_rec = get_current_user_record(request)
|
||||
if user_rec is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
return render_template(
|
||||
request,
|
||||
"main.html",
|
||||
{
|
||||
"user": user_rec,
|
||||
"menu_items": menu_items,
|
||||
"menu_items": _menu_items_for(user_rec),
|
||||
"is_admin": is_admin(user_rec),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""개인경비(expense) 모듈.
|
||||
|
||||
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/expense)
|
||||
- 저장소: `store.py` (DATA_DIR/expense.json, 향후 expense_db 후보)
|
||||
- 템플릿: `templates/expense/index.html`
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .router import router
|
||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"ExpenseStore",
|
||||
"CATEGORIES",
|
||||
"METHODS",
|
||||
"STATUSES",
|
||||
"build_expense_store",
|
||||
]
|
||||
|
||||
|
||||
def build_expense_store(*, dsn: str | None, json_path: Path) -> Any:
|
||||
"""env 의 EXPENSE_DB_URL 이 있으면 DB 저장소, 없으면 JSON 저장소.
|
||||
|
||||
DB 저장소 실패(드라이버 미설치/접속 실패) 시 예외를 그대로 전파한다 —
|
||||
의도치 않게 JSON 으로 폴백해 운영 데이터가 갈라지는 것을 막기 위함.
|
||||
"""
|
||||
if dsn:
|
||||
from .db import ExpenseDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return ExpenseDBStore(dsn)
|
||||
return ExpenseStore(json_path)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""expense_db PostgreSQL 저장소.
|
||||
|
||||
JSON 저장소(`ExpenseStore`)와 같은 인터페이스를 제공하여, 라우터 코드를
|
||||
바꾸지 않고도 교체할 수 있다.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`)
|
||||
- 연결 정보: 환경변수 `EXPENSE_DB_URL` (예: postgresql://user:pwd@host:5432/expense_db)
|
||||
- 스키마: `scripts/sql/expense_db_init.sql` 로 사전 초기화한다. 본 클래스는
|
||||
앱 부팅 시 `CREATE TABLE IF NOT EXISTS`로 보강만 한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from .store import CATEGORIES, METHODS, STATUSES
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS expense_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
spent_at DATE NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
merchant TEXT NOT NULL DEFAULT '',
|
||||
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
|
||||
"""
|
||||
|
||||
|
||||
class ExpenseDBStore:
|
||||
"""`ExpenseStore` 와 동일한 메서드 시그니처."""
|
||||
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=True,
|
||||
)
|
||||
self._ensure_schema()
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
conn.execute(_DDL)
|
||||
|
||||
# ── 조회 ──
|
||||
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE owner = %s "
|
||||
"ORDER BY spent_at DESC, created_at DESC",
|
||||
(email,),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_items ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE id = %s AND owner = %s",
|
||||
(item_id, owner),
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
# ── 변경 ──
|
||||
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
norm = self._normalize(payload)
|
||||
if not norm["spent_at"]:
|
||||
raise ValueError("spent_at 필수")
|
||||
status = (payload.get("status") or "작성중").strip()
|
||||
item_id = uuid.uuid4().hex[:12]
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO expense_items
|
||||
(id, owner, spent_at, category, method, merchant, amount, memo, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
owner,
|
||||
norm["spent_at"],
|
||||
norm["category"],
|
||||
norm["method"],
|
||||
norm["merchant"],
|
||||
norm["amount"],
|
||||
norm["memo"],
|
||||
status,
|
||||
),
|
||||
).fetchone()
|
||||
return self._serialize(row)
|
||||
|
||||
def update(
|
||||
self, *, item_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
norm = self._normalize(payload)
|
||||
status = payload.get("status")
|
||||
with self._pool.connection() as conn:
|
||||
if status:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET spent_at = %s, category = %s, method = %s,
|
||||
merchant = %s, amount = %s, memo = %s, status = %s
|
||||
WHERE id = %s AND owner = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
norm["spent_at"] or None,
|
||||
norm["category"],
|
||||
norm["method"],
|
||||
norm["merchant"],
|
||||
norm["amount"],
|
||||
norm["memo"],
|
||||
status,
|
||||
item_id,
|
||||
owner,
|
||||
),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET spent_at = %s, category = %s, method = %s,
|
||||
merchant = %s, amount = %s, memo = %s
|
||||
WHERE id = %s AND owner = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
norm["spent_at"] or None,
|
||||
norm["category"],
|
||||
norm["method"],
|
||||
norm["merchant"],
|
||||
norm["amount"],
|
||||
norm["memo"],
|
||||
item_id,
|
||||
owner,
|
||||
),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(item_id)
|
||||
return self._serialize(row)
|
||||
|
||||
def delete(self, *, item_id: str, owner: str) -> None:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM expense_items WHERE id = %s AND owner = %s",
|
||||
(item_id, owner),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(item_id)
|
||||
|
||||
# ── 요약 ──
|
||||
def summary_for(self, email: str) -> dict[str, Any]:
|
||||
email = email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
head = conn.execute(
|
||||
"SELECT COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total "
|
||||
"FROM expense_items WHERE owner = %s",
|
||||
(email,),
|
||||
).fetchone()
|
||||
status_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS c FROM expense_items "
|
||||
"WHERE owner = %s GROUP BY status",
|
||||
(email,),
|
||||
).fetchall()
|
||||
cat_rows = conn.execute(
|
||||
"SELECT category, COALESCE(SUM(amount), 0) AS s "
|
||||
"FROM expense_items WHERE owner = %s GROUP BY category",
|
||||
(email,),
|
||||
).fetchall()
|
||||
by_status = {s: 0 for s in STATUSES}
|
||||
for r in status_rows:
|
||||
by_status[r["status"]] = int(r["c"])
|
||||
by_category = {c: 0 for c in CATEGORIES}
|
||||
for r in cat_rows:
|
||||
by_category[r["category"]] = int(r["s"])
|
||||
return {
|
||||
"count": int(head["count"]) if head else 0,
|
||||
"total": int(head["total"]) if head else 0,
|
||||
"by_status": by_status,
|
||||
"by_category": by_category,
|
||||
}
|
||||
|
||||
# ── helpers ──
|
||||
@staticmethod
|
||||
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
if isinstance(out.get("spent_at"), date):
|
||||
out["spent_at"] = out["spent_at"].isoformat()
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
out["amount"] = int(out.get("amount", 0))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _normalize(
|
||||
payload: dict[str, Any], base: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
b = dict(base or {})
|
||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||
b["category"] = category if category in CATEGORIES else "기타"
|
||||
b["method"] = method if method in METHODS else "법인카드"
|
||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||
try:
|
||||
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
|
||||
except (TypeError, ValueError):
|
||||
b["amount"] = 0
|
||||
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
|
||||
return b
|
||||
@@ -0,0 +1,150 @@
|
||||
"""개인경비 모듈 라우터.
|
||||
|
||||
- 경로: /expense
|
||||
- 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과).
|
||||
- 데이터: ExpenseStore (JSON). app.state.expense_store 에 연결.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||
|
||||
router = APIRouter(prefix="/expense", tags=["expense"])
|
||||
|
||||
|
||||
def _store(request: Request) -> ExpenseStore:
|
||||
store = getattr(request.app.state, "expense_store", None)
|
||||
if store is None:
|
||||
raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.")
|
||||
return store
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
# 순환 import 회피: 핸들러 호출 시점에 main 모듈에서 helper 를 가져온다.
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not has_module(user, "expense"):
|
||||
raise HTTPException(status_code=403, detail="개인경비 모듈 권한이 없습니다.")
|
||||
return user
|
||||
|
||||
|
||||
class ExpensePayload(BaseModel):
|
||||
spent_at: str = Field(..., description="YYYY-MM-DD")
|
||||
category: str
|
||||
method: str
|
||||
merchant: str = ""
|
||||
amount: int = Field(ge=0)
|
||||
memo: str = ""
|
||||
status: str | None = None
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def expense_index(request: Request) -> HTMLResponse:
|
||||
from app.main import ( # noqa: WPS433
|
||||
build_erp_nav,
|
||||
get_current_user_record,
|
||||
render_template,
|
||||
)
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not has_module(user, "expense"):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "개인경비 모듈 접근 권한이 없습니다."},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
store = _store(request)
|
||||
items = sorted(
|
||||
store.list_for(user["email"]),
|
||||
key=lambda i: (i.get("spent_at", ""), i.get("created_at", "")),
|
||||
reverse=True,
|
||||
)
|
||||
summary = store.summary_for(user["email"])
|
||||
return render_template(
|
||||
request,
|
||||
"expense/index.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"items": items,
|
||||
"summary": summary,
|
||||
"categories": list(CATEGORIES),
|
||||
"methods": list(METHODS),
|
||||
"statuses": list(STATUSES),
|
||||
"nav_items": build_erp_nav(user, active="expense"),
|
||||
"page_title": "개인경비",
|
||||
"page_subtitle": "법인카드 · 개인지출 · 정산 신청",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/items")
|
||||
async def list_items(
|
||||
request: Request,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"items": _store(request).list_for(user["email"]),
|
||||
"summary": _store(request).summary_for(user["email"]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/items")
|
||||
async def create_item(
|
||||
request: Request,
|
||||
body: ExpensePayload,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
item = _store(request).create(owner=user["email"], payload=body.model_dump())
|
||||
return JSONResponse({"item": item}, status_code=201)
|
||||
|
||||
|
||||
@router.put("/api/items/{item_id}")
|
||||
async def update_item(
|
||||
request: Request,
|
||||
item_id: str,
|
||||
body: ExpensePayload,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
item = _store(request).update(
|
||||
item_id=item_id, owner=user["email"], payload=body.model_dump()
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
|
||||
return JSONResponse({"item": item})
|
||||
|
||||
|
||||
@router.delete("/api/items/{item_id}")
|
||||
async def delete_item(
|
||||
request: Request,
|
||||
item_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
_store(request).delete(item_id=item_id, owner=user["email"])
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다.")
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "module": "expense"}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""개인경비 항목 JSON 저장소.
|
||||
|
||||
- 저장 위치: DATA_DIR/expense.json
|
||||
- 사용자(email) 단위 소유. 본인 항목만 조회/수정/삭제.
|
||||
- 향후 expense_db(PostgreSQL)로 마이그레이션 예정. 현재는 DB 생성 승인 전이라 JSON 사용.
|
||||
- 동시성: 프로세스 내 threading.Lock + 원자적 쓰기(temp → rename). UserStore 와 동일 패턴.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
||||
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class ExpenseStore:
|
||||
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({"items": []})
|
||||
|
||||
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 = {"items": []}
|
||||
if not isinstance(data.get("items"), list):
|
||||
data["items"] = []
|
||||
return data
|
||||
|
||||
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=".expense.", 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
|
||||
|
||||
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
return [it for it in data["items"] if it.get("owner") == email]
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
return list(self._read()["items"])
|
||||
|
||||
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
for it in data["items"]:
|
||||
if it["id"] == item_id and it.get("owner") == owner:
|
||||
return dict(it)
|
||||
return None
|
||||
|
||||
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
item = self._normalize(payload)
|
||||
item["id"] = uuid.uuid4().hex[:12]
|
||||
item["owner"] = owner
|
||||
item["status"] = payload.get("status") or "작성중"
|
||||
item["created_at"] = _now_iso()
|
||||
item["updated_at"] = item["created_at"]
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
data["items"].append(item)
|
||||
self._write_atomic(data)
|
||||
return item
|
||||
|
||||
def update(
|
||||
self, *, item_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
for idx, it in enumerate(data["items"]):
|
||||
if it["id"] == item_id and it.get("owner") == owner:
|
||||
new = self._normalize(payload, base=it)
|
||||
new["id"] = it["id"]
|
||||
new["owner"] = it["owner"]
|
||||
new["created_at"] = it.get("created_at", _now_iso())
|
||||
new["status"] = payload.get("status") or it.get("status", "작성중")
|
||||
new["updated_at"] = _now_iso()
|
||||
data["items"][idx] = new
|
||||
self._write_atomic(data)
|
||||
return new
|
||||
raise KeyError(item_id)
|
||||
|
||||
def delete(self, *, item_id: str, owner: str) -> None:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
before = len(data["items"])
|
||||
data["items"] = [
|
||||
it
|
||||
for it in data["items"]
|
||||
if not (it["id"] == item_id and it.get("owner") == owner)
|
||||
]
|
||||
if len(data["items"]) == before:
|
||||
raise KeyError(item_id)
|
||||
self._write_atomic(data)
|
||||
|
||||
def summary_for(self, email: str) -> dict[str, Any]:
|
||||
items = self.list_for(email)
|
||||
total = sum(int(i.get("amount", 0)) for i in items)
|
||||
by_status = {s: sum(1 for i in items if i.get("status") == s) for s in STATUSES}
|
||||
by_category = {
|
||||
c: sum(int(i.get("amount", 0)) for i in items if i.get("category") == c)
|
||||
for c in CATEGORIES
|
||||
}
|
||||
return {
|
||||
"count": len(items),
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"by_category": by_category,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize(
|
||||
payload: dict[str, Any], base: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
b = dict(base or {})
|
||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||
b["category"] = category if category in CATEGORIES else "기타"
|
||||
b["method"] = method if method in METHODS else "법인카드"
|
||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||
try:
|
||||
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
|
||||
except (TypeError, ValueError):
|
||||
b["amount"] = 0
|
||||
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
|
||||
return b
|
||||
@@ -0,0 +1,238 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-expense">
|
||||
|
||||
<!-- ── 요약 카드 ── -->
|
||||
<div class="erp-summary-grid">
|
||||
<div class="erp-summary-card">
|
||||
<span class="erp-summary-label">총 건수</span>
|
||||
<strong class="erp-summary-value">{{ summary.count }} 건</strong>
|
||||
</div>
|
||||
<div class="erp-summary-card">
|
||||
<span class="erp-summary-label">총 금액</span>
|
||||
<strong class="erp-summary-value" id="ex-total-amount">
|
||||
{{ "{:,}".format(summary.total) }} 원
|
||||
</strong>
|
||||
</div>
|
||||
{% for status in statuses %}
|
||||
<div class="erp-summary-card erp-summary-card--mini">
|
||||
<span class="erp-summary-label">{{ status }}</span>
|
||||
<strong class="erp-summary-value erp-summary-value--sm">
|
||||
{{ summary.by_status.get(status, 0) }}
|
||||
</strong>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── 입력 폼 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>경비 등록</h2>
|
||||
<span class="erp-muted">필수 항목을 입력하고 등록을 누르세요.</span>
|
||||
</div>
|
||||
<form id="ex-form" class="erp-form-grid">
|
||||
<input type="hidden" name="id" />
|
||||
<label class="erp-field">
|
||||
<span>사용일</span>
|
||||
<input type="date" name="spent_at" required />
|
||||
</label>
|
||||
<label class="erp-field">
|
||||
<span>분류</span>
|
||||
<select name="category" required>
|
||||
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-field">
|
||||
<span>결제수단</span>
|
||||
<select name="method" required>
|
||||
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-field">
|
||||
<span>금액</span>
|
||||
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
||||
</label>
|
||||
<label class="erp-field erp-field-wide">
|
||||
<span>가맹점/사용처</span>
|
||||
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
|
||||
</label>
|
||||
<label class="erp-field erp-field-wide">
|
||||
<span>메모</span>
|
||||
<input type="text" name="memo" placeholder="비고" />
|
||||
</label>
|
||||
<div class="erp-form-actions">
|
||||
<button type="reset" class="erp-btn erp-btn-outline" id="ex-reset">초기화</button>
|
||||
<button type="submit" class="erp-btn erp-btn-primary" id="ex-submit">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ── 항목 목록 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>경비 내역</h2>
|
||||
<span class="erp-muted" id="ex-count">{{ items | length }}건</span>
|
||||
</div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table" id="ex-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 110px;">사용일</th>
|
||||
<th style="width: 90px;">분류</th>
|
||||
<th style="width: 100px;">수단</th>
|
||||
<th>가맹점</th>
|
||||
<th style="width: 120px; text-align: right;">금액</th>
|
||||
<th style="width: 90px;">상태</th>
|
||||
<th style="width: 130px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ex-tbody">
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}">
|
||||
<td>{{ it.spent_at }}</td>
|
||||
<td>{{ it.category }}</td>
|
||||
<td>{{ it.method }}</td>
|
||||
<td>
|
||||
<div>{{ it.merchant }}</div>
|
||||
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||
</td>
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(it.amount) }} 원
|
||||
</td>
|
||||
<td><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td>
|
||||
<td style="text-align: right;">
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr id="ex-empty"><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById("ex-form");
|
||||
const submitBtn = document.getElementById("ex-submit");
|
||||
const resetBtn = document.getElementById("ex-reset");
|
||||
const tbody = document.getElementById("ex-tbody");
|
||||
const countEl = document.getElementById("ex-count");
|
||||
const totalEl = document.getElementById("ex-total-amount");
|
||||
|
||||
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")} 원`;
|
||||
|
||||
function setEditing(id, data) {
|
||||
form.id.value = id || "";
|
||||
if (data) {
|
||||
form.spent_at.value = data.spent_at || "";
|
||||
form.category.value = data.category || "";
|
||||
form.method.value = data.method || "";
|
||||
form.amount.value = data.amount || 0;
|
||||
form.merchant.value = data.merchant || "";
|
||||
form.memo.value = data.memo || "";
|
||||
submitBtn.textContent = "수정 저장";
|
||||
} else {
|
||||
submitBtn.textContent = "등록";
|
||||
}
|
||||
}
|
||||
|
||||
resetBtn.addEventListener("click", () => setEditing("", null));
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const id = form.id.value.trim();
|
||||
const payload = {
|
||||
spent_at: form.spent_at.value,
|
||||
category: form.category.value,
|
||||
method: form.method.value,
|
||||
amount: parseInt(form.amount.value || "0", 10),
|
||||
merchant: form.merchant.value,
|
||||
memo: form.memo.value,
|
||||
};
|
||||
const url = id ? `/expense/api/items/${id}` : "/expense/api/items";
|
||||
const method = id ? "PUT" : "POST";
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await reload();
|
||||
form.reset();
|
||||
setEditing("", null);
|
||||
} catch (err) {
|
||||
alert(`저장 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr");
|
||||
const id = tr && tr.dataset.id;
|
||||
if (!id) return;
|
||||
if (btn.classList.contains("js-delete")) {
|
||||
if (!confirm("이 항목을 삭제할까요?")) return;
|
||||
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||
if (res.ok) reload(); else alert("삭제 실패");
|
||||
} else if (btn.classList.contains("js-edit")) {
|
||||
const res = await fetch(`/expense/api/items`);
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
const found = (j.items || []).find((x) => x.id === id);
|
||||
if (found) setEditing(id, found);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
const res = await fetch(`/expense/api/items`);
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
const items = j.items || [];
|
||||
const summary = j.summary || { count: 0, total: 0 };
|
||||
countEl.textContent = `${items.length}건`;
|
||||
if (totalEl) totalEl.textContent = fmt(summary.total);
|
||||
|
||||
tbody.innerHTML = "";
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
items
|
||||
.sort((a, b) => (b.spent_at || "").localeCompare(a.spent_at || ""))
|
||||
.forEach((it) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.id = it.id;
|
||||
tr.innerHTML = `
|
||||
<td>${it.spent_at || ""}</td>
|
||||
<td>${it.category || ""}</td>
|
||||
<td>${it.method || ""}</td>
|
||||
<td><div>${it.merchant || ""}</div>${it.memo ? `<div class="erp-row-sub">${it.memo}</div>` : ""}</td>
|
||||
<td style="text-align:right; font-variant-numeric: tabular-nums;">${fmt(it.amount)}</td>
|
||||
<td><span class="erp-badge erp-badge-neutral">${it.status || "작성중"}</span></td>
|
||||
<td style="text-align:right;">
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// 사용일 기본값 = 오늘
|
||||
if (!form.spent_at.value) {
|
||||
const d = new Date();
|
||||
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,288 @@
|
||||
/* ════════════════════════════════════════════════════
|
||||
ERP Shell — 좌측 사이드바 + 우측 콘텐츠 레이아웃
|
||||
erp.css 의 토큰을 그대로 사용한다. 같이 로드 필수.
|
||||
════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--erp-sidebar-w: 240px;
|
||||
--erp-sidebar-w-collapsed: 64px;
|
||||
--erp-topbar-h: 56px;
|
||||
}
|
||||
|
||||
/* ── 페이지 전체 컨테이너 ── */
|
||||
body.erp-app-body { background: var(--color-canvas-white); min-height: 100vh; }
|
||||
|
||||
.erp-app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--erp-sidebar-w) 1fr;
|
||||
min-height: 100vh;
|
||||
transition: grid-template-columns .18s ease;
|
||||
}
|
||||
.erp-app:has(.erp-sidebar[data-collapsed="true"]) {
|
||||
grid-template-columns: var(--erp-sidebar-w-collapsed) 1fr;
|
||||
}
|
||||
|
||||
/* ── 사이드바 ── */
|
||||
.erp-sidebar {
|
||||
position: sticky; top: 0;
|
||||
height: 100vh;
|
||||
border-right: 1px solid var(--color-subtle-ash);
|
||||
background: var(--color-canvas-white);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.erp-sidebar-head {
|
||||
height: var(--erp-topbar-h);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 var(--sp-12) 0 var(--sp-16);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.erp-sidebar-brand {
|
||||
display: inline-flex; align-items: center; gap: var(--sp-8);
|
||||
color: var(--color-deep-black);
|
||||
}
|
||||
.erp-sidebar-logo { height: 22px; width: auto; display: block; }
|
||||
.erp-sidebar-system {
|
||||
font-weight: 600; font-size: 14px; letter-spacing: -0.2px;
|
||||
}
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-system { display: none; }
|
||||
|
||||
.erp-sidebar-toggle {
|
||||
width: 28px; height: 28px; border-radius: 8px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: 1px solid transparent; cursor: pointer;
|
||||
color: var(--color-midtone-gray);
|
||||
}
|
||||
.erp-sidebar-toggle:hover { background: var(--color-ghost-gray); color: var(--color-deep-black); }
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-toggle svg { transform: rotate(180deg); }
|
||||
|
||||
.erp-sidebar-nav {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: var(--sp-12) var(--sp-8);
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
|
||||
.erp-sidebar-group {
|
||||
font-size: 11px; font-weight: 600;
|
||||
color: var(--color-midtone-gray);
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
padding: var(--sp-12) var(--sp-12) var(--sp-6);
|
||||
}
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-group { opacity: 0; height: 0; padding: 0; pointer-events: none; }
|
||||
|
||||
.erp-sidebar-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px var(--sp-12);
|
||||
border-radius: 10px;
|
||||
color: var(--color-rich-black);
|
||||
font-size: 14px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.erp-sidebar-item:hover:not(.is-disabled) { background: var(--color-ghost-gray); }
|
||||
.erp-sidebar-item.is-active {
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
}
|
||||
.erp-sidebar-item.is-disabled {
|
||||
color: var(--color-midtone-gray);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.erp-sidebar-icon {
|
||||
width: 18px; height: 18px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.erp-sidebar-label { flex: 1; }
|
||||
.erp-sidebar-badge {
|
||||
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||
padding: 2px 8px; border-radius: var(--r-badge);
|
||||
font-size: 11px; font-weight: 500;
|
||||
}
|
||||
.erp-sidebar-item.is-active .erp-sidebar-badge {
|
||||
background: rgba(255,255,255,0.18); color: var(--color-canvas-white);
|
||||
}
|
||||
.erp-sidebar-ext { color: var(--color-midtone-gray); display: inline-flex; }
|
||||
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-label,
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-badge,
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-ext {
|
||||
display: none;
|
||||
}
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-item { justify-content: center; padding: 8px 0; }
|
||||
|
||||
.erp-sidebar-foot {
|
||||
padding: var(--sp-8);
|
||||
border-top: 1px solid var(--color-subtle-ash);
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 콘텐츠 영역 ── */
|
||||
.erp-content { display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.erp-topbar {
|
||||
height: var(--erp-topbar-h);
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
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);
|
||||
gap: var(--sp-16);
|
||||
}
|
||||
.erp-topbar-left { display: flex; align-items: center; gap: var(--sp-12); min-width: 0; }
|
||||
.erp-topbar-title h1 {
|
||||
font-size: 16px; font-weight: 600; margin: 0; line-height: 1.2;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
.erp-topbar-title p {
|
||||
margin: 2px 0 0; color: var(--color-midtone-gray); font-size: 12px;
|
||||
}
|
||||
.erp-topbar-right { display: flex; align-items: center; gap: var(--sp-12); }
|
||||
|
||||
.erp-sidebar-mobile-toggle {
|
||||
display: none;
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
align-items: center; justify-content: center;
|
||||
background: transparent; border: 1px solid var(--color-subtle-ash); cursor: pointer;
|
||||
color: var(--color-rich-black);
|
||||
}
|
||||
|
||||
.erp-page {
|
||||
padding: var(--sp-24);
|
||||
max-width: 1280px; width: 100%; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── 홈 대시보드 타일 ── */
|
||||
.erp-hero { margin: var(--sp-8) 0 var(--sp-24); }
|
||||
.erp-hero-title { font-size: 28px; font-weight: 600; margin: 0; letter-spacing: -0.6px; }
|
||||
.erp-hero-sub { margin: 6px 0 0; color: var(--color-midtone-gray); font-size: 13px; }
|
||||
|
||||
.erp-home-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--sp-16);
|
||||
}
|
||||
.erp-tile {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-card);
|
||||
padding: var(--sp-20);
|
||||
background: var(--color-canvas-white);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.erp-tile-head {
|
||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
|
||||
}
|
||||
.erp-tile-label {
|
||||
font-size: 11px; color: var(--color-midtone-gray); text-transform: uppercase; letter-spacing: 0.08em;
|
||||
}
|
||||
.erp-tile-title { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
|
||||
.erp-tile-desc { margin: 0 0 8px; color: var(--color-midtone-gray); font-size: 13px; }
|
||||
.erp-tile-list { margin: 0; padding-left: 18px; color: var(--color-rich-black); font-size: 13px; }
|
||||
.erp-tile-list li { margin: 4px 0; }
|
||||
|
||||
/* ── 개인경비 페이지 ── */
|
||||
.erp-expense { display: flex; flex-direction: column; gap: var(--sp-20); }
|
||||
|
||||
.erp-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: var(--sp-12);
|
||||
}
|
||||
.erp-summary-card {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-card);
|
||||
padding: var(--sp-16);
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
background: var(--color-canvas-white);
|
||||
}
|
||||
.erp-summary-card--mini { padding: var(--sp-12) var(--sp-16); }
|
||||
.erp-summary-label { font-size: 12px; color: var(--color-midtone-gray); }
|
||||
.erp-summary-value { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; font-variant-numeric: tabular-nums; }
|
||||
.erp-summary-value--sm { font-size: 16px; }
|
||||
|
||||
.erp-card-block {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-card);
|
||||
background: var(--color-canvas-white);
|
||||
overflow: hidden;
|
||||
}
|
||||
.erp-card-block-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
padding: var(--sp-16) var(--sp-20);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
}
|
||||
.erp-card-block-head h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -0.2px; }
|
||||
.erp-muted { color: var(--color-midtone-gray); font-size: 12px; }
|
||||
|
||||
.erp-form-grid {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0,1fr));
|
||||
gap: var(--sp-12); padding: var(--sp-16) var(--sp-20);
|
||||
}
|
||||
.erp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--color-midtone-gray); }
|
||||
.erp-field-wide { grid-column: span 2; }
|
||||
.erp-field input, .erp-field select {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input);
|
||||
padding: 6px 10px;
|
||||
font-family: inherit; font-size: 14px; color: var(--color-rich-black);
|
||||
background: var(--color-canvas-white);
|
||||
outline: none;
|
||||
}
|
||||
.erp-field input:focus, .erp-field select:focus {
|
||||
border-color: var(--color-rich-black);
|
||||
box-shadow: 0 0 0 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.erp-form-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; justify-content: flex-end; gap: var(--sp-8);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.erp-btn-sm { padding: 4px 10px !important; font-size: 12px !important; }
|
||||
|
||||
.erp-table-wrap { overflow-x: auto; }
|
||||
.erp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.erp-table thead th {
|
||||
text-align: left;
|
||||
font-weight: 500; color: var(--color-midtone-gray);
|
||||
padding: 10px var(--sp-16);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
background: var(--color-canvas-white);
|
||||
}
|
||||
.erp-table tbody td {
|
||||
padding: 10px var(--sp-16);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
vertical-align: top;
|
||||
}
|
||||
.erp-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
|
||||
.erp-row-sub { font-size: 12px; color: var(--color-midtone-gray); margin-top: 2px; }
|
||||
.erp-empty { text-align: center; color: var(--color-midtone-gray); padding: var(--sp-24); }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 900px) {
|
||||
.erp-app { grid-template-columns: 0 1fr; }
|
||||
.erp-sidebar {
|
||||
position: fixed; left: 0; top: 0; height: 100vh;
|
||||
width: var(--erp-sidebar-w);
|
||||
transform: translateX(-100%);
|
||||
transition: transform .2s ease;
|
||||
box-shadow: 0 0 0 1px var(--color-subtle-ash);
|
||||
}
|
||||
.erp-sidebar.is-open { transform: translateX(0); }
|
||||
.erp-sidebar-mobile-toggle { display: inline-flex; }
|
||||
.erp-form-grid { grid-template-columns: 1fr 1fr; }
|
||||
.erp-field-wide { grid-column: span 2; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.erp-form-grid { grid-template-columns: 1fr; }
|
||||
.erp-field-wide { grid-column: auto; }
|
||||
.erp-page { padding: var(--sp-16); }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ page_title or "ERP" }} — DBX Corporation</title>
|
||||
<link rel="stylesheet" href="/static/erp.css" />
|
||||
<link rel="stylesheet" href="/static/erp-shell.css" />
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body class="erp-body erp-app-body">
|
||||
|
||||
<div class="erp-app">
|
||||
|
||||
<!-- ── 좌측 사이드바 ── -->
|
||||
<aside class="erp-sidebar" data-collapsed="false">
|
||||
|
||||
<div class="erp-sidebar-head">
|
||||
<a href="/" class="erp-sidebar-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-sidebar-logo" />
|
||||
<span class="erp-sidebar-system">ERP</span>
|
||||
</a>
|
||||
<button type="button" class="erp-sidebar-toggle" id="erp-sidebar-toggle" aria-label="사이드바 접기">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="erp-sidebar-nav">
|
||||
{% set last_group = namespace(value=None) %}
|
||||
{% for item in nav_items %}
|
||||
{% if item.group != last_group.value %}
|
||||
<div class="erp-sidebar-group">{{ item.group }}</div>
|
||||
{% set last_group.value = item.group %}
|
||||
{% endif %}
|
||||
|
||||
{% set classes = "erp-sidebar-item" %}
|
||||
{% if item.active %}{% set classes = classes + " is-active" %}{% endif %}
|
||||
{% if item.disabled %}{% set classes = classes + " is-disabled" %}{% endif %}
|
||||
|
||||
{% if item.disabled %}
|
||||
<div class="{{ classes }}" title="{{ item.disabled_reason or '준비중' }}">
|
||||
{% else %}
|
||||
<a class="{{ classes }}" href="{{ item.url }}"
|
||||
{% if item.target == '_blank' %}target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
{% endif %}
|
||||
<span class="erp-sidebar-icon">{{ item.icon | safe }}</span>
|
||||
<span class="erp-sidebar-label">{{ item.label }}</span>
|
||||
{% if item.badge %}<span class="erp-sidebar-badge">{{ item.badge }}</span>{% endif %}
|
||||
{% if item.target == '_blank' %}
|
||||
<span class="erp-sidebar-ext" aria-hidden="true">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if item.disabled %}
|
||||
</div>
|
||||
{% else %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="erp-sidebar-foot">
|
||||
{% if user.is_super_admin %}
|
||||
<a class="erp-sidebar-item" href="/modules" title="업무 모듈 선택 화면">
|
||||
<span class="erp-sidebar-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="erp-sidebar-label">업무 모듈</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<a class="erp-sidebar-item" href="/admin">
|
||||
<span class="erp-sidebar-icon">
|
||||
<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>
|
||||
</span>
|
||||
<span class="erp-sidebar-label">관리자</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── 우측 콘텐츠 ── -->
|
||||
<div class="erp-content">
|
||||
|
||||
<header class="erp-topbar">
|
||||
<div class="erp-topbar-left">
|
||||
<button type="button" class="erp-sidebar-mobile-toggle" id="erp-sidebar-mobile-toggle" aria-label="메뉴 열기">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="erp-topbar-title">
|
||||
<h1>{{ page_title or "" }}</h1>
|
||||
{% if page_subtitle %}<p>{{ page_subtitle }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="erp-topbar-right">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main class="erp-page">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const sidebar = document.querySelector(".erp-sidebar");
|
||||
const collapseBtn = document.getElementById("erp-sidebar-toggle");
|
||||
const mobileBtn = document.getElementById("erp-sidebar-mobile-toggle");
|
||||
if (collapseBtn) {
|
||||
collapseBtn.addEventListener("click", () => {
|
||||
const next = sidebar.dataset.collapsed === "true" ? "false" : "true";
|
||||
sidebar.dataset.collapsed = next;
|
||||
try { localStorage.setItem("erp_sidebar_collapsed", next); } catch (_) {}
|
||||
});
|
||||
try {
|
||||
const saved = localStorage.getItem("erp_sidebar_collapsed");
|
||||
if (saved === "true") sidebar.dataset.collapsed = "true";
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mobileBtn) {
|
||||
mobileBtn.addEventListener("click", () => {
|
||||
sidebar.classList.toggle("is-open");
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-home">
|
||||
|
||||
<div class="erp-hero">
|
||||
<h2 class="erp-hero-title">안녕하세요, {{ user.name }}님</h2>
|
||||
<p class="erp-hero-sub" id="erp-hero-sub"></p>
|
||||
</div>
|
||||
|
||||
<div class="erp-home-grid">
|
||||
|
||||
<article class="erp-tile">
|
||||
<div class="erp-tile-head">
|
||||
<span class="erp-tile-label">바로가기</span>
|
||||
<span class="erp-badge erp-badge-neutral">개인업무</span>
|
||||
</div>
|
||||
<h3 class="erp-tile-title">개인경비</h3>
|
||||
<p class="erp-tile-desc">법인카드/개인지출 등록과 정산 신청.</p>
|
||||
<a class="erp-btn erp-btn-primary" href="/expense/">개인경비 열기</a>
|
||||
</article>
|
||||
|
||||
<article class="erp-tile">
|
||||
<div class="erp-tile-head">
|
||||
<span class="erp-tile-label">바로가기</span>
|
||||
<span class="erp-badge erp-badge-outline">준비중</span>
|
||||
</div>
|
||||
<h3 class="erp-tile-title">휴가</h3>
|
||||
<p class="erp-tile-desc">연차/반차 신청과 잔여일수 확인.</p>
|
||||
<button class="erp-btn erp-btn-outline" disabled>오픈 예정</button>
|
||||
</article>
|
||||
|
||||
<article class="erp-tile">
|
||||
<div class="erp-tile-head">
|
||||
<span class="erp-tile-label">알림</span>
|
||||
<span class="erp-badge erp-badge-outline">공지</span>
|
||||
</div>
|
||||
<h3 class="erp-tile-title">시스템 안내</h3>
|
||||
<ul class="erp-tile-list">
|
||||
<li>좌측 메뉴에서 사용 가능한 업무 모듈을 확인하세요.</li>
|
||||
<li>외부 모듈(주문/CORM)은 새 창에서 열립니다.</li>
|
||||
<li>권한이 필요한 모듈은 관리자에게 요청하세요.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(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()]})`;
|
||||
const el = document.getElementById("erp-hero-sub");
|
||||
if (el) el.textContent = `${dateStr} · ${greet}`;
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user