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:
@@ -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 %}
|
||||
Reference in New Issue
Block a user