feat(vacation): 휴가 관리 모듈 — 월간 달력 bar/연차·반차 신청/승인 워크플로/공휴일·연차 설정
- vacation_db 전용 저장소(VacationDBStore), VACATION_DB_URL 미설정 시 '설정 필요' 안내 - 월간 달력: 구글식 휴가 bar(주 단위 lane 배정), 일/공휴일 빨강·토요일 파랑 - 신청 폼: 종류/기간/시작·종료 구분(종일/오전/오후)/사유 + 예상 일수 미리보기 - 일수 계산은 서버(compute_days)에서 주말+공휴일(is_red) 제외 재계산 - 워크플로: 작성중/반려→제출→승인|반려, 취소 soft delete, 본인만 수정(작성중/반려) - 권한: vacation(접근)/vacation_approver(승인), admin 통과. 설정은 admin 전용 - 공휴일(vacation_holidays) DB 기반 provider + settings CRUD, 연차잔여(vacation_balances) - 엑셀 내보내기(openpyxl), scripts/sql/vacation_db_init.sql(멱등, 2026 공휴일 seed) - main.py 등록, 메뉴 ready 전환, docs/.env.example/CLAUDE.md 갱신 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,12 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
||||
# DB/역할/스키마/센터 seed 생성: scripts/sql/cupang_db_init.sql 참고.
|
||||
# CUPANG_DB_URL=postgresql://cupang_app:replace-me@postgres-db:5432/cupang_db
|
||||
|
||||
# ─── 휴가 관리 모듈 (vacation_db) ───
|
||||
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||
# DB/역할/스키마/공휴일 seed 생성: scripts/sql/vacation_db_init.sql 참고.
|
||||
# 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과.
|
||||
# VACATION_DB_URL=postgresql://vacation_app:replace-me@postgres-db:5432/vacation_db
|
||||
|
||||
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
||||
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
||||
# 미설정 시 검색 비활성 → 수동 등록만 가능.
|
||||
|
||||
@@ -32,6 +32,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
|
||||
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
|
||||
- 개인경비 (`app/modules/expense/`, `expense_db`)
|
||||
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
|
||||
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
|
||||
|
||||
상세는 `docs/PROJECT_OVERVIEW.md`.
|
||||
|
||||
|
||||
+10
-4
@@ -17,6 +17,8 @@ from .modules.cupang import build_cupang_store, build_itemcode_reader
|
||||
from .modules.cupang import router as cupang_router
|
||||
from .modules.expense import build_expense_store
|
||||
from .modules.expense import router as expense_router
|
||||
from .modules.vacation import build_vacation_store
|
||||
from .modules.vacation import router as vacation_router
|
||||
from .store import (
|
||||
APPROVER_KEYS,
|
||||
MODULE_KEYS,
|
||||
@@ -105,6 +107,7 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
|
||||
_MODULE_TEMPLATE_DIRS = [
|
||||
BASE_DIR / "modules" / "expense" / "templates",
|
||||
BASE_DIR / "modules" / "cupang" / "templates",
|
||||
BASE_DIR / "modules" / "vacation" / "templates",
|
||||
]
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
templates.env.loader = ChoiceLoader(
|
||||
@@ -128,10 +131,13 @@ app.state.expense_store = build_expense_store(
|
||||
# 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력 폴백).
|
||||
app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None)
|
||||
app.state.itemcode_reader = build_itemcode_reader()
|
||||
# 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||
app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or None)
|
||||
|
||||
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
|
||||
app.include_router(expense_router)
|
||||
app.include_router(cupang_router)
|
||||
app.include_router(vacation_router)
|
||||
|
||||
|
||||
def public_url_for(request: Request, route_name: str) -> str:
|
||||
@@ -268,10 +274,10 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"key": "vacation",
|
||||
"title": "휴가",
|
||||
"subtitle": "Vacation",
|
||||
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 확인합니다.",
|
||||
"url": "#",
|
||||
"health_url": None,
|
||||
"status": "preparing",
|
||||
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 달력에서 관리합니다.",
|
||||
"url": "/vacation/",
|
||||
"health_url": "/vacation/health",
|
||||
"status": "ready",
|
||||
"category": "관리",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""휴가 관리(vacation) 모듈.
|
||||
|
||||
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/vacation)
|
||||
- 저장소: `db.py` (vacation_db / PostgreSQL 전용) + `store.py` (상수/일수 계산)
|
||||
- 템플릿: `templates/vacation/`
|
||||
|
||||
데이터 저장은 vacation_db 전용이다. VACATION_DB_URL 미설정 시 build_vacation_store 는
|
||||
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
|
||||
|
||||
권한:
|
||||
- `vacation` : 휴가 관리 접근
|
||||
- `vacation_approver` : 승인/반려 (admin 은 항상 통과)
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .router import router
|
||||
from .store import HALF_LABELS, HALVES, STATUSES, VACATION_TYPES, compute_days
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"VACATION_TYPES",
|
||||
"STATUSES",
|
||||
"HALVES",
|
||||
"HALF_LABELS",
|
||||
"compute_days",
|
||||
"build_vacation_store",
|
||||
]
|
||||
|
||||
|
||||
def build_vacation_store(*, dsn: str | None) -> Any:
|
||||
"""VACATION_DB_URL 이 있으면 VacationDBStore, 없으면 None.
|
||||
|
||||
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
|
||||
"""
|
||||
if not dsn:
|
||||
return None
|
||||
from .db import VacationDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return VacationDBStore(dsn)
|
||||
@@ -0,0 +1,485 @@
|
||||
"""vacation_db PostgreSQL 저장소.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense/cupang 모듈과 동일 패턴.
|
||||
- 연결 정보: 환경변수 `VACATION_DB_URL`
|
||||
(예: postgresql://vacation_app:<pwd>@postgres-db:5432/vacation_db)
|
||||
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
|
||||
`scripts/sql/vacation_db_init.sql` 을 superuser 가 사전 적용한다.
|
||||
앱 계정(vacation_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
|
||||
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||
|
||||
휴가 일수는 서버에서 `store.compute_days` 로 재계산하여 저장한다.
|
||||
공휴일 집합은 vacation_holidays(is_red=TRUE)에서 읽어 계산에 넘긴다(holiday provider).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from .store import EDITABLE_STATUSES, STATUSES, VACATION_TYPES, compute_days, normalize_half
|
||||
|
||||
|
||||
class VacationDBStore:
|
||||
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=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 공휴일 (vacation_holidays) — holiday provider
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_holidays(self, *, year: int | None = None) -> list[dict[str, Any]]:
|
||||
clause = ""
|
||||
params: list[Any] = []
|
||||
if year:
|
||||
clause = "WHERE EXTRACT(YEAR FROM holiday_date) = %s"
|
||||
params.append(year)
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM vacation_holidays {clause} ORDER BY holiday_date ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._holiday_serialize(r) for r in rows]
|
||||
|
||||
def red_holiday_set(self, *, date_from: str, date_to: str) -> set[str]:
|
||||
"""[date_from, date_to] 범위의 is_red=TRUE 공휴일 ISO 날짜 집합.
|
||||
|
||||
휴가일수 계산/달력 색상의 단일 진실 공급원.
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT holiday_date FROM vacation_holidays "
|
||||
"WHERE is_red = TRUE AND holiday_date BETWEEN %s AND %s",
|
||||
(date_from, date_to),
|
||||
).fetchall()
|
||||
out: set[str] = set()
|
||||
for r in rows:
|
||||
d = r["holiday_date"]
|
||||
out.add(d.isoformat() if isinstance(d, date) else str(d))
|
||||
return out
|
||||
|
||||
def upsert_holiday(
|
||||
self,
|
||||
*,
|
||||
holiday_date: str,
|
||||
name: str,
|
||||
kind: str = "public",
|
||||
is_red: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
hd = (holiday_date or "").strip()
|
||||
nm = (name or "").strip()
|
||||
if not hd or not nm:
|
||||
raise ValueError("공휴일 날짜와 이름은 필수입니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO vacation_holidays (holiday_date, name, kind, is_red)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (holiday_date) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
kind = EXCLUDED.kind,
|
||||
is_red = EXCLUDED.is_red
|
||||
RETURNING *
|
||||
""",
|
||||
(hd, nm, (kind or "public").strip(), bool(is_red)),
|
||||
).fetchone()
|
||||
return self._holiday_serialize(row)
|
||||
|
||||
def delete_holiday(self, *, holiday_id: int) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM vacation_holidays WHERE id = %s", (holiday_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(holiday_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 잔여 연차 (vacation_balances)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def get_balance(self, *, user_email: str, year: int) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM vacation_balances WHERE user_email = %s AND year = %s",
|
||||
(user_email.lower().strip(), year),
|
||||
).fetchone()
|
||||
return self._balance_serialize(row) if row else None
|
||||
|
||||
def list_balances(self, *, year: int) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_balances WHERE year = %s ORDER BY user_email ASC",
|
||||
(year,),
|
||||
).fetchall()
|
||||
return [self._balance_serialize(r) for r in rows]
|
||||
|
||||
def upsert_balance(
|
||||
self, *, user_email: str, year: int, total_days: float, memo: str = ""
|
||||
) -> dict[str, Any]:
|
||||
email = (user_email or "").lower().strip()
|
||||
if not email or "@" not in email:
|
||||
raise ValueError("올바른 이메일이 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO vacation_balances (user_email, year, total_days, memo)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (user_email, year) DO UPDATE
|
||||
SET total_days = EXCLUDED.total_days,
|
||||
memo = EXCLUDED.memo
|
||||
RETURNING *
|
||||
""",
|
||||
(email, year, total_days, (memo or "").strip()),
|
||||
).fetchone()
|
||||
return self._balance_serialize(row)
|
||||
|
||||
def used_days(self, *, user_email: str, year: int) -> float:
|
||||
"""해당 연도 승인된 휴가의 합계 일수(사용 연차). start_date 연도 기준."""
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(SUM(days), 0) AS s FROM vacation_requests "
|
||||
"WHERE owner = %s AND status = '승인' "
|
||||
"AND EXTRACT(YEAR FROM start_date) = %s",
|
||||
(user_email.lower().strip(), year),
|
||||
).fetchone()
|
||||
return float(row["s"]) if row else 0.0
|
||||
|
||||
def balance_summary(self, *, user_email: str, year: int) -> dict[str, Any]:
|
||||
bal = self.get_balance(user_email=user_email, year=year)
|
||||
total = float(bal["total_days"]) if bal else 0.0
|
||||
used = self.used_days(user_email=user_email, year=year)
|
||||
return {
|
||||
"year": year,
|
||||
"total_days": total,
|
||||
"used_days": used,
|
||||
"remaining_days": round(total - used, 2),
|
||||
"memo": bal["memo"] if bal else "",
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 휴가 신청 (vacation_requests)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_for(self, owner: str) -> list[dict[str, Any]]:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_requests WHERE owner = %s "
|
||||
"ORDER BY start_date DESC, created_at DESC",
|
||||
(owner,),
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
def list_overlapping(self, *, date_from: str, date_to: str) -> list[dict[str, Any]]:
|
||||
"""[date_from, date_to] 와 겹치는 모든 신청(달력 bar 표시용).
|
||||
|
||||
취소 포함(흐리게 표시). 범위가 한 칸이라도 겹치면 포함.
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_requests "
|
||||
"WHERE start_date <= %s AND end_date >= %s "
|
||||
"ORDER BY start_date ASC, owner ASC",
|
||||
(date_to, date_from),
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
def get_request(self, *, request_id: str) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM vacation_requests WHERE id = %s", (request_id,)
|
||||
).fetchone()
|
||||
return self._req_serialize(row) if row else None
|
||||
|
||||
def create_request(
|
||||
self, *, owner: str, owner_name: str, payload: dict[str, Any], status: str = "작성중"
|
||||
) -> dict[str, Any]:
|
||||
h = self._normalize_payload(payload)
|
||||
if status not in STATUSES:
|
||||
status = "작성중"
|
||||
days = self._calc_days(h)
|
||||
req_id = uuid.uuid4().hex[:12]
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO vacation_requests
|
||||
(id, owner, owner_name, vacation_type, start_date, end_date,
|
||||
start_half, end_half, days, reason, status)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
req_id,
|
||||
owner.lower().strip(),
|
||||
(owner_name or "").strip(),
|
||||
h["vacation_type"],
|
||||
h["start_date"],
|
||||
h["end_date"],
|
||||
h["start_half"],
|
||||
h["end_half"],
|
||||
days,
|
||||
h["reason"],
|
||||
status,
|
||||
),
|
||||
).fetchone()
|
||||
return self._req_serialize(row)
|
||||
|
||||
def update_request(
|
||||
self, *, request_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""작성중/반려 상태에서만 본인이 수정 가능."""
|
||||
owner = owner.lower().strip()
|
||||
current = self.get_request(request_id=request_id)
|
||||
if not current:
|
||||
raise KeyError(request_id)
|
||||
if current["owner"] != owner:
|
||||
raise PermissionError("본인 신청만 수정할 수 있습니다.")
|
||||
if current["status"] not in EDITABLE_STATUSES:
|
||||
raise ValueError(f"{current['status']} 상태에서는 수정할 수 없습니다.")
|
||||
h = self._normalize_payload(payload)
|
||||
days = self._calc_days(h)
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET vacation_type = %s, start_date = %s, end_date = %s,
|
||||
start_half = %s, end_half = %s, days = %s, reason = %s,
|
||||
status = '작성중', reject_reason = '',
|
||||
approver_email = NULL, decided_at = NULL
|
||||
WHERE id = %s AND owner = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
h["vacation_type"],
|
||||
h["start_date"],
|
||||
h["end_date"],
|
||||
h["start_half"],
|
||||
h["end_half"],
|
||||
days,
|
||||
h["reason"],
|
||||
request_id,
|
||||
owner,
|
||||
),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(request_id)
|
||||
return self._req_serialize(row)
|
||||
|
||||
# ── 워크플로 ──
|
||||
def submit(self, *, request_id: str, owner: str) -> dict[str, Any]:
|
||||
"""작성중/반려 → 제출 (본인)."""
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET status = '제출', reject_reason = '',
|
||||
approver_email = NULL, decided_at = NULL
|
||||
WHERE id = %s AND owner = %s AND status IN ('작성중', '반려')
|
||||
RETURNING *
|
||||
""",
|
||||
(request_id, owner),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("작성중/반려 상태에서만 제출할 수 있습니다.")
|
||||
return self._req_serialize(row)
|
||||
|
||||
def approve(self, *, request_id: str, approver_email: str) -> dict[str, Any]:
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET status = '승인', approver_email = %s, decided_at = now(),
|
||||
reject_reason = ''
|
||||
WHERE id = %s AND status = '제출'
|
||||
RETURNING *
|
||||
""",
|
||||
(approver_email, request_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
|
||||
return self._req_serialize(row)
|
||||
|
||||
def reject(
|
||||
self, *, request_id: str, approver_email: str, reason: str
|
||||
) -> dict[str, Any]:
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("반려 사유는 필수입니다.")
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET status = '반려', approver_email = %s, decided_at = now(),
|
||||
reject_reason = %s
|
||||
WHERE id = %s AND status = '제출'
|
||||
RETURNING *
|
||||
""",
|
||||
(approver_email, reason.strip(), request_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
|
||||
return self._req_serialize(row)
|
||||
|
||||
def cancel(self, *, request_id: str, user_email: str, is_admin: bool) -> dict[str, Any]:
|
||||
"""soft delete — status='취소'. owner 또는 admin 만."""
|
||||
user_email = user_email.lower().strip()
|
||||
current = self.get_request(request_id=request_id)
|
||||
if not current:
|
||||
raise KeyError(request_id)
|
||||
if not is_admin and current["owner"] != user_email:
|
||||
raise PermissionError("본인 신청만 취소할 수 있습니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"UPDATE vacation_requests SET status = '취소' WHERE id = %s RETURNING *",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return self._req_serialize(row)
|
||||
|
||||
def list_pending(self) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_requests WHERE status = '제출' "
|
||||
"ORDER BY start_date ASC, created_at ASC"
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
def list_for_export(
|
||||
self, *, date_from: str | None = None, date_to: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if date_from:
|
||||
clauses.append("end_date >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("start_date <= %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 vacation_requests {where} "
|
||||
"ORDER BY start_date ASC, owner ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 정규화 / 계산 / 직렬화 helpers
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def _calc_days(self, h: dict[str, Any]) -> float:
|
||||
holidays = self.red_holiday_set(
|
||||
date_from=h["start_date"], date_to=h["end_date"]
|
||||
)
|
||||
return compute_days(
|
||||
start_date=date.fromisoformat(h["start_date"]),
|
||||
end_date=date.fromisoformat(h["end_date"]),
|
||||
start_half=h["start_half"],
|
||||
end_half=h["end_half"],
|
||||
vacation_type=h["vacation_type"],
|
||||
holidays=holidays,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
vtype = str(payload.get("vacation_type") or "연차").strip()
|
||||
if vtype not in VACATION_TYPES:
|
||||
vtype = "연차"
|
||||
start_date = str(payload.get("start_date") or "").strip()
|
||||
end_date = str(payload.get("end_date") or "").strip()
|
||||
if not start_date:
|
||||
raise ValueError("시작일은 필수입니다.")
|
||||
if not end_date:
|
||||
end_date = start_date
|
||||
# 날짜 형식/순서 검증
|
||||
try:
|
||||
sd = date.fromisoformat(start_date)
|
||||
ed = date.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
raise ValueError("날짜 형식이 올바르지 않습니다 (YYYY-MM-DD).")
|
||||
if ed < sd:
|
||||
raise ValueError("종료일이 시작일보다 빠를 수 없습니다.")
|
||||
start_half = normalize_half(payload.get("start_half"))
|
||||
end_half = normalize_half(payload.get("end_half"))
|
||||
# 반차 종류면 단일 일자로 강제
|
||||
if vtype in ("오전반차", "오후반차"):
|
||||
end_date = start_date
|
||||
start_half = "am" if vtype == "오전반차" else "pm"
|
||||
end_half = start_half
|
||||
return {
|
||||
"vacation_type": vtype,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"start_half": start_half,
|
||||
"end_half": end_half,
|
||||
"reason": str(payload.get("reason") or "").strip(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _num(v: Any) -> float:
|
||||
if isinstance(v, Decimal):
|
||||
return float(v)
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _req_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k in ("start_date", "end_date"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, date):
|
||||
out[k] = v.isoformat()
|
||||
for k in ("created_at", "updated_at", "decided_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
out["days"] = cls._num(out.get("days", 0))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _holiday_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
v = out.get("holiday_date")
|
||||
if isinstance(v, date):
|
||||
out["holiday_date"] = v.isoformat()
|
||||
out["is_red"] = bool(out.get("is_red", True))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def _balance_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["year"] = int(out["year"])
|
||||
out["total_days"] = cls._num(out.get("total_days", 0))
|
||||
out["used_days"] = cls._num(out.get("used_days", 0))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
return out
|
||||
@@ -0,0 +1,800 @@
|
||||
"""휴가 관리 모듈 라우터.
|
||||
|
||||
- 경로: /vacation
|
||||
- 권한: 로그인 + `vacation` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||
승인/반려는 `vacation_approver` 또는 admin.
|
||||
- 데이터: VacationDBStore (vacation_db / PostgreSQL) 전용.
|
||||
VACATION_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar as _calendar
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import (
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
RedirectResponse,
|
||||
StreamingResponse,
|
||||
)
|
||||
|
||||
from .store import HALF_LABELS, HALVES, VACATION_TYPES
|
||||
|
||||
router = APIRouter(prefix="/vacation", tags=["vacation"])
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 공용 헬퍼
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def _store(request: Request) -> Any:
|
||||
return getattr(request.app.state, "vacation_store", None)
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
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, "vacation"):
|
||||
raise HTTPException(status_code=403, detail="휴가 관리 모듈 권한이 없습니다.")
|
||||
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, "vacation_approver")):
|
||||
raise HTTPException(status_code=403, detail="휴가 승인자 권한이 필요합니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _is_approver(request: Request, user: dict[str, Any]) -> bool:
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
return is_admin(user) or has_module(user, "vacation_approver")
|
||||
|
||||
|
||||
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{
|
||||
"reason": "휴가 관리 모듈이 아직 설정되지 않았습니다. "
|
||||
"VACATION_DB_URL 환경변수를 설정하고 scripts/sql/vacation_db_init.sql 로 "
|
||||
"vacation_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
|
||||
def _guard(
|
||||
request: Request,
|
||||
) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
|
||||
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용."""
|
||||
from app.main import get_current_user_record, render_template # noqa: WPS433
|
||||
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, "vacation"):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "휴가 관리 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
return _render_config_needed(request, user)
|
||||
return store, user
|
||||
|
||||
|
||||
def _ym(request: Request) -> tuple[int, int]:
|
||||
today = date.today()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
month = int(request.query_params.get("month") or today.month)
|
||||
except ValueError:
|
||||
year, month = today.year, today.month
|
||||
if not (1 <= month <= 12):
|
||||
year, month = today.year, today.month
|
||||
return year, month
|
||||
|
||||
|
||||
# 상태별 bar 스타일 클래스 (CSS 와 매핑)
|
||||
_STATUS_CLASS = {
|
||||
"작성중": "vac-bar-draft",
|
||||
"제출": "vac-bar-submit",
|
||||
"승인": "vac-bar-approve",
|
||||
"반려": "vac-bar-reject",
|
||||
"취소": "vac-bar-cancel",
|
||||
}
|
||||
|
||||
|
||||
def _assign_lanes(bars: list[dict[str, Any]]) -> int:
|
||||
"""주(week) 내 bar 들에 겹치지 않는 lane(행) 번호를 그리디 배정.
|
||||
|
||||
bars 는 같은 주의 segment 들. 각 bar 에 'lane' 키를 추가하고, 사용된 lane 수를 반환.
|
||||
"""
|
||||
bars.sort(key=lambda b: (b["start_col"], -b["span"]))
|
||||
lane_end: list[int] = [] # lane 별 마지막 점유 col(포함)
|
||||
for b in bars:
|
||||
placed = False
|
||||
for li, end_col in enumerate(lane_end):
|
||||
if b["start_col"] > end_col:
|
||||
b["lane"] = li
|
||||
lane_end[li] = b["start_col"] + b["span"] - 1
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
b["lane"] = len(lane_end)
|
||||
lane_end.append(b["start_col"] + b["span"] - 1)
|
||||
return len(lane_end)
|
||||
|
||||
|
||||
def _build_calendar(
|
||||
store: Any, year: int, month: int, sel: str
|
||||
) -> dict[str, Any]:
|
||||
"""월간 달력 데이터 + bar lane 레이아웃 계산."""
|
||||
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
|
||||
weeks_dates = cal.monthdatescalendar(year, month)
|
||||
range_start = weeks_dates[0][0]
|
||||
range_end = weeks_dates[-1][-1]
|
||||
|
||||
red_set = store.red_holiday_set(
|
||||
date_from=range_start.isoformat(), date_to=range_end.isoformat()
|
||||
)
|
||||
holiday_names = {
|
||||
h["holiday_date"]: h["name"]
|
||||
for h in store.list_holidays()
|
||||
if h.get("is_red")
|
||||
}
|
||||
requests = store.list_overlapping(
|
||||
date_from=range_start.isoformat(), date_to=range_end.isoformat()
|
||||
)
|
||||
|
||||
today = date.today()
|
||||
weeks: list[dict[str, Any]] = []
|
||||
for week in weeks_dates:
|
||||
w_start, w_end = week[0], week[-1]
|
||||
days = [
|
||||
{
|
||||
"date": d.isoformat(),
|
||||
"day": d.day,
|
||||
"in_month": d.month == month,
|
||||
"is_today": d == today,
|
||||
"is_selected": d.isoformat() == sel,
|
||||
"is_sunday": d.weekday() == 6,
|
||||
"is_saturday": d.weekday() == 5,
|
||||
"is_holiday": d.isoformat() in red_set,
|
||||
"holiday_name": holiday_names.get(d.isoformat(), ""),
|
||||
}
|
||||
for d in week
|
||||
]
|
||||
# 이 주에 걸치는 bar segment
|
||||
bars: list[dict[str, Any]] = []
|
||||
for r in requests:
|
||||
rs = date.fromisoformat(r["start_date"])
|
||||
re_ = date.fromisoformat(r["end_date"])
|
||||
if re_ < w_start or rs > w_end:
|
||||
continue
|
||||
seg_start = max(rs, w_start)
|
||||
seg_end = min(re_, w_end)
|
||||
start_col = (seg_start - w_start).days # 0..6
|
||||
span = (seg_end - seg_start).days + 1
|
||||
label = f"{r.get('owner_name') or r.get('owner')} {r['vacation_type']}"
|
||||
bars.append(
|
||||
{
|
||||
"id": r["id"],
|
||||
"label": label,
|
||||
"status": r["status"],
|
||||
"status_class": _STATUS_CLASS.get(r["status"], "vac-bar-draft"),
|
||||
"start_col": start_col,
|
||||
"span": span,
|
||||
"continues_left": rs < w_start,
|
||||
"continues_right": re_ > w_end,
|
||||
"days": r["days"],
|
||||
}
|
||||
)
|
||||
lane_count = _assign_lanes(bars)
|
||||
weeks.append({"days": days, "bars": bars, "lane_count": lane_count})
|
||||
|
||||
return {"weeks": weeks, "requests": requests, "sel_date": sel}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 메인 — 월간 달력 + 선택일 휴가 리스트
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
|
||||
year, month = _ym(request)
|
||||
sel = request.query_params.get("date") or ""
|
||||
today = date.today()
|
||||
if not sel:
|
||||
sel = (
|
||||
today.isoformat()
|
||||
if (today.year == year and today.month == month)
|
||||
else f"{year:04d}-{month:02d}-01"
|
||||
)
|
||||
|
||||
caldata = _build_calendar(store, year, month, sel)
|
||||
sel_requests = [
|
||||
r
|
||||
for r in caldata["requests"]
|
||||
if r["start_date"] <= sel <= r["end_date"]
|
||||
]
|
||||
|
||||
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
|
||||
summary = store.balance_summary(user_email=user["email"], year=year)
|
||||
is_approver = _is_approver(request, user)
|
||||
pending_count = len(store.list_pending()) if is_approver else 0
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/index.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": is_approver,
|
||||
"pending_count": pending_count,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 관리",
|
||||
"page_subtitle": f"{year}년 {month}월 휴가 달력",
|
||||
"year": year,
|
||||
"month": month,
|
||||
"prev_y": prev_y, "prev_m": prev_m,
|
||||
"next_y": next_y, "next_m": next_m,
|
||||
"today": today.isoformat(),
|
||||
"weekdays": ["일", "월", "화", "수", "목", "금", "토"],
|
||||
"weeks": caldata["weeks"],
|
||||
"selected_date": sel,
|
||||
"sel_requests": sel_requests,
|
||||
"balance": summary,
|
||||
"half_labels": HALF_LABELS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 휴가 신청 — 등록 / 수정 / 상세
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def _form_context(request: Request, user: dict[str, Any]) -> dict[str, Any]:
|
||||
from app.main import build_erp_nav # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": _is_approver(request, user),
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"vacation_types": list(VACATION_TYPES),
|
||||
"halves": list(HALVES),
|
||||
"half_labels": HALF_LABELS,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def new_form(request: Request) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
_store_obj, user = guard
|
||||
ctx = _form_context(request, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "휴가 신청",
|
||||
"page_subtitle": "휴가 종류 · 기간 · 사유 입력",
|
||||
"mode": "new",
|
||||
"req": None,
|
||||
"default_date": date.today().isoformat(),
|
||||
}
|
||||
)
|
||||
return render_template(request, "vacation/form.html", ctx)
|
||||
|
||||
|
||||
def _payload_from_form(
|
||||
vacation_type: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
start_half: str,
|
||||
end_half: str,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"vacation_type": vacation_type,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date or start_date,
|
||||
"start_half": start_half,
|
||||
"end_half": end_half,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
async def create(
|
||||
request: Request,
|
||||
vacation_type: str = Form(...),
|
||||
start_date: str = Form(...),
|
||||
end_date: str = Form(""),
|
||||
start_half: str = Form("full"),
|
||||
end_half: str = Form("full"),
|
||||
reason: str = Form(""),
|
||||
action: str = Form("submit"), # "draft" | "submit"
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
status = "작성중" if action == "draft" else "제출"
|
||||
payload = _payload_from_form(
|
||||
vacation_type, start_date, end_date, start_half, end_half, reason
|
||||
)
|
||||
try:
|
||||
req = store.create_request(
|
||||
owner=user["email"],
|
||||
owner_name=user.get("name") or user["email"],
|
||||
payload=payload,
|
||||
status=status,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{req['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/pending", response_class=HTMLResponse)
|
||||
async def pending_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
if not _is_approver(request, user):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "휴가 승인자 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
pending = store.list_pending()
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/pending.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": True,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 — 승인 대기",
|
||||
"page_subtitle": f"제출 상태 {len(pending)}건",
|
||||
"items": pending,
|
||||
"half_labels": HALF_LABELS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
if not is_admin(user):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "휴가 설정은 관리자만 접근할 수 있습니다.", "is_admin": False},
|
||||
status_code=403,
|
||||
)
|
||||
today = date.today()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
except ValueError:
|
||||
year = today.year
|
||||
balances = store.list_balances(year=year)
|
||||
for b in balances:
|
||||
b["used_days"] = store.used_days(user_email=b["user_email"], year=year)
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/settings.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": True,
|
||||
"is_approver": True,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 — 설정",
|
||||
"page_subtitle": "공휴일 · 연차 잔여 관리",
|
||||
"year": year,
|
||||
"holidays": store.list_holidays(year=year),
|
||||
"balances": balances,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/settings/holidays")
|
||||
async def holiday_upsert(
|
||||
request: Request,
|
||||
holiday_date: str = Form(...),
|
||||
name: str = Form(...),
|
||||
kind: str = Form("public"),
|
||||
is_red: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.upsert_holiday(
|
||||
holiday_date=holiday_date,
|
||||
name=name,
|
||||
kind=kind,
|
||||
is_red=is_red in ("1", "true", "on", "True"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
year = holiday_date[:4]
|
||||
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/settings/holidays/{holiday_id:int}/delete")
|
||||
async def holiday_delete(
|
||||
request: Request,
|
||||
holiday_id: int,
|
||||
year: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.delete_holiday(holiday_id=holiday_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="공휴일을 찾을 수 없습니다.")
|
||||
suffix = f"?year={year}" if year else ""
|
||||
return RedirectResponse(url=f"/vacation/settings{suffix}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/settings/balances")
|
||||
async def balance_upsert(
|
||||
request: Request,
|
||||
user_email: str = Form(...),
|
||||
year: int = Form(...),
|
||||
total_days: float = Form(...),
|
||||
memo: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.upsert_balance(
|
||||
user_email=user_email, year=year, total_days=total_days, memo=memo
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/api/calendar")
|
||||
async def api_calendar(
|
||||
request: Request, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> JSONResponse:
|
||||
"""달력 비동기 데이터(JSON). year/month 쿼리."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
year, month = _ym(request)
|
||||
sel = request.query_params.get("date") or f"{year:04d}-{month:02d}-01"
|
||||
caldata = _build_calendar(store, year, month, sel)
|
||||
return JSONResponse(
|
||||
{
|
||||
"year": year,
|
||||
"month": month,
|
||||
"weeks": caldata["weeks"],
|
||||
"requests": caldata["requests"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export.xlsx")
|
||||
async def export_xlsx(
|
||||
request: Request, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> StreamingResponse:
|
||||
from openpyxl import Workbook # 지연 import
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
|
||||
today = date.today()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
month = int(request.query_params.get("month") or 0)
|
||||
except ValueError:
|
||||
year, month = today.year, 0
|
||||
|
||||
if 1 <= month <= 12:
|
||||
last = _calendar.monthrange(year, month)[1]
|
||||
date_from = f"{year:04d}-{month:02d}-01"
|
||||
date_to = f"{year:04d}-{month:02d}-{last:02d}"
|
||||
else:
|
||||
date_from = f"{year:04d}-01-01"
|
||||
date_to = f"{year:04d}-12-31"
|
||||
|
||||
rows = store.list_for_export(date_from=date_from, date_to=date_to)
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "vacation"
|
||||
header = [
|
||||
"신청자", "휴가종류", "시작일", "종료일", "시작구분", "종료구분",
|
||||
"사용일수", "상태", "승인자", "승인/반려일", "사유", "반려사유",
|
||||
]
|
||||
ws.append(header)
|
||||
for r in rows:
|
||||
ws.append([
|
||||
r.get("owner_name") or r.get("owner", ""),
|
||||
r.get("vacation_type", ""),
|
||||
r.get("start_date", ""),
|
||||
r.get("end_date", ""),
|
||||
HALF_LABELS.get(r.get("start_half", "full"), ""),
|
||||
HALF_LABELS.get(r.get("end_half", "full"), ""),
|
||||
r.get("days", 0),
|
||||
r.get("status", ""),
|
||||
r.get("approver_email", "") or "",
|
||||
r.get("decided_at", "") or "",
|
||||
r.get("reason", "") or "",
|
||||
r.get("reject_reason", "") or "",
|
||||
])
|
||||
widths = [24, 10, 12, 12, 8, 8, 8, 8, 24, 22, 30, 30]
|
||||
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)
|
||||
scope = f"{year}{('%02d' % month) if (1 <= month <= 12) else ''}"
|
||||
fname = f"vacation_{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")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "module": "vacation"}
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_class=HTMLResponse)
|
||||
async def detail(request: Request, request_id: str) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
req = store.get_request(request_id=request_id)
|
||||
if not req:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
is_owner = req["owner"] == user["email"]
|
||||
is_approver = _is_approver(request, user)
|
||||
if not (is_owner or is_approver):
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "본인 또는 승인자만 조회할 수 있습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/detail.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": is_approver,
|
||||
"is_owner": is_owner,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 상세",
|
||||
"page_subtitle": f"{req['start_date']} · {req['vacation_type']}",
|
||||
"req": req,
|
||||
"half_labels": HALF_LABELS,
|
||||
"can_edit": is_owner and req["status"] in ("작성중", "반려"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{request_id}/edit", response_class=HTMLResponse)
|
||||
async def edit_form(request: Request, request_id: str) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
req = store.get_request(request_id=request_id)
|
||||
if not req:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
if req["owner"] != user["email"]:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "본인 신청만 수정할 수 있습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
if req["status"] not in ("작성중", "반려"):
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": f"{req['status']} 상태에서는 수정할 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=409,
|
||||
)
|
||||
ctx = _form_context(request, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "휴가 신청 수정",
|
||||
"page_subtitle": "작성중/반려 상태만 수정 가능",
|
||||
"mode": "edit",
|
||||
"req": req,
|
||||
"default_date": req["start_date"],
|
||||
}
|
||||
)
|
||||
return render_template(request, "vacation/form.html", ctx)
|
||||
|
||||
|
||||
@router.post("/{request_id}/edit")
|
||||
async def update(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
vacation_type: str = Form(...),
|
||||
start_date: str = Form(...),
|
||||
end_date: str = Form(""),
|
||||
start_half: str = Form("full"),
|
||||
end_half: str = Form("full"),
|
||||
reason: str = Form(""),
|
||||
action: str = Form("save"), # "save" | "submit"
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
payload = _payload_from_form(
|
||||
vacation_type, start_date, end_date, start_half, end_half, reason
|
||||
)
|
||||
try:
|
||||
store.update_request(request_id=request_id, owner=user["email"], payload=payload)
|
||||
if action == "submit":
|
||||
store.submit(request_id=request_id, owner=user["email"])
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/submit")
|
||||
async def submit(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.submit(request_id=request_id, owner=user["email"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/approve")
|
||||
async def approve(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_approver),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.approve(request_id=request_id, approver_email=user["email"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/reject")
|
||||
async def reject(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
reject_reason: str = Form(...),
|
||||
user: dict[str, Any] = Depends(_require_approver),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.reject(
|
||||
request_id=request_id, approver_email=user["email"], reason=reject_reason
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/cancel")
|
||||
async def cancel(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.cancel(
|
||||
request_id=request_id, user_email=user["email"], is_admin=is_admin(user)
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""휴가 관리 모듈 상수 및 순수 계산 헬퍼.
|
||||
|
||||
- 데이터 저장은 vacation_db(PostgreSQL) 전용이다(`db.py`).
|
||||
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
|
||||
VACATION_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
|
||||
- 이 모듈에는 상수와 순수 계산 헬퍼(휴가일수 계산)만 둔다.
|
||||
공휴일 집합은 db.py 가 vacation_holidays 에서 읽어 넘겨준다(holiday provider 분리).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Iterable
|
||||
|
||||
# 휴가 종류 (한글 라벨 그대로 저장)
|
||||
VACATION_TYPES: tuple[str, ...] = (
|
||||
"연차",
|
||||
"오전반차",
|
||||
"오후반차",
|
||||
"병가",
|
||||
"경조",
|
||||
"대체휴무",
|
||||
"기타",
|
||||
)
|
||||
|
||||
# 신청 상태 워크플로
|
||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "취소")
|
||||
|
||||
# 시작/종료일 구분
|
||||
HALVES: tuple[str, ...] = ("full", "am", "pm")
|
||||
HALF_LABELS: dict[str, str] = {"full": "종일", "am": "오전", "pm": "오후"}
|
||||
|
||||
# 수정/삭제(취소) 가능한 상태 — 본인 편집 허용
|
||||
EDITABLE_STATUSES: tuple[str, ...] = ("작성중", "반려")
|
||||
|
||||
# 반차 성격의 휴가 종류 (단일 일자 0.5일 강제)
|
||||
_HALF_DAY_TYPES: dict[str, str] = {"오전반차": "am", "오후반차": "pm"}
|
||||
|
||||
|
||||
def _daterange(start: date, end: date) -> Iterable[date]:
|
||||
cur = start
|
||||
while cur <= end:
|
||||
yield cur
|
||||
cur += timedelta(days=1)
|
||||
|
||||
|
||||
def is_working_day(d: date, holidays: set[str]) -> bool:
|
||||
"""주말(토/일)과 공휴일(holidays: ISO 날짜 집합)을 제외하면 근무일."""
|
||||
if d.weekday() >= 5: # 5=토, 6=일
|
||||
return False
|
||||
return d.isoformat() not in holidays
|
||||
|
||||
|
||||
def compute_days(
|
||||
*,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
start_half: str = "full",
|
||||
end_half: str = "full",
|
||||
vacation_type: str = "연차",
|
||||
holidays: set[str] | None = None,
|
||||
) -> float:
|
||||
"""휴가 일수 계산. 서버에서 항상 이 함수로 재계산한다(클라이언트 신뢰 금지).
|
||||
|
||||
- 주말 + 공휴일(is_red) 제외.
|
||||
- 오전/오후 반차는 0.5일.
|
||||
- 같은 날 + 반차면 0.5일.
|
||||
- 여러 날에서 시작/종료가 반차면 시작일/종료일 각각 0.5 차감.
|
||||
- 휴가 종류가 오전반차/오후반차면 단일 일자 0.5일로 강제.
|
||||
"""
|
||||
holidays = holidays or set()
|
||||
if end_date < start_date:
|
||||
return 0.0
|
||||
|
||||
# 반차 종류는 단일 일자 0.5
|
||||
if vacation_type in _HALF_DAY_TYPES:
|
||||
if is_working_day(start_date, holidays):
|
||||
return 0.5
|
||||
return 0.0
|
||||
|
||||
working = [d for d in _daterange(start_date, end_date) if is_working_day(d, holidays)]
|
||||
n = len(working)
|
||||
if n == 0:
|
||||
return 0.0
|
||||
|
||||
if start_date == end_date:
|
||||
return 0.5 if start_half in ("am", "pm") else 1.0
|
||||
|
||||
total = float(n)
|
||||
if start_half in ("am", "pm") and is_working_day(start_date, holidays):
|
||||
total -= 0.5
|
||||
if end_half in ("am", "pm") and is_working_day(end_date, holidays):
|
||||
total -= 0.5
|
||||
return max(0.0, total)
|
||||
|
||||
|
||||
def normalize_half(value: str | None) -> str:
|
||||
v = (value or "full").strip().lower()
|
||||
return v if v in HALVES else "full"
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530a" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
<div class="erp-card vac-detail-card">
|
||||
|
||||
<div class="vac-detail-head">
|
||||
<div>
|
||||
<h2>{{ req.owner_name or req.owner }}</h2>
|
||||
<span class="erp-muted">{{ req.owner }}</span>
|
||||
</div>
|
||||
{% set badge = 'erp-badge-neutral' %}
|
||||
{% if req.status == '승인' %}{% set badge = 'erp-badge-success' %}
|
||||
{% elif req.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
|
||||
{% elif req.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||
<span class="erp-badge {{ badge }} vac-detail-status">{{ req.status }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="vac-detail-grid">
|
||||
<div><dt>휴가 종류</dt><dd>{{ req.vacation_type }}</dd></div>
|
||||
<div><dt>사용 일수</dt><dd><strong>{{ req.days }}</strong>일</dd></div>
|
||||
<div><dt>시작일</dt><dd>{{ req.start_date }} ({{ half_labels[req.start_half] }})</dd></div>
|
||||
<div><dt>종료일</dt><dd>{{ req.end_date }} ({{ half_labels[req.end_half] }})</dd></div>
|
||||
{% if req.approver_email %}
|
||||
<div><dt>승인자</dt><dd>{{ req.approver_email }}</dd></div>
|
||||
<div><dt>결재일시</dt><dd>{{ req.decided_at }}</dd></div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if req.reason %}
|
||||
<div class="vac-detail-block">
|
||||
<div class="vac-detail-label">사유</div>
|
||||
<p>{{ req.reason }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if req.status == '반려' and req.reject_reason %}
|
||||
<div class="vac-detail-block vac-reject-block">
|
||||
<div class="vac-detail-label">반려 사유</div>
|
||||
<p>{{ req.reject_reason }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── 승인자 액션 (제출 상태) ── -->
|
||||
{% if is_approver and req.status == '제출' %}
|
||||
<div class="vac-approve-box">
|
||||
<form method="post" action="/vacation/{{ req.id }}/approve" class="vac-inline-form">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">승인</button>
|
||||
</form>
|
||||
<form method="post" action="/vacation/{{ req.id }}/reject" class="vac-reject-form">
|
||||
<input class="erp-input" type="text" name="reject_reason" placeholder="반려 사유" required />
|
||||
<button type="submit" class="erp-btn erp-btn-danger">반려</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="erp-page-actions vac-detail-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/vacation/">◀◀ 달력</a>
|
||||
|
||||
{% if is_owner and req.status in ('작성중', '반려') %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/submit" class="vac-inline-form">
|
||||
<button type="submit" class="erp-btn erp-btn-outline">제출</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if req.status != '취소' and (is_owner or is_admin) %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/cancel" class="vac-inline-form"
|
||||
onsubmit="return confirm('이 휴가 신청을 취소 처리할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-outline">취소처리</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<a class="erp-btn erp-btn-outline vac-push-right" href="/vacation/{{ req.id }}/edit">수정</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530a" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
|
||||
{% set action = '/vacation/new' if mode == 'new' else '/vacation/' ~ req.id ~ '/edit' %}
|
||||
<form id="vac-form" method="post" action="{{ action }}" class="erp-card vac-form-card">
|
||||
|
||||
<div class="vac-form-grid">
|
||||
<label class="erp-field"><span>휴가 종류 *</span>
|
||||
<select class="erp-select" name="vacation_type" id="vac-type" required>
|
||||
{% for t in vacation_types %}
|
||||
<option value="{{ t }}" {% if req and req.vacation_type == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="erp-field"><span>시작일 *</span>
|
||||
<input class="erp-input" type="date" name="start_date" id="vac-start" required
|
||||
value="{{ req.start_date if req else default_date }}" />
|
||||
</label>
|
||||
|
||||
<label class="erp-field" id="vac-start-half-field"><span>시작 구분</span>
|
||||
<select class="erp-select" name="start_half" id="vac-start-half">
|
||||
{% for h in halves %}
|
||||
<option value="{{ h }}" {% if req and req.start_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="erp-field" id="vac-end-field"><span>종료일 *</span>
|
||||
<input class="erp-input" type="date" name="end_date" id="vac-end"
|
||||
value="{{ req.end_date if req else default_date }}" />
|
||||
</label>
|
||||
|
||||
<label class="erp-field" id="vac-end-half-field"><span>종료 구분</span>
|
||||
<select class="erp-select" name="end_half" id="vac-end-half">
|
||||
{% for h in halves %}
|
||||
<option value="{{ h }}" {% if req and req.end_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="erp-field vac-full"><span>사유</span>
|
||||
<textarea class="erp-input" name="reason" rows="3"
|
||||
placeholder="휴가 사유를 입력하세요.">{{ req.reason if req else '' }}</textarea>
|
||||
</label>
|
||||
|
||||
<div class="vac-days-preview">
|
||||
예상 사용 일수: <strong id="vac-days-out">—</strong>
|
||||
<span class="erp-muted">(주말 제외 · 공휴일은 저장 시 반영)</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-page-actions vac-form-actions">
|
||||
<button type="submit" class="erp-btn erp-btn-primary" name="action" value="submit">제출</button>
|
||||
<button type="submit" class="erp-btn erp-btn-outline" name="action"
|
||||
value="{{ 'save' if mode == 'edit' else 'draft' }}">작성중 저장</button>
|
||||
{% if mode == 'edit' %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/{{ req.id }}">취소</a>
|
||||
{% else %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/">취소</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}<script src="/static/vacation.js?v=20260530a" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,125 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530a" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
|
||||
<!-- 페이지 액션 -->
|
||||
<div class="vac-actions">
|
||||
<div class="vac-actions-main">
|
||||
<a class="erp-btn erp-btn-primary" href="/vacation/new">+ 휴가 신청</a>
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/pending">승인 대기
|
||||
{% if pending_count %}<span class="erp-badge erp-badge-inverse vac-mini">{{ pending_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/export.xlsx?year={{ year }}&month={{ month }}">엑셀</a>
|
||||
{% if is_admin %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/settings?year={{ year }}">설정</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="vac-balance">
|
||||
<span class="vac-bal-item">총 <strong>{{ balance.total_days }}</strong></span>
|
||||
<span class="vac-bal-sep">·</span>
|
||||
<span class="vac-bal-item">사용 <strong>{{ balance.used_days }}</strong></span>
|
||||
<span class="vac-bal-sep">·</span>
|
||||
<span class="vac-bal-item vac-bal-remain">잔여 <strong>{{ balance.remaining_days }}</strong></span>
|
||||
<span class="erp-muted vac-bal-year">{{ year }}년</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vac-layout">
|
||||
<!-- ── 왼쪽: 월간 달력 ── -->
|
||||
<div class="erp-card vac-cal-card">
|
||||
<div class="vac-cal-head">
|
||||
<a class="erp-btn erp-btn-outline vac-nav-btn"
|
||||
href="/vacation/?year={{ prev_y }}&month={{ prev_m }}">‹</a>
|
||||
<h2 class="vac-cal-title">{{ year }}년 {{ month }}월</h2>
|
||||
<a class="erp-btn erp-btn-outline vac-nav-btn"
|
||||
href="/vacation/?year={{ next_y }}&month={{ next_m }}">›</a>
|
||||
<a class="erp-btn erp-btn-outline vac-today-btn" href="/vacation/">오늘</a>
|
||||
</div>
|
||||
|
||||
<div class="vac-cal">
|
||||
<div class="vac-wd-row">
|
||||
{% for wd in weekdays %}
|
||||
<div class="vac-wd {% if loop.index0 == 0 %}vac-red{% elif loop.index0 == 6 %}vac-blue{% endif %}">{{ wd }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% for week in weeks %}
|
||||
<div class="vac-week" style="--lanes: {{ week.lane_count }}">
|
||||
{% for cell in week.days %}
|
||||
<a class="vac-day
|
||||
{% if not cell.in_month %}vac-out{% endif %}
|
||||
{% if cell.is_today %}vac-today{% endif %}
|
||||
{% if cell.is_selected %}vac-selected{% endif %}"
|
||||
style="grid-column: {{ loop.index }}; grid-row: 1;"
|
||||
href="/vacation/?year={{ year }}&month={{ month }}&date={{ cell.date }}"
|
||||
{% if cell.holiday_name %}title="{{ cell.holiday_name }}"{% endif %}>
|
||||
<span class="vac-day-num
|
||||
{% if cell.is_sunday or cell.is_holiday %}vac-red{% elif cell.is_saturday %}vac-blue{% endif %}">{{ cell.day }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% for bar in week.bars %}
|
||||
<a class="vac-bar {{ bar.status_class }}
|
||||
{% if bar.continues_left %}vac-bar-l{% endif %}
|
||||
{% if bar.continues_right %}vac-bar-r{% endif %}"
|
||||
style="grid-column: {{ bar.start_col + 1 }} / span {{ bar.span }}; grid-row: {{ bar.lane + 2 }};"
|
||||
href="/vacation/{{ bar.id }}"
|
||||
title="{{ bar.label }} ({{ bar.days }}일 · {{ bar.status }})">
|
||||
<span class="vac-bar-label">{{ bar.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="vac-legend">
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-submit"></i>제출</span>
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-approve"></i>승인</span>
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-reject"></i>반려</span>
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-cancel"></i>취소</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 오른쪽: 선택일 휴가 리스트 ── -->
|
||||
<div class="erp-card vac-list-card">
|
||||
<div class="vac-list-head">
|
||||
<h2>{{ selected_date }}</h2>
|
||||
<span class="erp-muted">{{ sel_requests|length }}건</span>
|
||||
</div>
|
||||
|
||||
{% if sel_requests %}
|
||||
<ul class="vac-list">
|
||||
{% for r in sel_requests %}
|
||||
<li class="vac-list-item">
|
||||
<a href="/vacation/{{ r.id }}" class="vac-list-link">
|
||||
<div class="vac-list-top">
|
||||
<strong>{{ r.owner_name or r.owner }}</strong>
|
||||
{% set badge = 'erp-badge-neutral' %}
|
||||
{% if r.status == '승인' %}{% set badge = 'erp-badge-success' %}
|
||||
{% elif r.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
|
||||
{% elif r.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||
<span class="erp-badge {{ badge }}">{{ r.status }}</span>
|
||||
</div>
|
||||
<div class="vac-list-meta erp-muted">
|
||||
{{ r.vacation_type }} · {{ r.days }}일 ·
|
||||
{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}
|
||||
</div>
|
||||
{% if r.reason %}<div class="vac-list-reason erp-muted">{{ r.reason }}</div>{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="erp-muted vac-empty">선택한 날짜의 휴가가 없습니다.
|
||||
<a href="/vacation/new">휴가 신청</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530a" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
<div class="erp-card">
|
||||
{% if items %}
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table vac-pending-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청자</th><th>휴가종류</th><th>기간</th><th>일수</th>
|
||||
<th>사유</th><th class="vac-act-col">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in items %}
|
||||
<tr>
|
||||
<td><a href="/vacation/{{ r.id }}">{{ r.owner_name or r.owner }}</a></td>
|
||||
<td>{{ r.vacation_type }}</td>
|
||||
<td>{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}</td>
|
||||
<td>{{ r.days }}</td>
|
||||
<td class="vac-reason-cell erp-muted">{{ r.reason }}</td>
|
||||
<td class="vac-act-col">
|
||||
<div class="vac-pending-acts">
|
||||
<form method="post" action="/vacation/{{ r.id }}/approve" class="vac-inline-form">
|
||||
<button type="submit" class="erp-btn erp-btn-primary erp-btn-sm">승인</button>
|
||||
</form>
|
||||
<form method="post" action="/vacation/{{ r.id }}/reject" class="vac-reject-form">
|
||||
<input class="erp-input erp-input-sm" type="text" name="reject_reason"
|
||||
placeholder="반려 사유" required />
|
||||
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">반려</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="erp-muted">승인 대기 중인 휴가 신청이 없습니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530a" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
|
||||
<div class="vac-settings-head">
|
||||
<form method="get" action="/vacation/settings" class="vac-year-form">
|
||||
<label class="erp-field vac-year-field"><span>연도</span>
|
||||
<input class="erp-input" type="number" name="year" value="{{ year }}" min="2020" max="2100" />
|
||||
</label>
|
||||
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="vac-settings-grid">
|
||||
|
||||
<!-- ── 공휴일 관리 ── -->
|
||||
<div class="erp-card vac-set-card">
|
||||
<div class="vac-card-head"><h2>공휴일 ({{ year }})</h2></div>
|
||||
|
||||
<form method="post" action="/vacation/settings/holidays" class="vac-holiday-form">
|
||||
<label class="erp-field"><span>날짜 *</span>
|
||||
<input class="erp-input" type="date" name="holiday_date" required value="{{ year }}-01-01" />
|
||||
</label>
|
||||
<label class="erp-field"><span>이름 *</span>
|
||||
<input class="erp-input" type="text" name="name" required placeholder="예: 신정" />
|
||||
</label>
|
||||
<label class="erp-field"><span>종류</span>
|
||||
<select class="erp-select" name="kind">
|
||||
<option value="public">공휴일</option>
|
||||
<option value="lunar">음력</option>
|
||||
<option value="substitute">대체</option>
|
||||
<option value="company">회사지정</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-check"><input type="checkbox" name="is_red" checked /> 빨강(달력 표시)</label>
|
||||
<button type="submit" class="erp-btn erp-btn-primary">추가 / 수정</button>
|
||||
</form>
|
||||
|
||||
<div class="erp-table-wrap vac-set-scroll">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th>날짜</th><th>이름</th><th>종류</th><th>빨강</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for h in holidays %}
|
||||
<tr>
|
||||
<td>{{ h.holiday_date }}</td>
|
||||
<td>{{ h.name }}</td>
|
||||
<td class="erp-muted">{{ h.kind }}</td>
|
||||
<td>{% if h.is_red %}●{% else %}○{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/vacation/settings/holidays/{{ h.id }}/delete"
|
||||
onsubmit="return confirm('{{ h.holiday_date }} {{ h.name }} 삭제?');">
|
||||
<input type="hidden" name="year" value="{{ year }}" />
|
||||
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="erp-muted">등록된 공휴일이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 연차 잔여 관리 ── -->
|
||||
<div class="erp-card vac-set-card">
|
||||
<div class="vac-card-head"><h2>연차 잔여 ({{ year }})</h2></div>
|
||||
|
||||
<form method="post" action="/vacation/settings/balances" class="vac-balance-form">
|
||||
<input type="hidden" name="year" value="{{ year }}" />
|
||||
<label class="erp-field"><span>이메일 *</span>
|
||||
<input class="erp-input" type="email" name="user_email" required
|
||||
placeholder="user@dbxcorp.co.kr" />
|
||||
</label>
|
||||
<label class="erp-field"><span>연차 일수 *</span>
|
||||
<input class="erp-input" type="number" name="total_days" step="0.5" min="0" required value="15" />
|
||||
</label>
|
||||
<label class="erp-field vac-full"><span>메모</span>
|
||||
<input class="erp-input" type="text" name="memo" placeholder="입사일/비고 등" />
|
||||
</label>
|
||||
<button type="submit" class="erp-btn erp-btn-primary">설정</button>
|
||||
</form>
|
||||
|
||||
<div class="erp-table-wrap vac-set-scroll">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th>이메일</th><th>연차</th><th>사용</th><th>메모</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in balances %}
|
||||
<tr>
|
||||
<td>{{ b.user_email }}</td>
|
||||
<td>{{ b.total_days }}</td>
|
||||
<td class="erp-muted">{{ b.used_days }}</td>
|
||||
<td class="erp-muted">{{ b.memo }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="erp-muted">설정된 연차가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="erp-muted vac-set-note">사용 일수는 승인된 휴가 합계로 자동 계산됩니다.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,189 @@
|
||||
/* 휴가 관리(vacation) 모듈 스타일.
|
||||
DESIGN.md 토큰 준수 — 모노크롬, 10/14px radius, 카드 padding 16px.
|
||||
캐시 버전: ?v=20260530a (변경 시 템플릿 head_extra 의 ?v= 도 함께 올린다) */
|
||||
|
||||
:root {
|
||||
--vac-ash: #e5e5e5;
|
||||
--vac-ghost: #f2f2f2;
|
||||
--vac-muted: #737373;
|
||||
--vac-black: #0a0a0a;
|
||||
--vac-red: #c22b10;
|
||||
--vac-blue: #1d4ed8;
|
||||
--vac-green: #10733a;
|
||||
}
|
||||
|
||||
.vac { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* ── 페이지 액션 + 잔여 요약 ── */
|
||||
.vac-actions {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.vac-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.vac-mini { margin-left: 4px; }
|
||||
.vac-balance {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
font-size: 14px; color: var(--vac-black);
|
||||
}
|
||||
.vac-balance strong { font-weight: 600; }
|
||||
.vac-bal-sep { color: var(--vac-ash); }
|
||||
.vac-bal-remain strong { color: var(--vac-green); }
|
||||
.vac-bal-year { margin-left: 6px; font-size: 12px; }
|
||||
|
||||
/* ── 레이아웃: 달력 좌 / 리스트 우 ── */
|
||||
.vac-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.vac-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── 달력 카드 ── */
|
||||
.vac-cal-card { padding: 16px; }
|
||||
.vac-cal-head {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
||||
}
|
||||
.vac-cal-title { font-size: 18px; font-weight: 600; margin: 0; letter-spacing: -0.45px; }
|
||||
.vac-nav-btn { padding: 4px 12px; font-size: 16px; line-height: 1; }
|
||||
.vac-today-btn { margin-left: auto; }
|
||||
|
||||
.vac-cal { border: 1px solid var(--vac-ash); border-radius: 10px; overflow: hidden; }
|
||||
|
||||
.vac-wd-row {
|
||||
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
background: var(--vac-ghost); border-bottom: 1px solid var(--vac-ash);
|
||||
}
|
||||
.vac-wd {
|
||||
text-align: center; padding: 6px 0; font-size: 13px; font-weight: 500;
|
||||
color: var(--vac-black);
|
||||
}
|
||||
|
||||
/* 주(week) — 1행=날짜, 2행+=bar lane */
|
||||
.vac-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-template-rows: 30px;
|
||||
grid-auto-rows: 21px;
|
||||
border-bottom: 1px solid var(--vac-ash);
|
||||
padding-bottom: 4px;
|
||||
position: relative;
|
||||
}
|
||||
.vac-week:last-child { border-bottom: none; }
|
||||
|
||||
.vac-day {
|
||||
border-right: 1px solid var(--vac-ash);
|
||||
padding: 4px 6px; text-decoration: none;
|
||||
display: block; min-height: 30px;
|
||||
}
|
||||
.vac-day:nth-child(7n) { border-right: none; }
|
||||
.vac-day-num { font-size: 13px; font-weight: 500; color: var(--vac-black); }
|
||||
.vac-out { background: #fafafa; }
|
||||
.vac-out .vac-day-num { color: #bbb; }
|
||||
.vac-today { background: #f5f5f5; }
|
||||
.vac-today .vac-day-num {
|
||||
background: var(--vac-black); color: #fff; border-radius: 9999px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
.vac-selected { box-shadow: inset 0 0 0 2px var(--vac-black); }
|
||||
.vac-red { color: var(--vac-red) !important; }
|
||||
.vac-blue { color: var(--vac-blue) !important; }
|
||||
|
||||
/* ── 휴가 bar (구글 달력 스타일) ── */
|
||||
.vac-bar {
|
||||
margin: 1px 3px; padding: 0 6px; height: 18px; line-height: 18px;
|
||||
border-radius: 6px; font-size: 11px; text-decoration: none;
|
||||
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||
align-self: center;
|
||||
}
|
||||
.vac-bar-label { pointer-events: none; }
|
||||
.vac-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; }
|
||||
.vac-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; }
|
||||
|
||||
.vac-bar-draft { background: #fff; border: 1px dashed var(--vac-muted); color: var(--vac-muted); }
|
||||
.vac-bar-submit { background: var(--vac-ghost); border: 1px solid var(--vac-muted); color: var(--vac-black); }
|
||||
.vac-bar-approve { background: var(--vac-black); color: #fff; }
|
||||
.vac-bar-reject { background: #fff; border: 1px solid var(--vac-red); color: var(--vac-red); text-decoration: line-through; }
|
||||
.vac-bar-cancel { background: #fff; border: 1px dashed var(--vac-ash); color: #bbb; opacity: 0.7; }
|
||||
|
||||
/* 범례 */
|
||||
.vac-legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.vac-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--vac-muted); }
|
||||
.vac-dot { width: 14px; height: 12px; border-radius: 4px; display: inline-block; }
|
||||
|
||||
/* ── 선택일 리스트 ── */
|
||||
.vac-list-card { padding: 16px; }
|
||||
.vac-list-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
|
||||
.vac-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
.vac-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.vac-list-link {
|
||||
display: block; text-decoration: none; color: var(--vac-black);
|
||||
border: 1px solid var(--vac-ash); border-radius: 10px; padding: 10px 12px;
|
||||
}
|
||||
.vac-list-link:hover { background: var(--vac-ghost); }
|
||||
.vac-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.vac-list-meta { font-size: 12px; margin-top: 4px; }
|
||||
.vac-list-reason { font-size: 12px; margin-top: 4px; }
|
||||
.vac-empty { padding: 12px 0; }
|
||||
|
||||
/* ── 신청 폼 ── */
|
||||
.vac-form-card { padding: 16px; max-width: 760px; }
|
||||
.vac-form-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px; margin-bottom: 12px;
|
||||
}
|
||||
.vac-full { grid-column: 1 / -1; }
|
||||
.vac-days-preview { margin: 12px 0; font-size: 14px; }
|
||||
.vac-days-preview strong { font-size: 16px; font-weight: 600; }
|
||||
.vac-form-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
|
||||
/* ── 상세 ── */
|
||||
.vac-detail-card { padding: 16px; max-width: 760px; }
|
||||
.vac-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.vac-detail-head h2 { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.vac-detail-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 24px; margin: 16px 0;
|
||||
}
|
||||
.vac-detail-grid dt { font-size: 12px; color: var(--vac-muted); margin-bottom: 2px; }
|
||||
.vac-detail-grid dd { margin: 0; font-size: 14px; }
|
||||
.vac-detail-block { margin: 12px 0; }
|
||||
.vac-detail-label { font-size: 12px; color: var(--vac-muted); margin-bottom: 4px; }
|
||||
.vac-reject-block p { color: var(--vac-red); }
|
||||
.vac-approve-box {
|
||||
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||
background: var(--vac-ghost); border-radius: 10px; padding: 12px; margin: 12px 0;
|
||||
}
|
||||
.vac-reject-form { display: flex; gap: 6px; align-items: center; }
|
||||
.vac-inline-form { display: inline; }
|
||||
.vac-detail-actions { display: flex; gap: 8px; margin-top: 16px; align-items: center; }
|
||||
.vac-push-right { margin-left: auto; }
|
||||
|
||||
/* ── 승인 대기 ── */
|
||||
.vac-pending-table .vac-act-col { width: 280px; }
|
||||
.vac-pending-acts { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.vac-reason-cell { max-width: 260px; }
|
||||
.erp-btn-sm { padding: 2px 10px; font-size: 12px; }
|
||||
.erp-input-sm { padding: 2px 8px; font-size: 12px; min-width: 140px; }
|
||||
|
||||
/* ── 설정 ── */
|
||||
.vac-settings-head { margin-bottom: 4px; }
|
||||
.vac-year-form { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.vac-year-field { max-width: 140px; }
|
||||
.vac-settings-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1100px) { .vac-settings-grid { grid-template-columns: 1fr; } }
|
||||
.vac-set-card { padding: 16px; }
|
||||
.vac-card-head h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; }
|
||||
.vac-holiday-form, .vac-balance-form {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px; margin-bottom: 12px; align-items: end;
|
||||
}
|
||||
.vac-holiday-form button, .vac-balance-form button { grid-column: 1 / -1; }
|
||||
.vac-check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.vac-set-scroll { max-height: 420px; overflow: auto; }
|
||||
.vac-set-note { font-size: 12px; margin-top: 8px; }
|
||||
@@ -0,0 +1,86 @@
|
||||
/* 휴가 신청 폼 — 종류에 따른 필드 토글 + 예상 일수 미리보기.
|
||||
서버(store.compute_days)가 공휴일 포함 정확히 재계산한다. 여기선 주말만 제외한 근사치. */
|
||||
(function () {
|
||||
"use strict";
|
||||
var form = document.getElementById("vac-form");
|
||||
if (!form) return;
|
||||
|
||||
var type = document.getElementById("vac-type");
|
||||
var start = document.getElementById("vac-start");
|
||||
var startHalf = document.getElementById("vac-start-half");
|
||||
var endField = document.getElementById("vac-end-field");
|
||||
var end = document.getElementById("vac-end");
|
||||
var endHalfField = document.getElementById("vac-end-half-field");
|
||||
var endHalf = document.getElementById("vac-end-half");
|
||||
var startHalfField = document.getElementById("vac-start-half-field");
|
||||
var out = document.getElementById("vac-days-out");
|
||||
|
||||
function isHalfType(t) { return t === "오전반차" || t === "오후반차"; }
|
||||
|
||||
function parseDate(s) {
|
||||
if (!s) return null;
|
||||
var p = s.split("-");
|
||||
if (p.length !== 3) return null;
|
||||
return new Date(+p[0], +p[1] - 1, +p[2]);
|
||||
}
|
||||
|
||||
function workingDays(sd, ed) {
|
||||
var n = 0;
|
||||
var d = new Date(sd.getTime());
|
||||
while (d <= ed) {
|
||||
var wd = d.getDay(); // 0=일, 6=토
|
||||
if (wd !== 0 && wd !== 6) n++;
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function isWorking(d) {
|
||||
var wd = d.getDay();
|
||||
return wd !== 0 && wd !== 6;
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
var t = type.value;
|
||||
var half = isHalfType(t);
|
||||
|
||||
// 반차 종류면 종료일/구분 숨김, 단일 0.5
|
||||
endField.style.display = half ? "none" : "";
|
||||
endHalfField.style.display = half ? "none" : "";
|
||||
startHalfField.style.display = half ? "none" : "";
|
||||
|
||||
if (half) {
|
||||
out.textContent = "0.5";
|
||||
return;
|
||||
}
|
||||
|
||||
var sd = parseDate(start.value);
|
||||
var ed = parseDate(end.value) || sd;
|
||||
if (!sd || !ed || ed < sd) { out.textContent = "—"; return; }
|
||||
|
||||
if (sd.getTime() === ed.getTime()) {
|
||||
out.textContent = (startHalf.value === "am" || startHalf.value === "pm") ? "0.5" : "1";
|
||||
return;
|
||||
}
|
||||
|
||||
var n = workingDays(sd, ed);
|
||||
var total = n;
|
||||
if ((startHalf.value === "am" || startHalf.value === "pm") && isWorking(sd)) total -= 0.5;
|
||||
if ((endHalf.value === "am" || endHalf.value === "pm") && isWorking(ed)) total -= 0.5;
|
||||
out.textContent = total > 0 ? String(total) : "0";
|
||||
}
|
||||
|
||||
// 시작일 변경 시 종료일이 비었거나 더 빠르면 맞춰줌
|
||||
start.addEventListener("change", function () {
|
||||
if (!end.value || parseDate(end.value) < parseDate(start.value)) {
|
||||
end.value = start.value;
|
||||
}
|
||||
recalc();
|
||||
});
|
||||
|
||||
[type, end, startHalf, endHalf].forEach(function (el) {
|
||||
if (el) el.addEventListener("change", recalc);
|
||||
});
|
||||
|
||||
recalc();
|
||||
})();
|
||||
@@ -222,6 +222,40 @@ cd /opt/www/main && docker compose up -d --build
|
||||
|
||||
---
|
||||
|
||||
## vacation_db 스키마 / 초기화
|
||||
|
||||
DDL: `scripts/sql/vacation_db_init.sql` (멱등). DB·역할(`vacation_app`)·테이블·인덱스·트리거·2026 공휴일 seed 를 한 번에 생성. **JSON 폴백 없음** — `VACATION_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
|
||||
|
||||
테이블:
|
||||
|
||||
| 테이블 | 용도 |
|
||||
| --- | --- |
|
||||
| `vacation_requests` | 휴가 신청(헤더). 종류/기간/시작·종료 구분(full/am/pm)/일수/사유/상태/승인자/반려사유 |
|
||||
| `vacation_holidays` | 공휴일(`holiday_date` UNIQUE). `is_red=true` 면 달력 빨강 + 일수 계산 제외. 관리자가 settings 에서 추가/수정/삭제 |
|
||||
| `vacation_balances` | 사용자별 연차(`UNIQUE(user_email, year)`). `total_days` 설정, 사용일수는 승인 휴가 합계로 자동 계산 |
|
||||
|
||||
`status` 허용값: `작성중`, `제출`, `승인`, `반려`, `취소`. 워크플로: 작성중/반려 → 제출 → 승인|반려. 삭제는 기본 soft delete(`status='취소'`). 수정은 작성중/반려 상태에서 본인만.
|
||||
|
||||
휴가 일수는 서버(`store.compute_days`)에서 재계산: 주말 + `vacation_holidays(is_red)` 제외, 오전/오후 반차 0.5일, 시작/종료 반차는 각 0.5 차감. 클라이언트 계산은 미리보기(주말만 제외)용.
|
||||
|
||||
권한: `vacation`(접근) / `vacation_approver`(승인·반려). admin 은 항상 통과. 공휴일·연차 설정은 admin 전용.
|
||||
|
||||
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
||||
|
||||
```bash
|
||||
read -s -p "vacation_app password: " APP_PWD; echo
|
||||
docker exec -i postgres-db psql -U postgres \
|
||||
-v app_password="$APP_PWD" \
|
||||
< scripts/sql/vacation_db_init.sql
|
||||
# main-app .env 에 추가:
|
||||
# VACATION_DB_URL=postgresql://vacation_app:<APP_PWD>@postgres-db:5432/vacation_db
|
||||
cd /opt/www/main && docker compose up -d --build
|
||||
```
|
||||
|
||||
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. 공휴일은 연도별로 다르므로 settings 화면에서 추가/수정.
|
||||
|
||||
---
|
||||
|
||||
## 백업 / 복구 (안전 절차)
|
||||
|
||||
### 백업
|
||||
|
||||
@@ -117,6 +117,7 @@ docker compose down # 컨테이너 제거 (볼륨 유지)
|
||||
| `*_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 폴백 |
|
||||
| `CUPANG_DB_URL` | 쿠팡 밀크런 DB DSN (예: `postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내) |
|
||||
| `VACATION_DB_URL` | 휴가 관리 DB DSN (예: `postgresql://vacation_app:<pwd>@postgres-db:5432/vacation_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내). 권한키 `vacation`/`vacation_approver` |
|
||||
| `ITEMCODE_DB_URL` | 상품 검색용 itemcode_db 읽기 전용 DSN. 미설정 시 검색 비활성(수동 입력). 테이블/컬럼은 `ITEMCODE_TABLE`/`ITEMCODE_CODE_COL`/`ITEMCODE_NAME_COL`/`ITEMCODE_TYPE_COL` 또는 `ITEMCODE_SEARCH_SQL` 로 지정 |
|
||||
| `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 |
|
||||
|
||||
|
||||
@@ -88,12 +88,7 @@ app/modules/<name>/
|
||||
| `return_db` | 반품·교환·CS 데이터 |
|
||||
| `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) |
|
||||
| `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) |
|
||||
|
||||
### DB 후보
|
||||
|
||||
| 모듈 | 현재 저장소 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 휴가 | (미개발) | `vacation_db` (승인 후 생성) |
|
||||
| `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) |
|
||||
|
||||
> 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`.
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
-- =====================================================================
|
||||
-- vacation_db 초기화 스크립트 (PostgreSQL) — 휴가 관리 모듈
|
||||
-- =====================================================================
|
||||
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
|
||||
--
|
||||
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
|
||||
--
|
||||
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
|
||||
--
|
||||
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
|
||||
-- read -s -p "vacation_app password: " APP_PWD; echo
|
||||
-- docker exec -i postgres-db psql -U postgres \
|
||||
-- -v app_password="$APP_PWD" \
|
||||
-- < scripts/sql/vacation_db_init.sql
|
||||
--
|
||||
-- 2) main-app .env 에 연결 정보 등록
|
||||
-- VACATION_DB_URL=postgresql://vacation_app:<APP_PWD>@postgres-db:5432/vacation_db
|
||||
--
|
||||
-- 3) main-app 재기동
|
||||
-- cd /opt/www/main && docker compose up -d --build
|
||||
--
|
||||
-- 주의:
|
||||
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
|
||||
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- DB 가 없을 때만 생성
|
||||
SELECT 'CREATE DATABASE vacation_db ENCODING ''UTF8'' TEMPLATE template0'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'vacation_db')
|
||||
\gexec
|
||||
|
||||
-- 앱 전용 로그인 역할 (expense_app / cupang_app 패턴과 동일)
|
||||
SELECT 'CREATE ROLE vacation_app LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vacation_app')
|
||||
\gexec
|
||||
|
||||
-- 항상 최신 비밀번호로 동기화
|
||||
SELECT 'ALTER ROLE vacation_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
\gexec
|
||||
|
||||
GRANT CONNECT ON DATABASE vacation_db TO vacation_app;
|
||||
|
||||
-- vacation_db 컨텍스트로 전환
|
||||
\connect vacation_db
|
||||
|
||||
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
|
||||
CREATE OR REPLACE FUNCTION vacation_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 1) 휴가 신청 (헤더)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS vacation_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
owner_name TEXT NOT NULL DEFAULT '',
|
||||
vacation_type TEXT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
start_half TEXT NOT NULL DEFAULT 'full',
|
||||
end_half TEXT NOT NULL DEFAULT 'full',
|
||||
days NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
approver_email TEXT,
|
||||
decided_at TIMESTAMPTZ,
|
||||
reject_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_req_owner_start ON vacation_requests (owner, start_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_req_status ON vacation_requests (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_req_range ON vacation_requests (start_date, end_date);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vacation_requests_updated ON vacation_requests;
|
||||
CREATE TRIGGER trg_vacation_requests_updated
|
||||
BEFORE UPDATE ON vacation_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 2) 공휴일 (달력 빨강 표시 + 휴가일수 계산 제외 기준)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS vacation_holidays (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
holiday_date DATE NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'public',
|
||||
is_red BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_holidays_date ON vacation_holidays (holiday_date);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vacation_holidays_updated ON vacation_holidays;
|
||||
CREATE TRIGGER trg_vacation_holidays_updated
|
||||
BEFORE UPDATE ON vacation_holidays
|
||||
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 3) 사용자별 연차 잔여 (연도 단위)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS vacation_balances (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_email TEXT NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
total_days NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
used_days NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_email, year)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_balances_user_year ON vacation_balances (user_email, year);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vacation_balances_updated ON vacation_balances;
|
||||
CREATE TRIGGER trg_vacation_balances_updated
|
||||
BEFORE UPDATE ON vacation_balances
|
||||
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 4) 공휴일 seed — 2026년 대한민국 공휴일 (멱등: ON CONFLICT DO NOTHING)
|
||||
-- 연도별로 달라지므로 settings 화면에서 추가/수정/삭제 가능.
|
||||
-- KASI(한국천문연구원) 발표 기준. 새 연도는 settings 또는 본 seed 추가.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
INSERT INTO vacation_holidays (holiday_date, name, kind) VALUES
|
||||
('2026-01-01', '신정', 'public'),
|
||||
('2026-02-16', '설날 연휴', 'lunar'),
|
||||
('2026-02-17', '설날', 'lunar'),
|
||||
('2026-02-18', '설날 연휴', 'lunar'),
|
||||
('2026-03-01', '삼일절', 'public'),
|
||||
('2026-03-02', '삼일절 대체', 'substitute'),
|
||||
('2026-05-05', '어린이날', 'public'),
|
||||
('2026-05-24', '부처님오신날', 'lunar'),
|
||||
('2026-05-25', '부처님오신날 대체', 'substitute'),
|
||||
('2026-06-06', '현충일', 'public'),
|
||||
('2026-08-15', '광복절', 'public'),
|
||||
('2026-08-17', '광복절 대체', 'substitute'),
|
||||
('2026-09-24', '추석 연휴', 'lunar'),
|
||||
('2026-09-25', '추석', 'lunar'),
|
||||
('2026-09-26', '추석 연휴', 'lunar'),
|
||||
('2026-09-28', '추석 대체', 'substitute'),
|
||||
('2026-10-03', '개천절', 'public'),
|
||||
('2026-10-05', '개천절 대체', 'substitute'),
|
||||
('2026-10-09', '한글날', 'public'),
|
||||
('2026-12-25', '성탄절', 'public')
|
||||
ON CONFLICT (holiday_date) DO NOTHING;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 5) 권한 (vacation_app: CRUD only)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
GRANT USAGE ON SCHEMA public TO vacation_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
||||
vacation_requests, vacation_holidays, vacation_balances
|
||||
TO vacation_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vacation_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO vacation_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO vacation_app;
|
||||
|
||||
SELECT 'vacation_db ready' AS status;
|
||||
Reference in New Issue
Block a user