feat(expense+admin): 결재 워크플로 + 첨부 + 집계 + 엑셀 + 사용자 직접 등록
[admin]
- POST /api/users 사용자 직접 등록 (이메일만으로). UserStore.create_user 추가
- admin.html: 등록 폼 + 삭제 버튼 + 한글 라벨 + 신규 권한 표시
- 신규 권한 키: expense_approver, vacation_approver (APPROVER_KEYS)
- is_approver(user_rec, kind) 헬퍼
[expense 워크플로]
- 상태 전이: 작성중 → 제출 → 승인/반려 → 정산완료
- API: /api/items/{id}/{submit,revert,approve,reject,settle}
- 권한: submit/revert=owner, approve/reject/settle=expense_approver 또는 admin
- expense_items 컬럼: approver_email, decided_at, reject_reason
- 작성중/반려 상태에서만 수정/삭제/첨부 가능
[expense 첨부]
- 영수증/기타 (kind: receipt|other). 최대 20MB, 확장자 화이트리스트
- 저장: DATA_DIR/uploads/expense/{item_id}/{att_id}_{filename}
- expense_attachments 테이블 (CASCADE on item delete)
- API: 업로드/목록/다운로드/삭제
[expense 집계]
- /expense/reports: 연도별 월×카테고리 피벗 표
[expense 엑셀]
- GET /expense/api/export.xlsx?from&to&scope=mine|all
- scope=all 은 승인자/관리자만
[DDL]
- expense_db_init.sql: 신규 컬럼/테이블 포함 (멱등)
- expense_db_002_workflow_attachments.sql: 운영 DB 마이그레이션용
[deps]
- openpyxl>=3.1, python-multipart>=0.0.20
[docs]
- DATABASES.md: 워크플로 다이어그램, attachments 테이블, 마이그레이션
- PROJECT_OVERVIEW.md: 권한 키 표
- DEPLOYMENT.md: DATA_DIR/uploads 안내
This commit is contained in:
+38
@@ -16,6 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
from .modules.expense import build_expense_store
|
from .modules.expense import build_expense_store
|
||||||
from .modules.expense import router as expense_router
|
from .modules.expense import router as expense_router
|
||||||
from .store import (
|
from .store import (
|
||||||
|
APPROVER_KEYS,
|
||||||
MODULE_KEYS,
|
MODULE_KEYS,
|
||||||
SUPER_ADMIN_EMAIL,
|
SUPER_ADMIN_EMAIL,
|
||||||
UserStore,
|
UserStore,
|
||||||
@@ -24,6 +25,16 @@ from .store import (
|
|||||||
is_admin,
|
is_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 권한 키 한글 라벨 (admin.html / 사이드바 공용)
|
||||||
|
MODULE_LABELS: dict[str, str] = {
|
||||||
|
"corm": "CORM",
|
||||||
|
"order": "Order",
|
||||||
|
"expense": "개인경비",
|
||||||
|
"vacation": "휴가",
|
||||||
|
"expense_approver": "개인경비",
|
||||||
|
"vacation_approver": "휴가",
|
||||||
|
}
|
||||||
|
|
||||||
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
|
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
|
||||||
SESSION_COOKIE_DEFAULT = "session"
|
SESSION_COOKIE_DEFAULT = "session"
|
||||||
|
|
||||||
@@ -104,6 +115,7 @@ user_store = UserStore(DATA_DIR / "users.json")
|
|||||||
|
|
||||||
# 모듈별 데이터 저장소.
|
# 모듈별 데이터 저장소.
|
||||||
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
|
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
|
||||||
|
app.state.data_dir = DATA_DIR # 모듈에서 첨부 저장 경로 등으로 참조
|
||||||
app.state.expense_store = build_expense_store(
|
app.state.expense_store = build_expense_store(
|
||||||
dsn=env("EXPENSE_DB_URL") or None,
|
dsn=env("EXPENSE_DB_URL") or None,
|
||||||
json_path=DATA_DIR / "expense.json",
|
json_path=DATA_DIR / "expense.json",
|
||||||
@@ -459,6 +471,8 @@ async def admin_page(request: Request) -> HTMLResponse:
|
|||||||
"user": rec,
|
"user": rec,
|
||||||
"users": users,
|
"users": users,
|
||||||
"module_keys": list(MODULE_KEYS),
|
"module_keys": list(MODULE_KEYS),
|
||||||
|
"module_labels": MODULE_LABELS,
|
||||||
|
"approver_keys": list(APPROVER_KEYS),
|
||||||
"super_admin_email": SUPER_ADMIN_EMAIL,
|
"super_admin_email": SUPER_ADMIN_EMAIL,
|
||||||
"is_admin": True,
|
"is_admin": True,
|
||||||
},
|
},
|
||||||
@@ -471,11 +485,35 @@ class UpdatePermissionsBody(BaseModel):
|
|||||||
modules: dict[str, bool] | None = None
|
modules: dict[str, bool] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserBody(BaseModel):
|
||||||
|
email: str
|
||||||
|
name: str = ""
|
||||||
|
role: str = "user"
|
||||||
|
modules: dict[str, bool] | None = None
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/users")
|
@app.get("/api/users")
|
||||||
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
|
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
|
||||||
return JSONResponse({"users": user_store.list_all()})
|
return JSONResponse({"users": user_store.list_all()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users")
|
||||||
|
async def api_create_user(
|
||||||
|
body: CreateUserBody,
|
||||||
|
_: dict[str, Any] = Depends(require_admin),
|
||||||
|
) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
rec = user_store.create_user(
|
||||||
|
email=body.email,
|
||||||
|
name=body.name,
|
||||||
|
role=body.role,
|
||||||
|
modules=body.modules,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return JSONResponse({"user": rec}, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/users/{email}")
|
@app.put("/api/users/{email}")
|
||||||
async def api_update_user(
|
async def api_update_user(
|
||||||
email: str,
|
email: str,
|
||||||
|
|||||||
@@ -178,6 +178,267 @@ class ExpenseDBStore:
|
|||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise KeyError(item_id)
|
raise KeyError(item_id)
|
||||||
|
|
||||||
|
# ── 워크플로 ──
|
||||||
|
def submit(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||||
|
return self._transition_owner(
|
||||||
|
item_id=item_id, owner=owner, from_status="작성중", to_status="제출"
|
||||||
|
)
|
||||||
|
|
||||||
|
def revert_to_draft(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||||
|
"""반려 또는 제출 상태에서 본인이 작성중으로 되돌림."""
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET status = '작성중',
|
||||||
|
reject_reason = NULL,
|
||||||
|
decided_at = NULL,
|
||||||
|
approver_email = NULL
|
||||||
|
WHERE id = %s AND owner = %s
|
||||||
|
AND status IN ('제출', '반려')
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(item_id, owner),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("작성중 으로 되돌릴 수 없는 상태입니다.")
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def approve(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||||
|
return self._transition_approver(
|
||||||
|
item_id=item_id,
|
||||||
|
approver_email=approver_email,
|
||||||
|
from_statuses=("제출",),
|
||||||
|
to_status="승인",
|
||||||
|
)
|
||||||
|
|
||||||
|
def reject(
|
||||||
|
self, *, item_id: str, approver_email: str, reason: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not reason.strip():
|
||||||
|
raise ValueError("반려 사유 필수")
|
||||||
|
approver_email = approver_email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET status = '반려',
|
||||||
|
approver_email = %s,
|
||||||
|
decided_at = now(),
|
||||||
|
reject_reason = %s
|
||||||
|
WHERE id = %s AND status = '제출'
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(approver_email, reason.strip(), item_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("제출 상태가 아니거나 항목 없음")
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def settle(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||||
|
return self._transition_approver(
|
||||||
|
item_id=item_id,
|
||||||
|
approver_email=approver_email,
|
||||||
|
from_statuses=("승인",),
|
||||||
|
to_status="정산완료",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _transition_owner(
|
||||||
|
self, *, item_id: str, owner: str, from_status: str, to_status: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET status = %s
|
||||||
|
WHERE id = %s AND owner = %s AND status = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(to_status, item_id, owner, from_status),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"전이 불가: {from_status} → {to_status}")
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
def _transition_approver(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
item_id: str,
|
||||||
|
approver_email: str,
|
||||||
|
from_statuses: tuple[str, ...],
|
||||||
|
to_status: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
approver_email = approver_email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expense_items
|
||||||
|
SET status = %s,
|
||||||
|
approver_email = %s,
|
||||||
|
decided_at = now()
|
||||||
|
WHERE id = %s AND status = ANY(%s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(to_status, approver_email, item_id, list(from_statuses)),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"전이 불가 → {to_status}")
|
||||||
|
return self._serialize(row)
|
||||||
|
|
||||||
|
# ── 승인자 대기열 ──
|
||||||
|
def list_pending_approval(self) -> list[dict[str, Any]]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM expense_items WHERE status = '제출' "
|
||||||
|
"ORDER BY created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def get_any(self, *, item_id: str) -> dict[str, Any] | None:
|
||||||
|
"""승인자/관리자용 — owner 무시하고 단일 조회."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM expense_items WHERE id = %s", (item_id,)
|
||||||
|
).fetchone()
|
||||||
|
return self._serialize(row) if row else None
|
||||||
|
|
||||||
|
# ── 첨부 ──
|
||||||
|
def add_attachment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
item_id: str,
|
||||||
|
owner: str,
|
||||||
|
kind: str,
|
||||||
|
filename: str,
|
||||||
|
stored_path: str,
|
||||||
|
content_type: str,
|
||||||
|
size_bytes: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if kind not in ("receipt", "other"):
|
||||||
|
raise ValueError("kind 는 receipt|other")
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
att_id = uuid.uuid4().hex[:12]
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO expense_attachments
|
||||||
|
(id, item_id, owner, kind, filename, stored_path,
|
||||||
|
content_type, size_bytes)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
att_id,
|
||||||
|
item_id,
|
||||||
|
owner,
|
||||||
|
kind,
|
||||||
|
filename,
|
||||||
|
stored_path,
|
||||||
|
content_type,
|
||||||
|
size_bytes,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return self._att_serialize(row)
|
||||||
|
|
||||||
|
def list_attachments(self, *, item_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM expense_attachments WHERE item_id = %s "
|
||||||
|
"ORDER BY uploaded_at ASC",
|
||||||
|
(item_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._att_serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def get_attachment(self, *, att_id: str) -> dict[str, Any] | None:
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM expense_attachments WHERE id = %s", (att_id,)
|
||||||
|
).fetchone()
|
||||||
|
return self._att_serialize(row) if row else None
|
||||||
|
|
||||||
|
def delete_attachment(self, *, att_id: str, owner: str) -> dict[str, Any]:
|
||||||
|
"""삭제된 행 반환 (파일 정리용 stored_path 포함)."""
|
||||||
|
owner = owner.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"DELETE FROM expense_attachments "
|
||||||
|
"WHERE id = %s AND owner = %s RETURNING *",
|
||||||
|
(att_id, owner),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise KeyError(att_id)
|
||||||
|
return self._att_serialize(row)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _att_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
v = out.get("uploaded_at")
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
out["uploaded_at"] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
out["size_bytes"] = int(out.get("size_bytes", 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ── 집계 (월별) ──
|
||||||
|
def monthly_summary(
|
||||||
|
self, *, email: str, year: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
email = email.lower().strip()
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT to_char(spent_at, 'YYYY-MM') AS month,
|
||||||
|
category,
|
||||||
|
COUNT(*) AS cnt,
|
||||||
|
COALESCE(SUM(amount), 0) AS total
|
||||||
|
FROM expense_items
|
||||||
|
WHERE owner = %s AND EXTRACT(YEAR FROM spent_at) = %s
|
||||||
|
GROUP BY 1, 2
|
||||||
|
ORDER BY 1, 2
|
||||||
|
""",
|
||||||
|
(email, year),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"month": r["month"],
|
||||||
|
"category": r["category"],
|
||||||
|
"count": int(r["cnt"]),
|
||||||
|
"total": int(r["total"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_for_export(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str | None,
|
||||||
|
date_from: str | None = None,
|
||||||
|
date_to: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""엑셀 내보내기용. email=None 이면 전체 (승인자/관리자용)."""
|
||||||
|
clauses = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if email:
|
||||||
|
clauses.append("owner = %s")
|
||||||
|
params.append(email.lower().strip())
|
||||||
|
if date_from:
|
||||||
|
clauses.append("spent_at >= %s")
|
||||||
|
params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
clauses.append("spent_at <= %s")
|
||||||
|
params.append(date_to)
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM expense_items {where} "
|
||||||
|
f"ORDER BY spent_at ASC, created_at ASC",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
# ── 요약 ──
|
# ── 요약 ──
|
||||||
def summary_for(self, email: str) -> dict[str, Any]:
|
def summary_for(self, email: str) -> dict[str, Any]:
|
||||||
email = email.lower().strip()
|
email = email.lower().strip()
|
||||||
|
|||||||
@@ -2,31 +2,77 @@
|
|||||||
|
|
||||||
- 경로: /expense
|
- 경로: /expense
|
||||||
- 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과).
|
- 권한: 로그인 + `expense` 모듈 권한 필요 (관리자는 항상 통과).
|
||||||
- 데이터: ExpenseStore (JSON). app.state.expense_store 에 연결.
|
- 데이터: ExpenseStore (JSON) 또는 ExpenseDBStore (PostgreSQL).
|
||||||
|
- 워크플로/첨부/집계/엑셀 내보내기는 DB 모드에서만 완전 지원.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import (
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
File,
|
||||||
|
Form,
|
||||||
|
HTTPException,
|
||||||
|
Request,
|
||||||
|
UploadFile,
|
||||||
|
)
|
||||||
|
from fastapi.responses import (
|
||||||
|
FileResponse,
|
||||||
|
HTMLResponse,
|
||||||
|
JSONResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
StreamingResponse,
|
||||||
|
)
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||||
|
|
||||||
router = APIRouter(prefix="/expense", tags=["expense"])
|
router = APIRouter(prefix="/expense", tags=["expense"])
|
||||||
|
|
||||||
|
# 첨부 파일 정책
|
||||||
|
ALLOWED_EXTS = {
|
||||||
|
".pdf", ".png", ".jpg", ".jpeg", ".webp", ".gif",
|
||||||
|
".heic", ".tif", ".tiff",
|
||||||
|
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||||
|
".hwp", ".hwpx", ".txt", ".csv", ".zip",
|
||||||
|
}
|
||||||
|
MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB
|
||||||
|
|
||||||
def _store(request: Request) -> ExpenseStore:
|
|
||||||
|
def _store(request: Request) -> Any:
|
||||||
store = getattr(request.app.state, "expense_store", None)
|
store = getattr(request.app.state, "expense_store", None)
|
||||||
if store is None:
|
if store is None:
|
||||||
raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.")
|
raise RuntimeError("ExpenseStore 가 app.state 에 등록되지 않았습니다.")
|
||||||
return store
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_dir(request: Request) -> Path:
|
||||||
|
"""첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관."""
|
||||||
|
base = getattr(request.app.state, "data_dir", None)
|
||||||
|
if base is None:
|
||||||
|
base = Path(os.getenv("DATA_DIR", "/data"))
|
||||||
|
root = Path(base) / "uploads" / "expense"
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str) -> str:
|
||||||
|
name = os.path.basename(name or "").strip() or "file"
|
||||||
|
name = re.sub(r"[\\/:*?\"<>|\r\n\t]", "_", name)
|
||||||
|
return name[:200]
|
||||||
|
|
||||||
|
|
||||||
def _require_user(request: Request) -> dict[str, Any]:
|
def _require_user(request: Request) -> dict[str, Any]:
|
||||||
# 순환 import 회피: 핸들러 호출 시점에 main 모듈에서 helper 를 가져온다.
|
|
||||||
from app.main import get_current_user_record # noqa: WPS433
|
from app.main import get_current_user_record # noqa: WPS433
|
||||||
from app.store import has_module # noqa: WPS433
|
from app.store import has_module # noqa: WPS433
|
||||||
|
|
||||||
@@ -38,6 +84,31 @@ def _require_user(request: Request) -> dict[str, Any]:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _require_approver(request: Request) -> dict[str, Any]:
|
||||||
|
from app.main import get_current_user_record # noqa: WPS433
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||||
|
if not (is_admin(user) or has_module(user, "expense_approver")):
|
||||||
|
raise HTTPException(status_code=403, detail="개인경비 승인자 권한이 필요합니다.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _require_db_store(store: Any) -> None:
|
||||||
|
"""워크플로/첨부/집계는 DB 모드 전용. JSON 폴백에서는 501."""
|
||||||
|
if not hasattr(store, "submit"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=501,
|
||||||
|
detail="이 기능은 expense_db (PostgreSQL) 모드에서만 지원됩니다. "
|
||||||
|
"EXPENSE_DB_URL 환경변수를 설정하세요.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# Pydantic 모델
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
class ExpensePayload(BaseModel):
|
class ExpensePayload(BaseModel):
|
||||||
spent_at: str = Field(..., description="YYYY-MM-DD")
|
spent_at: str = Field(..., description="YYYY-MM-DD")
|
||||||
category: str
|
category: str
|
||||||
@@ -48,6 +119,13 @@ class ExpensePayload(BaseModel):
|
|||||||
status: str | None = None
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RejectBody(BaseModel):
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 페이지
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def expense_index(request: Request) -> HTMLResponse:
|
async def expense_index(request: Request) -> HTMLResponse:
|
||||||
from app.main import ( # noqa: WPS433
|
from app.main import ( # noqa: WPS433
|
||||||
@@ -75,12 +153,19 @@ async def expense_index(request: Request) -> HTMLResponse:
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
summary = store.summary_for(user["email"])
|
summary = store.summary_for(user["email"])
|
||||||
|
pending_count = 0
|
||||||
|
is_approver = is_admin(user) or has_module(user, "expense_approver")
|
||||||
|
if is_approver and hasattr(store, "list_pending_approval"):
|
||||||
|
pending_count = len(store.list_pending_approval())
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
request,
|
request,
|
||||||
"expense/index.html",
|
"expense/index.html",
|
||||||
{
|
{
|
||||||
"user": user,
|
"user": user,
|
||||||
"is_admin": is_admin(user),
|
"is_admin": is_admin(user),
|
||||||
|
"is_approver": is_approver,
|
||||||
|
"pending_count": pending_count,
|
||||||
"items": items,
|
"items": items,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"categories": list(CATEGORIES),
|
"categories": list(CATEGORIES),
|
||||||
@@ -89,19 +174,110 @@ async def expense_index(request: Request) -> HTMLResponse:
|
|||||||
"nav_items": build_erp_nav(user, active="expense"),
|
"nav_items": build_erp_nav(user, active="expense"),
|
||||||
"page_title": "개인경비",
|
"page_title": "개인경비",
|
||||||
"page_subtitle": "법인카드 · 개인지출 · 정산 신청",
|
"page_subtitle": "법인카드 · 개인지출 · 정산 신청",
|
||||||
|
"supports_workflow": hasattr(store, "submit"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending", response_class=HTMLResponse)
|
||||||
|
async def pending_page(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 (is_admin(user) or has_module(user, "expense_approver")):
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": "개인경비 승인자 권한이 없습니다."},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
pending = store.list_pending_approval()
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"expense/pending.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"items": pending,
|
||||||
|
"nav_items": build_erp_nav(user, active="expense"),
|
||||||
|
"page_title": "개인경비 — 승인 대기",
|
||||||
|
"page_subtitle": f"제출 상태 {len(pending)}건",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports", response_class=HTMLResponse)
|
||||||
|
async def reports_page(request: Request) -> HTMLResponse:
|
||||||
|
"""월별/카테고리별 집계."""
|
||||||
|
from app.main import ( # noqa: WPS433
|
||||||
|
build_erp_nav,
|
||||||
|
get_current_user_record,
|
||||||
|
render_template,
|
||||||
|
)
|
||||||
|
from app.store import is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
year_str = request.query_params.get("year") or str(datetime.now().year)
|
||||||
|
try:
|
||||||
|
year = int(year_str)
|
||||||
|
except ValueError:
|
||||||
|
year = datetime.now().year
|
||||||
|
rows = store.monthly_summary(email=user["email"], year=year)
|
||||||
|
|
||||||
|
# 피벗: month → {category → total}
|
||||||
|
months: dict[str, dict[str, int]] = {}
|
||||||
|
for r in rows:
|
||||||
|
months.setdefault(r["month"], {})[r["category"]] = r["total"]
|
||||||
|
month_keys = sorted(months.keys())
|
||||||
|
cat_totals = {c: sum(months[m].get(c, 0) for m in month_keys) for c in CATEGORIES}
|
||||||
|
grand_total = sum(cat_totals.values())
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"expense/reports.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"year": year,
|
||||||
|
"months": month_keys,
|
||||||
|
"pivot": months,
|
||||||
|
"categories": list(CATEGORIES),
|
||||||
|
"cat_totals": cat_totals,
|
||||||
|
"grand_total": grand_total,
|
||||||
|
"nav_items": build_erp_nav(user, active="expense"),
|
||||||
|
"page_title": "개인경비 — 집계",
|
||||||
|
"page_subtitle": f"{year}년 월별/카테고리별",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 항목 CRUD API
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
@router.get("/api/items")
|
@router.get("/api/items")
|
||||||
async def list_items(
|
async def list_items(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: dict[str, Any] = Depends(_require_user),
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
"items": _store(request).list_for(user["email"]),
|
"items": store.list_for(user["email"]),
|
||||||
"summary": _store(request).summary_for(user["email"]),
|
"summary": store.summary_for(user["email"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,8 +299,17 @@ async def update_item(
|
|||||||
body: ExpensePayload,
|
body: ExpensePayload,
|
||||||
user: dict[str, Any] = Depends(_require_user),
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
|
# 워크플로 모드: 작성중/반려 상태에서만 수정 가능
|
||||||
|
if hasattr(store, "get"):
|
||||||
|
current = store.get(item_id=item_id, owner=user["email"])
|
||||||
|
if current and current.get("status") not in ("작성중", "반려"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"{current['status']} 상태에서는 수정할 수 없습니다.",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
item = _store(request).update(
|
item = store.update(
|
||||||
item_id=item_id, owner=user["email"], payload=body.model_dump()
|
item_id=item_id, owner=user["email"], payload=body.model_dump()
|
||||||
)
|
)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -138,6 +323,14 @@ async def delete_item(
|
|||||||
item_id: str,
|
item_id: str,
|
||||||
user: dict[str, Any] = Depends(_require_user),
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
|
if hasattr(store, "get"):
|
||||||
|
current = store.get(item_id=item_id, owner=user["email"])
|
||||||
|
if current and current.get("status") not in ("작성중", "반려"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"{current['status']} 상태에서는 삭제할 수 없습니다.",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
_store(request).delete(item_id=item_id, owner=user["email"])
|
_store(request).delete(item_id=item_id, owner=user["email"])
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -145,6 +338,309 @@ async def delete_item(
|
|||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 워크플로 API
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
@router.post("/api/items/{item_id}/submit")
|
||||||
|
async def submit_item(
|
||||||
|
request: Request,
|
||||||
|
item_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
try:
|
||||||
|
item = store.submit(item_id=item_id, owner=user["email"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return JSONResponse({"item": item})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/items/{item_id}/revert")
|
||||||
|
async def revert_item(
|
||||||
|
request: Request,
|
||||||
|
item_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
try:
|
||||||
|
item = store.revert_to_draft(item_id=item_id, owner=user["email"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return JSONResponse({"item": item})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/items/{item_id}/approve")
|
||||||
|
async def approve_item(
|
||||||
|
request: Request,
|
||||||
|
item_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_approver),
|
||||||
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
try:
|
||||||
|
item = store.approve(item_id=item_id, approver_email=user["email"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return JSONResponse({"item": item})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/items/{item_id}/reject")
|
||||||
|
async def reject_item(
|
||||||
|
request: Request,
|
||||||
|
item_id: str,
|
||||||
|
body: RejectBody,
|
||||||
|
user: dict[str, Any] = Depends(_require_approver),
|
||||||
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
try:
|
||||||
|
item = store.reject(
|
||||||
|
item_id=item_id, approver_email=user["email"], reason=body.reason
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return JSONResponse({"item": item})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/items/{item_id}/settle")
|
||||||
|
async def settle_item(
|
||||||
|
request: Request,
|
||||||
|
item_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_approver),
|
||||||
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
try:
|
||||||
|
item = store.settle(item_id=item_id, approver_email=user["email"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return JSONResponse({"item": item})
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 첨부 API
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
@router.get("/api/items/{item_id}/attachments")
|
||||||
|
async def list_attachments(
|
||||||
|
request: Request,
|
||||||
|
item_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> JSONResponse:
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
item = store.get_any(item_id=item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="항목 없음")
|
||||||
|
# 본인 또는 승인자/관리자만 조회
|
||||||
|
if item["owner"] != user["email"] and not (
|
||||||
|
is_admin(user) or has_module(user, "expense_approver")
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="조회 권한 없음")
|
||||||
|
return JSONResponse({"attachments": store.list_attachments(item_id=item_id)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/items/{item_id}/attachments")
|
||||||
|
async def upload_attachment(
|
||||||
|
request: Request,
|
||||||
|
item_id: str,
|
||||||
|
kind: str = Form("receipt"),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
|
||||||
|
if kind not in ("receipt", "other"):
|
||||||
|
raise HTTPException(status_code=400, detail="kind 는 receipt|other")
|
||||||
|
|
||||||
|
item = store.get(item_id=item_id, owner=user["email"])
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="항목 없음")
|
||||||
|
if item["status"] not in ("작성중", "반려"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"{item['status']} 상태에서는 첨부할 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_name = _safe_filename(file.filename or "")
|
||||||
|
ext = os.path.splitext(raw_name)[1].lower()
|
||||||
|
if ext and ext not in ALLOWED_EXTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail=f"허용되지 않는 확장자: {ext}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 디스크에 저장 (스트리밍, 사이즈 제한)
|
||||||
|
att_id = uuid.uuid4().hex[:12]
|
||||||
|
sub_dir = _upload_dir(request) / item_id
|
||||||
|
sub_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stored_name = f"{att_id}_{raw_name}"
|
||||||
|
stored_path = sub_dir / stored_name
|
||||||
|
|
||||||
|
size_total = 0
|
||||||
|
chunk_size = 1024 * 256
|
||||||
|
try:
|
||||||
|
with stored_path.open("wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size_total += len(chunk)
|
||||||
|
if size_total > MAX_UPLOAD_BYTES:
|
||||||
|
out.close()
|
||||||
|
stored_path.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"파일 크기 초과 (최대 {MAX_UPLOAD_BYTES // (1024*1024)}MB)",
|
||||||
|
)
|
||||||
|
out.write(chunk)
|
||||||
|
finally:
|
||||||
|
await file.close()
|
||||||
|
|
||||||
|
content_type = (
|
||||||
|
file.content_type
|
||||||
|
or mimetypes.guess_type(raw_name)[0]
|
||||||
|
or "application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
rec = store.add_attachment(
|
||||||
|
item_id=item_id,
|
||||||
|
owner=user["email"],
|
||||||
|
kind=kind,
|
||||||
|
filename=raw_name,
|
||||||
|
stored_path=str(stored_path),
|
||||||
|
content_type=content_type,
|
||||||
|
size_bytes=size_total,
|
||||||
|
)
|
||||||
|
return JSONResponse({"attachment": rec}, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/attachments/{att_id}")
|
||||||
|
async def download_attachment(
|
||||||
|
request: Request,
|
||||||
|
att_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> FileResponse:
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
rec = store.get_attachment(att_id=att_id)
|
||||||
|
if not rec:
|
||||||
|
raise HTTPException(status_code=404, detail="첨부 없음")
|
||||||
|
item = store.get_any(item_id=rec["item_id"])
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="항목 없음")
|
||||||
|
if item["owner"] != user["email"] and not (
|
||||||
|
is_admin(user) or has_module(user, "expense_approver")
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="조회 권한 없음")
|
||||||
|
|
||||||
|
path = Path(rec["stored_path"])
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=410, detail="파일이 사라졌습니다.")
|
||||||
|
return FileResponse(
|
||||||
|
path=str(path),
|
||||||
|
filename=rec["filename"],
|
||||||
|
media_type=rec["content_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/attachments/{att_id}")
|
||||||
|
async def delete_attachment(
|
||||||
|
request: Request,
|
||||||
|
att_id: str,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> JSONResponse:
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
try:
|
||||||
|
rec = store.delete_attachment(att_id=att_id, owner=user["email"])
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="첨부 없음")
|
||||||
|
# 파일 정리 (실패해도 DB 삭제는 유효 — 고아 파일 남을 수 있으니 로그만)
|
||||||
|
try:
|
||||||
|
Path(rec["stored_path"]).unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 엑셀 내보내기
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
@router.get("/api/export.xlsx")
|
||||||
|
async def export_xlsx(
|
||||||
|
request: Request,
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
from openpyxl import Workbook # 지연 import
|
||||||
|
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
|
||||||
|
date_from = request.query_params.get("from")
|
||||||
|
date_to = request.query_params.get("to")
|
||||||
|
scope = request.query_params.get("scope", "mine")
|
||||||
|
|
||||||
|
target_email: str | None = user["email"]
|
||||||
|
if scope == "all":
|
||||||
|
if not (is_admin(user) or has_module(user, "expense_approver")):
|
||||||
|
raise HTTPException(status_code=403, detail="전체 내보내기 권한 없음")
|
||||||
|
target_email = None
|
||||||
|
|
||||||
|
rows = store.list_for_export(
|
||||||
|
email=target_email, date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "expense"
|
||||||
|
header = [
|
||||||
|
"사용일", "소유자", "분류", "결제수단", "가맹점",
|
||||||
|
"금액", "메모", "상태", "승인자", "결재일시", "반려사유",
|
||||||
|
"생성일시", "수정일시",
|
||||||
|
]
|
||||||
|
ws.append(header)
|
||||||
|
for r in rows:
|
||||||
|
ws.append([
|
||||||
|
r.get("spent_at", ""),
|
||||||
|
r.get("owner", ""),
|
||||||
|
r.get("category", ""),
|
||||||
|
r.get("method", ""),
|
||||||
|
r.get("merchant", ""),
|
||||||
|
int(r.get("amount", 0)),
|
||||||
|
r.get("memo", ""),
|
||||||
|
r.get("status", ""),
|
||||||
|
r.get("approver_email", "") or "",
|
||||||
|
r.get("decided_at", "") or "",
|
||||||
|
r.get("reject_reason", "") or "",
|
||||||
|
r.get("created_at", ""),
|
||||||
|
r.get("updated_at", ""),
|
||||||
|
])
|
||||||
|
# 컬럼 너비 대충
|
||||||
|
widths = [12, 28, 10, 12, 24, 12, 30, 10, 24, 22, 24, 22, 22]
|
||||||
|
for col, w in enumerate(widths, start=1):
|
||||||
|
ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
fname_scope = "all" if target_email is None else "mine"
|
||||||
|
fname = f"expense_{fname_scope}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||||
|
return StreamingResponse(
|
||||||
|
buf,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
return {"status": "ok", "module": "expense"}
|
return {"status": "ok", "module": "expense"}
|
||||||
|
|||||||
@@ -3,6 +3,20 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="erp-expense">
|
<section class="erp-expense">
|
||||||
|
|
||||||
|
<!-- ── 페이지 액션 ── -->
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/reports">월별 집계</a>
|
||||||
|
{% if is_approver %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/pending">
|
||||||
|
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
|
||||||
|
{% if is_approver %}
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── 요약 카드 ── -->
|
<!-- ── 요약 카드 ── -->
|
||||||
<div class="erp-summary-grid">
|
<div class="erp-summary-grid">
|
||||||
<div class="erp-summary-card">
|
<div class="erp-summary-card">
|
||||||
@@ -29,36 +43,28 @@
|
|||||||
<div class="erp-card-block">
|
<div class="erp-card-block">
|
||||||
<div class="erp-card-block-head">
|
<div class="erp-card-block-head">
|
||||||
<h2>경비 등록</h2>
|
<h2>경비 등록</h2>
|
||||||
<span class="erp-muted">필수 항목을 입력하고 등록을 누르세요.</span>
|
<span class="erp-muted">필수 항목 입력 후 등록. 등록 후 항목별로 첨부/제출.</span>
|
||||||
</div>
|
</div>
|
||||||
<form id="ex-form" class="erp-form-grid">
|
<form id="ex-form" class="erp-form-grid">
|
||||||
<input type="hidden" name="id" />
|
<input type="hidden" name="id" />
|
||||||
<label class="erp-field">
|
<label class="erp-field"><span>사용일</span><input type="date" name="spent_at" required /></label>
|
||||||
<span>사용일</span>
|
<label class="erp-field"><span>분류</span>
|
||||||
<input type="date" name="spent_at" required />
|
|
||||||
</label>
|
|
||||||
<label class="erp-field">
|
|
||||||
<span>분류</span>
|
|
||||||
<select name="category" required>
|
<select name="category" required>
|
||||||
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field">
|
<label class="erp-field"><span>결제수단</span>
|
||||||
<span>결제수단</span>
|
|
||||||
<select name="method" required>
|
<select name="method" required>
|
||||||
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
|
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field">
|
<label class="erp-field"><span>금액</span>
|
||||||
<span>금액</span>
|
|
||||||
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field erp-field-wide">
|
<label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
|
||||||
<span>가맹점/사용처</span>
|
|
||||||
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
|
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" />
|
||||||
</label>
|
</label>
|
||||||
<label class="erp-field erp-field-wide">
|
<label class="erp-field erp-field-wide"><span>메모</span>
|
||||||
<span>메모</span>
|
|
||||||
<input type="text" name="memo" placeholder="비고" />
|
<input type="text" name="memo" placeholder="비고" />
|
||||||
</label>
|
</label>
|
||||||
<div class="erp-form-actions">
|
<div class="erp-form-actions">
|
||||||
@@ -78,36 +84,93 @@
|
|||||||
<table class="erp-table" id="ex-table">
|
<table class="erp-table" id="ex-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 28px;"></th>
|
||||||
<th style="width: 110px;">사용일</th>
|
<th style="width: 110px;">사용일</th>
|
||||||
<th style="width: 90px;">분류</th>
|
<th style="width: 90px;">분류</th>
|
||||||
<th style="width: 100px;">수단</th>
|
<th style="width: 100px;">수단</th>
|
||||||
<th>가맹점</th>
|
<th>가맹점</th>
|
||||||
<th style="width: 120px; text-align: right;">금액</th>
|
<th style="width: 120px; text-align: right;">금액</th>
|
||||||
<th style="width: 90px;">상태</th>
|
<th style="width: 100px;">상태</th>
|
||||||
<th style="width: 130px; text-align: right;">동작</th>
|
<th style="width: 220px; text-align: right;">동작</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="ex-tbody">
|
<tbody id="ex-tbody">
|
||||||
{% for it in items %}
|
{% for it in items %}
|
||||||
<tr data-id="{{ it.id }}">
|
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
|
||||||
|
<td>
|
||||||
|
{% if supports_workflow %}
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-toggle" title="첨부/상세">▸</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ it.spent_at }}</td>
|
<td>{{ it.spent_at }}</td>
|
||||||
<td>{{ it.category }}</td>
|
<td>{{ it.category }}</td>
|
||||||
<td>{{ it.method }}</td>
|
<td>{{ it.method }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div>{{ it.merchant }}</div>
|
<div>{{ it.merchant }}</div>
|
||||||
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||||
|
{% if it.reject_reason %}
|
||||||
|
<div class="erp-row-sub" style="color: var(--color-callout-red);">반려: {{ it.reject_reason }}</div>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
{{ "{:,}".format(it.amount) }} 원
|
{{ "{:,}".format(it.amount) }} 원
|
||||||
</td>
|
</td>
|
||||||
<td><span class="erp-badge erp-badge-neutral">{{ it.status }}</span></td>
|
<td>
|
||||||
<td style="text-align: right;">
|
{% if it.status == '작성중' or it.status == '반려' %}
|
||||||
|
<span class="erp-badge erp-badge-outline">{{ it.status }}</span>
|
||||||
|
{% elif it.status == '제출' %}
|
||||||
|
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||||
|
{% elif it.status == '승인' %}
|
||||||
|
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right;" class="js-actions">
|
||||||
|
{% if it.status in ('작성중', '반려') %}
|
||||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
<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>
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
||||||
|
{% if supports_workflow %}
|
||||||
|
<button class="erp-btn erp-btn-primary erp-btn-sm js-submit">제출</button>
|
||||||
|
{% endif %}
|
||||||
|
{% elif it.status == '제출' and supports_workflow %}
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if supports_workflow %}
|
||||||
|
<tr class="ex-detail" data-detail-for="{{ it.id }}" hidden>
|
||||||
|
<td colspan="8">
|
||||||
|
<div class="ex-detail-box">
|
||||||
|
<div class="ex-attach-head">
|
||||||
|
<strong>첨부파일</strong>
|
||||||
|
{% if it.status in ('작성중', '반려') %}
|
||||||
|
<div class="ex-upload">
|
||||||
|
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||||
|
영수증 추가
|
||||||
|
<input type="file" class="js-upload" data-kind="receipt" hidden />
|
||||||
|
</label>
|
||||||
|
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||||
|
기타파일 추가
|
||||||
|
<input type="file" class="js-upload" data-kind="other" hidden />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<ul class="ex-attach-list" data-list="{{ it.id }}">
|
||||||
|
<li class="erp-muted">불러오는 중…</li>
|
||||||
|
</ul>
|
||||||
|
{% if it.approver_email %}
|
||||||
|
<div class="erp-muted" style="margin-top: 6px;">
|
||||||
|
결재: {{ it.approver_email }} · {{ (it.decided_at or '')[:16].replace('T',' ') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr id="ex-empty"><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
<tr id="ex-empty"><td colspan="8" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -115,11 +178,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions {
|
||||||
|
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ex-detail-box {
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
padding: var(--sp-12) var(--sp-16);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.ex-attach-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-8);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.ex-upload { display: flex; gap: 6px; }
|
||||||
|
.ex-attach-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.ex-attach-list li {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
|
||||||
|
background: var(--color-canvas-white); border: 1px solid var(--color-subtle-ash); border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ex-attach-list li .ex-kind {
|
||||||
|
padding: 1px 6px; border-radius: 8px; font-size: 11px;
|
||||||
|
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||||
|
}
|
||||||
|
.ex-attach-list li .ex-name { flex: 1; }
|
||||||
|
.ex-attach-list li .ex-size { color: var(--color-midtone-gray); font-size: 12px; }
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
const supportsWorkflow = {{ 'true' if supports_workflow else 'false' }};
|
||||||
const form = document.getElementById("ex-form");
|
const form = document.getElementById("ex-form");
|
||||||
const submitBtn = document.getElementById("ex-submit");
|
const submitBtn = document.getElementById("ex-submit");
|
||||||
const resetBtn = document.getElementById("ex-reset");
|
const resetBtn = document.getElementById("ex-reset");
|
||||||
@@ -128,6 +220,12 @@
|
|||||||
const totalEl = document.getElementById("ex-total-amount");
|
const totalEl = document.getElementById("ex-total-amount");
|
||||||
|
|
||||||
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")} 원`;
|
const fmt = (n) => `${Number(n || 0).toLocaleString("ko-KR")} 원`;
|
||||||
|
const fmtSize = (b) => {
|
||||||
|
b = Number(b || 0);
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
if (b < 1024*1024) return `${(b/1024).toFixed(1)} KB`;
|
||||||
|
return `${(b/1024/1024).toFixed(2)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
function setEditing(id, data) {
|
function setEditing(id, data) {
|
||||||
form.id.value = id || "";
|
form.id.value = id || "";
|
||||||
@@ -143,7 +241,6 @@
|
|||||||
submitBtn.textContent = "등록";
|
submitBtn.textContent = "등록";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetBtn.addEventListener("click", () => setEditing("", null));
|
resetBtn.addEventListener("click", () => setEditing("", null));
|
||||||
|
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
@@ -165,70 +262,119 @@
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||||
await reload();
|
location.reload();
|
||||||
form.reset();
|
|
||||||
setEditing("", null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`저장 실패: ${err.message || err}`);
|
alert(`저장 실패: ${err.message || err}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function postAction(id, action, body) {
|
||||||
|
const res = await fetch(`/expense/api/items/${id}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAttachments(itemId) {
|
||||||
|
const ul = tbody.querySelector(`.ex-attach-list[data-list="${itemId}"]`);
|
||||||
|
if (!ul) return;
|
||||||
|
ul.innerHTML = `<li class="erp-muted">불러오는 중…</li>`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/expense/api/items/${itemId}/attachments`);
|
||||||
|
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||||
|
const { attachments } = await res.json();
|
||||||
|
if (!attachments.length) {
|
||||||
|
ul.innerHTML = `<li class="erp-muted">첨부 없음</li>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ul.innerHTML = "";
|
||||||
|
attachments.forEach((a) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.innerHTML = `
|
||||||
|
<span class="ex-kind">${a.kind === 'receipt' ? '영수증' : '기타'}</span>
|
||||||
|
<a class="ex-name" href="/expense/api/attachments/${a.id}" target="_blank">${a.filename}</a>
|
||||||
|
<span class="ex-size">${fmtSize(a.size_bytes)}</span>
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-del-att" data-att="${a.id}">삭제</button>
|
||||||
|
`;
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ul.innerHTML = `<li style="color: var(--color-callout-red);">로드 실패: ${err.message || err}</li>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tbody.addEventListener("click", async (e) => {
|
tbody.addEventListener("click", async (e) => {
|
||||||
const btn = e.target.closest("button");
|
const btn = e.target.closest("button");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const tr = btn.closest("tr");
|
const tr = btn.closest("tr[data-id]");
|
||||||
const id = tr && tr.dataset.id;
|
if (!tr) return;
|
||||||
if (!id) return;
|
const id = tr.dataset.id;
|
||||||
if (btn.classList.contains("js-delete")) {
|
|
||||||
if (!confirm("이 항목을 삭제할까요?")) return;
|
if (btn.classList.contains("js-toggle")) {
|
||||||
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
const detail = tbody.querySelector(`tr.ex-detail[data-detail-for="${id}"]`);
|
||||||
if (res.ok) reload(); else alert("삭제 실패");
|
if (!detail) return;
|
||||||
|
const opening = detail.hasAttribute("hidden");
|
||||||
|
if (opening) {
|
||||||
|
detail.removeAttribute("hidden");
|
||||||
|
btn.textContent = "▾";
|
||||||
|
loadAttachments(id);
|
||||||
|
} else {
|
||||||
|
detail.setAttribute("hidden", "");
|
||||||
|
btn.textContent = "▸";
|
||||||
|
}
|
||||||
} else if (btn.classList.contains("js-edit")) {
|
} else if (btn.classList.contains("js-edit")) {
|
||||||
const res = await fetch(`/expense/api/items`);
|
const res = await fetch(`/expense/api/items`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const j = await res.json();
|
const { items } = await res.json();
|
||||||
const found = (j.items || []).find((x) => x.id === id);
|
const found = items.find((x) => x.id === id);
|
||||||
if (found) setEditing(id, found);
|
if (found) setEditing(id, found);
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
} else if (btn.classList.contains("js-delete")) {
|
||||||
|
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
|
||||||
|
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패");
|
||||||
|
} else if (btn.classList.contains("js-submit")) {
|
||||||
|
if (!confirm("결재 제출할까요? 제출 후에는 수정할 수 없습니다.")) return;
|
||||||
|
try { await postAction(id, "submit"); location.reload(); }
|
||||||
|
catch (err) { alert(`제출 실패: ${err.message || err}`); }
|
||||||
|
} else if (btn.classList.contains("js-revert")) {
|
||||||
|
if (!confirm("작성중 상태로 되돌릴까요?")) return;
|
||||||
|
try { await postAction(id, "revert"); location.reload(); }
|
||||||
|
catch (err) { alert(`취소 실패: ${err.message || err}`); }
|
||||||
|
} else if (btn.classList.contains("js-del-att")) {
|
||||||
|
if (!confirm("첨부를 삭제할까요?")) return;
|
||||||
|
const attId = btn.dataset.att;
|
||||||
|
const res = await fetch(`/expense/api/attachments/${attId}`, { method: "DELETE" });
|
||||||
|
if (res.ok) loadAttachments(id); else alert((await res.json()).detail || "삭제 실패");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function reload() {
|
// 첨부 업로드
|
||||||
const res = await fetch(`/expense/api/items`);
|
tbody.addEventListener("change", async (e) => {
|
||||||
if (!res.ok) return;
|
const input = e.target.closest("input.js-upload");
|
||||||
const j = await res.json();
|
if (!input || !input.files.length) return;
|
||||||
const items = j.items || [];
|
const tr = input.closest("tr.ex-detail");
|
||||||
const summary = j.summary || { count: 0, total: 0 };
|
if (!tr) return;
|
||||||
countEl.textContent = `${items.length}건`;
|
const itemId = tr.dataset.detailFor;
|
||||||
if (totalEl) totalEl.textContent = fmt(summary.total);
|
const fd = new FormData();
|
||||||
|
fd.append("file", input.files[0]);
|
||||||
tbody.innerHTML = "";
|
fd.append("kind", input.dataset.kind || "other");
|
||||||
if (items.length === 0) {
|
try {
|
||||||
tbody.innerHTML = `<tr><td colspan="7" class="erp-empty">등록된 경비가 없습니다.</td></tr>`;
|
const res = await fetch(`/expense/api/items/${itemId}/attachments`, {
|
||||||
return;
|
method: "POST", body: fd,
|
||||||
}
|
|
||||||
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 (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||||
|
input.value = "";
|
||||||
|
loadAttachments(itemId);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`업로드 실패: ${err.message || err}`);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 사용일 기본값 = 오늘
|
|
||||||
if (!form.spent_at.value) {
|
if (!form.spent_at.value) {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-pending">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>승인 대기 ({{ items | length }}건)</h2>
|
||||||
|
<span class="erp-muted">제출 상태 항목 — 승인/반려 처리</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 110px;">사용일</th>
|
||||||
|
<th style="width: 200px;">소유자</th>
|
||||||
|
<th style="width: 90px;">분류</th>
|
||||||
|
<th>가맹점/메모</th>
|
||||||
|
<th style="width: 120px; text-align: right;">금액</th>
|
||||||
|
<th style="width: 260px; text-align: right;">결재</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pend-tbody">
|
||||||
|
{% for it in items %}
|
||||||
|
<tr data-id="{{ it.id }}">
|
||||||
|
<td>{{ it.spent_at }}</td>
|
||||||
|
<td>{{ it.owner }}</td>
|
||||||
|
<td>{{ it.category }}</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 style="text-align: right;">
|
||||||
|
<a class="erp-btn erp-btn-ghost erp-btn-sm" href="/expense/api/items/{{ it.id }}/attachments" target="_blank">첨부</a>
|
||||||
|
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</button>
|
||||||
|
<button class="erp-btn erp-btn-primary erp-btn-sm js-approve">승인</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="erp-empty">대기 항목이 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.getElementById("pend-tbody").addEventListener("click", async (e) => {
|
||||||
|
const btn = e.target.closest("button");
|
||||||
|
if (!btn) return;
|
||||||
|
const tr = btn.closest("tr[data-id]");
|
||||||
|
const id = tr.dataset.id;
|
||||||
|
|
||||||
|
if (btn.classList.contains("js-approve")) {
|
||||||
|
if (!confirm("승인할까요?")) return;
|
||||||
|
const res = await fetch(`/expense/api/items/${id}/approve`, { method: "POST" });
|
||||||
|
if (res.ok) { tr.remove(); }
|
||||||
|
else { alert((await res.json()).detail || "승인 실패"); }
|
||||||
|
} else if (btn.classList.contains("js-reject")) {
|
||||||
|
const reason = prompt("반려 사유를 입력하세요:");
|
||||||
|
if (!reason || !reason.trim()) return;
|
||||||
|
const res = await fetch(`/expense/api/items/${id}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reason: reason.trim() }),
|
||||||
|
});
|
||||||
|
if (res.ok) { tr.remove(); }
|
||||||
|
else { alert((await res.json()).detail || "반려 실패"); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-reports">
|
||||||
|
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||||
|
<form method="get" style="display: inline-flex; gap: 6px; align-items: center;">
|
||||||
|
<label class="erp-muted">연도</label>
|
||||||
|
<input type="number" name="year" value="{{ year }}" min="2000" max="2100"
|
||||||
|
style="width: 100px; padding: 6px 10px; border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-input); font-family: inherit;" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||||
|
</form>
|
||||||
|
<a class="erp-btn erp-btn-outline"
|
||||||
|
href="/expense/api/export.xlsx?from={{ year }}-01-01&to={{ year }}-12-31">엑셀</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>{{ year }}년 월별 / 카테고리별 합계</h2>
|
||||||
|
<span class="erp-muted">총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 110px;">월</th>
|
||||||
|
{% for c in categories %}
|
||||||
|
<th style="text-align: right;">{{ c }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th style="text-align: right; background: var(--color-ghost-gray);">합계</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in months %}
|
||||||
|
{% set row_total = pivot[m].values() | sum %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ m }}</strong></td>
|
||||||
|
{% for c in categories %}
|
||||||
|
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
|
{% if pivot[m].get(c) %}{{ "{:,}".format(pivot[m][c]) }}{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td style="text-align: right; font-variant-numeric: tabular-nums; background: var(--color-ghost-gray);">
|
||||||
|
<strong>{{ "{:,}".format(row_total) }}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="{{ categories | length + 2 }}" class="erp-empty">데이터 없음</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% if months %}
|
||||||
|
<tfoot>
|
||||||
|
<tr style="background: var(--color-ghost-gray);">
|
||||||
|
<th>합계</th>
|
||||||
|
{% for c in categories %}
|
||||||
|
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
|
{{ "{:,}".format(cat_totals[c]) }}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||||
|
{{ "{:,}".format(grand_total) }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); align-items: center; flex-wrap: wrap; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
+56
-2
@@ -17,8 +17,21 @@ from typing import Any, Iterable
|
|||||||
# 슈퍼 관리자 — 강등/삭제 불가
|
# 슈퍼 관리자 — 강등/삭제 불가
|
||||||
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
|
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
|
||||||
|
|
||||||
# 시스템에서 지원하는 모듈 키 — 신규 추가시 여기 + 메뉴/템플릿 동시 갱신
|
# 시스템에서 지원하는 권한 키.
|
||||||
MODULE_KEYS: tuple[str, ...] = ("corm", "order", "expense", "vacation")
|
# - 접근권한: 모듈 페이지 진입 허용
|
||||||
|
# - 승인자: 결재 워크플로에서 승인/반려 가능
|
||||||
|
# 신규 추가 시 여기 + admin.html MODULE_LABELS + 라우터 검사 동시 갱신.
|
||||||
|
MODULE_KEYS: tuple[str, ...] = (
|
||||||
|
"corm",
|
||||||
|
"order",
|
||||||
|
"expense",
|
||||||
|
"vacation",
|
||||||
|
"expense_approver",
|
||||||
|
"vacation_approver",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 승인자 권한 키 — 결재 워크플로에서 별도 검사할 때 사용
|
||||||
|
APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
@@ -88,6 +101,42 @@ class UserStore:
|
|||||||
data = self._read()
|
data = self._read()
|
||||||
return [self._enrich(email, rec) for email, rec in data["users"].items()]
|
return [self._enrich(email, rec) for email, rec in data["users"].items()]
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
name: str = "",
|
||||||
|
role: str = "user",
|
||||||
|
modules: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""관리자가 이메일만으로 신규 사용자 등록. 로그인 전이라도 권한 부여 가능."""
|
||||||
|
email = email.lower().strip()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
raise ValueError("올바른 이메일 형식이 아닙니다.")
|
||||||
|
if role not in ("admin", "user"):
|
||||||
|
raise ValueError(f"role 값이 잘못되었습니다: {role}")
|
||||||
|
is_super = email == SUPER_ADMIN_EMAIL
|
||||||
|
with self._lock:
|
||||||
|
data = self._read()
|
||||||
|
if email in data["users"]:
|
||||||
|
raise ValueError(f"이미 등록된 사용자입니다: {email}")
|
||||||
|
now = _now_iso()
|
||||||
|
final_role = "admin" if is_super else role
|
||||||
|
rec = {
|
||||||
|
"email": email,
|
||||||
|
"name": name or email.split("@")[0],
|
||||||
|
"picture": "",
|
||||||
|
"role": final_role,
|
||||||
|
"modules": self._normalize_modules(
|
||||||
|
modules, is_admin=(final_role == "admin")
|
||||||
|
),
|
||||||
|
"created_at": now,
|
||||||
|
"last_login": "",
|
||||||
|
}
|
||||||
|
data["users"][email] = rec
|
||||||
|
self._write_atomic(data)
|
||||||
|
return self._enrich(email, rec)
|
||||||
|
|
||||||
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
|
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
|
||||||
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
|
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
|
||||||
email = email.lower().strip()
|
email = email.lower().strip()
|
||||||
@@ -182,5 +231,10 @@ def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
|
|||||||
return bool(mods.get(module))
|
return bool(mods.get(module))
|
||||||
|
|
||||||
|
|
||||||
|
def is_approver(user_rec: dict[str, Any] | None, kind: str) -> bool:
|
||||||
|
"""결재 승인자 권한 검사. kind 예: 'expense', 'vacation'."""
|
||||||
|
return has_module(user_rec, f"{kind}_approver")
|
||||||
|
|
||||||
|
|
||||||
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
|
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
|
||||||
return {m for m in MODULE_KEYS if has_module(user_rec, m)}
|
return {m for m in MODULE_KEYS if has_module(user_rec, m)}
|
||||||
|
|||||||
+109
-39
@@ -5,12 +5,36 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>사용자/권한 관리 — DBX ERP</title>
|
<title>사용자/권한 관리 — DBX ERP</title>
|
||||||
<link rel="stylesheet" href="/static/erp.css" />
|
<link rel="stylesheet" href="/static/erp.css" />
|
||||||
|
<style>
|
||||||
|
.erp-admin-toolbar {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-12);
|
||||||
|
margin: var(--sp-16) 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.erp-admin-toolbar .erp-input { flex: 0 1 280px; }
|
||||||
|
|
||||||
|
.erp-add-form {
|
||||||
|
display: flex; gap: var(--sp-8); align-items: center;
|
||||||
|
margin-left: auto; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.erp-add-form input, .erp-add-form select {
|
||||||
|
border: 1px solid var(--color-subtle-ash);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
padding: 6px 10px; font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erp-mod-th { text-align: center; font-size: 11px; padding: 6px 4px; }
|
||||||
|
.erp-mod-cell { text-align: center; padding: 4px; }
|
||||||
|
.erp-mod-group { display: inline-block; padding: 2px 6px; border-radius: 6px;
|
||||||
|
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||||
|
font-size: 10px; font-weight: 500; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="erp-body">
|
<body class="erp-body">
|
||||||
|
|
||||||
<div class="erp-shell">
|
<div class="erp-shell">
|
||||||
|
|
||||||
<!-- ── 상단 네비게이션 ── -->
|
|
||||||
<nav class="erp-nav">
|
<nav class="erp-nav">
|
||||||
<div class="erp-nav-left">
|
<div class="erp-nav-left">
|
||||||
<a href="/" class="erp-brand">
|
<a href="/" class="erp-brand">
|
||||||
@@ -19,7 +43,6 @@
|
|||||||
<span class="erp-brand-system">ERP 시스템</span>
|
<span class="erp-brand-system">ERP 시스템</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="erp-nav-right">
|
<div class="erp-nav-right">
|
||||||
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
|
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
|
||||||
<div class="erp-user-chip">
|
<div class="erp-user-chip">
|
||||||
@@ -37,39 +60,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="erp-main" style="max-width: 1100px;">
|
<main class="erp-main" style="max-width: 1280px;">
|
||||||
|
|
||||||
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
|
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
|
||||||
<p class="erp-greeting-sub">
|
<p class="erp-greeting-sub">
|
||||||
회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다.
|
이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사).
|
||||||
토글을 변경한 뒤 <strong>저장</strong> 버튼을 누르세요.
|
토글을 변경한 뒤 <strong>저장</strong>을 누르세요.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- 이메일 등록 폼 + 검색 -->
|
||||||
<div class="erp-admin-toolbar">
|
<div class="erp-admin-toolbar">
|
||||||
<input type="search"
|
<input type="search" id="user-search" class="erp-input"
|
||||||
id="user-search"
|
placeholder="이메일/이름 검색…" style="padding: 6px 10px;" />
|
||||||
class="erp-input"
|
|
||||||
placeholder="이메일 또는 이름으로 검색…" />
|
|
||||||
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
|
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
|
||||||
|
|
||||||
|
<form id="add-user-form" class="erp-add-form">
|
||||||
|
<input type="email" name="email" required placeholder="email@dbxcorp.co.kr" style="width: 220px;" />
|
||||||
|
<input type="text" name="name" placeholder="이름(선택)" style="width: 120px;" />
|
||||||
|
<select name="role">
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary">사용자 추가</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="erp-table-wrap">
|
<div class="erp-table-wrap">
|
||||||
<table class="erp-table" id="users-table">
|
<table class="erp-table" id="users-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 28%;">사용자</th>
|
<th style="width: 22%;">사용자</th>
|
||||||
<th style="width: 11%;">역할</th>
|
<th style="width: 80px;">역할</th>
|
||||||
{% for key in module_keys %}
|
{% for key in module_keys %}
|
||||||
<th style="text-align:center; width: 9%;">
|
<th class="erp-mod-th">
|
||||||
{% if key == 'corm' %}CORM
|
{{ module_labels.get(key, key) }}
|
||||||
{% elif key == 'order' %}Order
|
{% if key in approver_keys %}<br><span class="erp-mod-group">승인</span>{% endif %}
|
||||||
{% elif key == 'expense' %}개인경비
|
|
||||||
{% elif key == 'vacation' %}휴가
|
|
||||||
{% else %}{{ key }}{% endif %}
|
|
||||||
</th>
|
</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<th style="width: 13%;">최근 로그인</th>
|
<th style="width: 120px;">최근 로그인</th>
|
||||||
<th style="width: 12%; text-align: right;">동작</th>
|
<th style="width: 130px; text-align: right;">동작</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -102,7 +131,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
{% for key in module_keys %}
|
{% for key in module_keys %}
|
||||||
<td style="text-align:center;">
|
<td class="erp-mod-cell">
|
||||||
<label class="erp-switch">
|
<label class="erp-switch">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
data-field="module"
|
data-field="module"
|
||||||
@@ -114,11 +143,15 @@
|
|||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td style="font-size:12px; color: var(--color-midtone-gray);">
|
<td style="font-size:12px; color: var(--color-midtone-gray);">
|
||||||
{{ (u.last_login or '')[:16].replace('T', ' ') }}
|
{{ (u.last_login or '미접속')[:16].replace('T', ' ') }}
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;">
|
||||||
<button class="erp-btn erp-btn-primary" data-action="save"
|
<button class="erp-btn erp-btn-primary" data-action="save"
|
||||||
{% if u.is_super_admin %}disabled{% endif %}>저장</button>
|
{% if u.is_super_admin %}disabled{% endif %}>저장</button>
|
||||||
|
{% if not u.is_super_admin %}
|
||||||
|
<button class="erp-btn erp-btn-outline" data-action="delete"
|
||||||
|
style="padding: 4px 8px; font-size: 12px;">삭제</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -127,8 +160,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
|
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
|
||||||
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자이므로 권한을 변경할 수 없습니다.
|
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가.
|
||||||
관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한이 자동 부여됩니다.
|
admin 역할은 모든 모듈 권한 자동 부여.
|
||||||
|
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
@@ -141,6 +175,7 @@
|
|||||||
const searchInput = document.getElementById("user-search");
|
const searchInput = document.getElementById("user-search");
|
||||||
const userCountEl = document.getElementById("user-count");
|
const userCountEl = document.getElementById("user-count");
|
||||||
const toastEl = document.getElementById("toast");
|
const toastEl = document.getElementById("toast");
|
||||||
|
const addForm = document.getElementById("add-user-form");
|
||||||
|
|
||||||
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
|
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
|
||||||
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
|
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
|
||||||
@@ -150,20 +185,16 @@
|
|||||||
toastEl.dataset.type = type || "info";
|
toastEl.dataset.type = type || "info";
|
||||||
toastEl.dataset.visible = "true";
|
toastEl.dataset.visible = "true";
|
||||||
clearTimeout(showToast._t);
|
clearTimeout(showToast._t);
|
||||||
showToast._t = setTimeout(() => {
|
showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400);
|
||||||
toastEl.dataset.visible = "false";
|
|
||||||
}, 2400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 역할 변경시 모듈 토글 활성/비활성 갱신
|
|
||||||
function syncModuleSwitches(tr) {
|
function syncModuleSwitches(tr) {
|
||||||
const isSuper = tr.dataset.super === "true";
|
const isSuper = tr.dataset.super === "true";
|
||||||
const role = tr.querySelector("select[data-field='role']").value;
|
const role = tr.querySelector("select[data-field='role']").value;
|
||||||
const adminRole = role === "admin";
|
const adminRole = role === "admin";
|
||||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
||||||
if (isSuper || adminRole) {
|
if (isSuper || adminRole) {
|
||||||
cb.checked = true;
|
cb.checked = true; cb.disabled = true;
|
||||||
cb.disabled = true;
|
|
||||||
} else {
|
} else {
|
||||||
cb.disabled = false;
|
cb.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -173,25 +204,23 @@
|
|||||||
tbody.addEventListener("change", (e) => {
|
tbody.addEventListener("change", (e) => {
|
||||||
const tr = e.target.closest("tr");
|
const tr = e.target.closest("tr");
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
if (e.target.dataset.field === "role") {
|
if (e.target.dataset.field === "role") syncModuleSwitches(tr);
|
||||||
syncModuleSwitches(tr);
|
|
||||||
}
|
|
||||||
markDirty(tr);
|
markDirty(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
tbody.addEventListener("click", async (e) => {
|
tbody.addEventListener("click", async (e) => {
|
||||||
const btn = e.target.closest("button[data-action='save']");
|
const btn = e.target.closest("button");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const tr = btn.closest("tr");
|
const tr = btn.closest("tr");
|
||||||
const email = tr.dataset.email;
|
const email = tr.dataset.email;
|
||||||
|
|
||||||
|
if (btn.dataset.action === "save") {
|
||||||
const role = tr.querySelector("select[data-field='role']").value;
|
const role = tr.querySelector("select[data-field='role']").value;
|
||||||
const modules = {};
|
const modules = {};
|
||||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
||||||
modules[cb.dataset.module] = cb.checked;
|
modules[cb.dataset.module] = cb.checked;
|
||||||
});
|
});
|
||||||
|
btn.disabled = true; btn.textContent = "저장 중…";
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = "저장 중…";
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -208,12 +237,53 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`저장 실패: ${err.message}`, "error");
|
showToast(`저장 실패: ${err.message}`, "error");
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false; btn.textContent = "저장";
|
||||||
btn.textContent = "저장";
|
}
|
||||||
|
} else if (btn.dataset.action === "delete") {
|
||||||
|
if (!confirm(`${email} 사용자를 삭제할까요? 복구 불가.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
||||||
|
method: "DELETE", credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
tr.remove();
|
||||||
|
showToast(`${email} 삭제 완료`, "info");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`삭제 실패: ${err.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사용자 추가
|
||||||
|
addForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(addForm);
|
||||||
|
const payload = {
|
||||||
|
email: fd.get("email").trim().toLowerCase(),
|
||||||
|
name: fd.get("name").trim(),
|
||||||
|
role: fd.get("role"),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info");
|
||||||
|
setTimeout(() => location.reload(), 800);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`등록 실패: ${err.message}`, "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 검색
|
|
||||||
searchInput.addEventListener("input", () => {
|
searchInput.addEventListener("input", () => {
|
||||||
const q = searchInput.value.toLowerCase().trim();
|
const q = searchInput.value.toLowerCase().trim();
|
||||||
let visible = 0;
|
let visible = 0;
|
||||||
|
|||||||
@@ -105,10 +105,54 @@ DDL: `scripts/sql/expense_db_init.sql` (멱등). DB·역할·테이블·인덱
|
|||||||
| `amount` | BIGINT | 원 단위, ≥ 0 |
|
| `amount` | BIGINT | 원 단위, ≥ 0 |
|
||||||
| `memo` | TEXT | 비고 |
|
| `memo` | TEXT | 비고 |
|
||||||
| `status` | TEXT | 작성중/제출/승인/반려/정산완료 |
|
| `status` | TEXT | 작성중/제출/승인/반려/정산완료 |
|
||||||
|
| `approver_email` | TEXT | 결재자 email (승인/반려/정산 시 기록) |
|
||||||
|
| `decided_at` | TIMESTAMPTZ | 결재 시점 |
|
||||||
|
| `reject_reason` | TEXT | 반려 사유 |
|
||||||
| `created_at` / `updated_at` | TIMESTAMPTZ | 트리거로 자동 갱신 |
|
| `created_at` / `updated_at` | TIMESTAMPTZ | 트리거로 자동 갱신 |
|
||||||
|
|
||||||
인덱스: `(owner, spent_at DESC)`, `(status)`, `(created_at DESC)`.
|
인덱스: `(owner, spent_at DESC)`, `(status)`, `(created_at DESC)`.
|
||||||
|
|
||||||
|
### 테이블 `expense_attachments`
|
||||||
|
|
||||||
|
영수증/기타파일 메타데이터. 실제 파일은 `DATA_DIR/uploads/expense/{item_id}/` 에 저장.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 비고 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | TEXT PK | 12자 hex |
|
||||||
|
| `item_id` | TEXT FK | `expense_items(id)` ON DELETE CASCADE |
|
||||||
|
| `owner` | TEXT | 업로드한 사용자 email |
|
||||||
|
| `kind` | TEXT | `receipt` 또는 `other` (CHECK) |
|
||||||
|
| `filename` | TEXT | 원본 파일명 |
|
||||||
|
| `stored_path` | TEXT | 디스크 경로 (절대) |
|
||||||
|
| `content_type` | TEXT | MIME |
|
||||||
|
| `size_bytes` | BIGINT | 바이트 |
|
||||||
|
| `uploaded_at` | TIMESTAMPTZ | 업로드 시각 |
|
||||||
|
|
||||||
|
인덱스: `(item_id)`.
|
||||||
|
|
||||||
|
### 결재 워크플로
|
||||||
|
|
||||||
|
```
|
||||||
|
작성중 ─submit──▶ 제출 ─approve──▶ 승인 ─settle──▶ 정산완료
|
||||||
|
▲ │
|
||||||
|
└─revert/reject──┴─reject──▶ 반려 ─revert──▶ 작성중
|
||||||
|
```
|
||||||
|
|
||||||
|
- `submit`/`revert`: owner 본인
|
||||||
|
- `approve`/`reject`/`settle`: `expense_approver` 또는 `admin`
|
||||||
|
- 첨부 추가/항목 수정/삭제: `작성중` 또는 `반려` 상태에서만
|
||||||
|
|
||||||
|
### 마이그레이션
|
||||||
|
|
||||||
|
기존 운영 DB 에 신규 컬럼/테이블 적용:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i postgres-db psql -U postgres -d expense_db \
|
||||||
|
< scripts/sql/expense_db_002_workflow_attachments.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
신규 설치는 `expense_db_init.sql` 하나로 충분 (둘 다 멱등).
|
||||||
|
|
||||||
### 운영 서버 초기화 (1회)
|
### 운영 서버 초기화 (1회)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ docker compose down # 컨테이너 제거 (볼륨 유지)
|
|||||||
| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 |
|
| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 |
|
||||||
| `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 |
|
| `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 |
|
||||||
| `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:<pwd>@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 |
|
| `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:<pwd>@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 |
|
||||||
|
| `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,20 @@
|
|||||||
| CS관리 | 문의/응대 이력, 발주 업무 트리거 |
|
| CS관리 | 문의/응대 이력, 발주 업무 트리거 |
|
||||||
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
|
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
|
||||||
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
|
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
|
||||||
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) |
|
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
|
||||||
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
|
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
|
||||||
|
|
||||||
|
### 권한 키 (`MODULE_KEYS`)
|
||||||
|
|
||||||
|
| 키 | 종류 | 설명 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `corm` / `order` | 접근 | 외부 모듈 진입 |
|
||||||
|
| `expense` / `vacation` | 접근 | 내부 모듈 진입 |
|
||||||
|
| `expense_approver` | 결재 | 개인경비 승인/반려/정산 |
|
||||||
|
| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) |
|
||||||
|
|
||||||
|
`admin` 역할은 모든 권한 자동 부여. 신규 사용자는 관리자 페이지(`/admin`)에서 이메일만으로 등록 가능.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 모듈 디렉토리 규약
|
## 모듈 디렉토리 규약
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ jinja2>=3.1
|
|||||||
itsdangerous>=2.2
|
itsdangerous>=2.2
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
psycopg[binary,pool]>=3.2
|
psycopg[binary,pool]>=3.2
|
||||||
|
python-multipart>=0.0.20
|
||||||
|
openpyxl>=3.1
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- expense_db 마이그레이션 #002 — 결재 워크플로 + 첨부파일
|
||||||
|
-- =====================================================================
|
||||||
|
-- 실행:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d expense_db \
|
||||||
|
-- < scripts/sql/expense_db_002_workflow_attachments.sql
|
||||||
|
--
|
||||||
|
-- 멱등. init.sql 도 동일 변경을 포함하므로 신규 설치는 init 하나로 충분.
|
||||||
|
-- 이미 운영 중인 DB 에만 별도로 적용한다.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
-- expense_items 컬럼 추가
|
||||||
|
ALTER TABLE expense_items
|
||||||
|
ADD COLUMN IF NOT EXISTS approver_email TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
|
||||||
|
|
||||||
|
-- 첨부파일 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS expense_attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
stored_path TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
|
||||||
|
|
||||||
|
SELECT 'migration 002 applied' AS status;
|
||||||
@@ -50,14 +50,37 @@ CREATE TABLE IF NOT EXISTS expense_items (
|
|||||||
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
||||||
memo TEXT NOT NULL DEFAULT '',
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT '작성중',
|
status TEXT NOT NULL DEFAULT '작성중',
|
||||||
|
approver_email TEXT,
|
||||||
|
decided_at TIMESTAMPTZ,
|
||||||
|
reject_reason TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 기존 DB 호환: 컬럼이 없으면 추가
|
||||||
|
ALTER TABLE expense_items
|
||||||
|
ADD COLUMN IF NOT EXISTS approver_email TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
|
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);
|
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_expense_created_at ON expense_items (created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_expense_created_at ON expense_items (created_at DESC);
|
||||||
|
|
||||||
|
-- 첨부파일 (영수증/기타). 실제 파일은 파일시스템 저장, 메타만 DB.
|
||||||
|
CREATE TABLE IF NOT EXISTS expense_attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
stored_path TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
|
||||||
|
|
||||||
-- updated_at 자동 갱신 트리거
|
-- updated_at 자동 갱신 트리거
|
||||||
CREATE OR REPLACE FUNCTION expense_set_updated_at() RETURNS trigger AS $$
|
CREATE OR REPLACE FUNCTION expense_set_updated_at() RETURNS trigger AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -74,6 +97,7 @@ CREATE TRIGGER trg_expense_set_updated_at
|
|||||||
-- 권한
|
-- 권한
|
||||||
GRANT USAGE ON SCHEMA public TO expense_app;
|
GRANT USAGE ON SCHEMA public TO expense_app;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_items TO expense_app;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_items TO expense_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO expense_app;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO expense_app;
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
|||||||
Reference in New Issue
Block a user