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:
2026-05-30 08:20:20 +09:00
parent 3cf22513ca
commit 269e779129
18 changed files with 2354 additions and 10 deletions
@@ -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 %}